feat: PWA + full web app — service worker, routing, mobile responsive
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
@@ -2,8 +2,21 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Project Hub (frontend)</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#090910" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="description" content="Project Hub — all your projects, tasks, and features in one place." />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Project Hub" />
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="Project Hub" />
|
||||
<meta property="og:description" content="All your projects, tasks, and features in one place." />
|
||||
<meta property="og:type" content="website" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon.svg" />
|
||||
<title>Project Hub</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
3963
frontend/package-lock.json
generated
3963
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -15,17 +15,20 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1"
|
||||
"react-dom": "18.3.1",
|
||||
"react-router-dom": "^6.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "5.0.0",
|
||||
"@vitejs/plugin-react": "5.0.0",
|
||||
"concurrently": "^8.0.0",
|
||||
"electron": "^26.0.0",
|
||||
"electron-builder": "^24.0.0",
|
||||
"concurrently": "^8.0.0",
|
||||
"wait-on": "^7.0.0"
|
||||
}
|
||||
,
|
||||
"vite": "5.0.0",
|
||||
"vite-plugin-pwa": "^0.21.1",
|
||||
"wait-on": "^7.0.0",
|
||||
"workbox-build": "^7.4.0",
|
||||
"workbox-window": "^7.0.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.projecthub.app",
|
||||
"productName": "Project Hub",
|
||||
@@ -38,6 +41,5 @@
|
||||
"buildResources": "build"
|
||||
}
|
||||
},
|
||||
"devDependenciesMeta": {
|
||||
}
|
||||
"devDependenciesMeta": {}
|
||||
}
|
||||
|
||||
11
frontend/public/icons/icon.svg
Normal file
11
frontend/public/icons/icon.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="100" fill="#090910"/>
|
||||
<polygon points="290,60 150,280 240,280 222,452 370,220 272,220" fill="#6366f1"/>
|
||||
<polygon points="290,60 150,280 240,280 222,452 370,220 272,220" fill="url(#g)" opacity="0.4"/>
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#a78bfa"/>
|
||||
<stop offset="100%" stop-color="#818cf8" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 521 B |
@@ -1,9 +1,14 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { api, ATTACHMENT_MAX_FILE_SIZE_LABEL } from './api.js'
|
||||
import GanttView from './components/GanttView.jsx'
|
||||
import CalendarView from './components/CalendarView.jsx'
|
||||
import BurndownChart from './components/BurndownChart.jsx'
|
||||
|
||||
// Map URL path → view state
|
||||
const pathToView = p => p === '/tasks' ? 'mytasks' : p === '/members' ? 'members' : 'projects'
|
||||
const viewToPath = v => v === 'mytasks' ? '/tasks' : v === 'members' ? '/members' : '/'
|
||||
|
||||
// The full App adapted from your original inline HTML (keeps UI and behavior).
|
||||
|
||||
// Dual storage: window.storage if available, else localStorage
|
||||
@@ -95,6 +100,16 @@ const normalizeProject = project => ({
|
||||
milestones: Array.isArray(project?.milestones) ? project.milestones : [],
|
||||
features: Array.isArray(project?.features) ? project.features.map(normalizeFeature) : [],
|
||||
})
|
||||
const explainApiError = error => {
|
||||
const message = String(error?.message || '').trim()
|
||||
if (!message) return 'Request failed'
|
||||
try {
|
||||
const parsed = JSON.parse(message)
|
||||
return parsed?.error || message
|
||||
} catch {
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
// 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",
|
||||
@@ -149,6 +164,10 @@ const btn = (x = {}) => ({ border: 'none', borderRadius: 7, cursor: 'pointer', f
|
||||
// APP
|
||||
// ════════════════════════════════════════════════════════════
|
||||
function App(){
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
// Derive initial view from URL
|
||||
const [projects, setProjects] = useState([])
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [sel, setSel] = useState(null)
|
||||
@@ -157,14 +176,56 @@ function App(){
|
||||
const [search, setSearch] = useState('')
|
||||
const [fSt, setFSt] = useState('all')
|
||||
const [delId, setDelId] = useState(null)
|
||||
const [view, setView] = useState('projects')
|
||||
const [view, setView] = useState(() => pathToView(location.pathname))
|
||||
|
||||
// Keep view in sync when browser back/forward is used
|
||||
useEffect(() => {
|
||||
setView(pathToView(location.pathname))
|
||||
}, [location.pathname])
|
||||
|
||||
// Navigate to a view — updates both state AND URL
|
||||
const goTo = (v) => {
|
||||
setView(v)
|
||||
navigate(viewToPath(v), { replace: false })
|
||||
}
|
||||
const [allMembers, setAllMembers] = useState([])
|
||||
const [serverOpen, setServerOpen] = useState(false)
|
||||
const [apiUrlInput, setApiUrlInput] = useState('')
|
||||
const [apiUrlMsg, setApiUrlMsg] = useState('')
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const [importDefaults, setImportDefaults] = useState({ mode: 'new-project', targetProjectId: '' })
|
||||
const [featureStatusFilter, setFeatureStatusFilter] = useState('all')
|
||||
const [projectSort, setProjectSort] = useState('feature-updated-desc')
|
||||
|
||||
const refreshFromApi = async (projectIdToSelect = sel?.id || '') => {
|
||||
try {
|
||||
const [memberList, projectList] = await Promise.all([
|
||||
api.getMembers().catch(() => null),
|
||||
api.getProjects().catch(() => null),
|
||||
])
|
||||
|
||||
if (Array.isArray(memberList)) setAllMembers(memberList)
|
||||
if (!Array.isArray(projectList)) return null
|
||||
|
||||
const normalizedProjects = projectList.map(normalizeProject)
|
||||
setProjects(normalizedProjects)
|
||||
if (projectIdToSelect) {
|
||||
setSel(normalizedProjects.find(project => project.id === projectIdToSelect) || null)
|
||||
}
|
||||
return normalizedProjects
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const openImport = (targetProjectId = '') => {
|
||||
setImportDefaults({
|
||||
mode: targetProjectId ? 'existing-project' : 'new-project',
|
||||
targetProjectId,
|
||||
})
|
||||
setImportOpen(true)
|
||||
}
|
||||
|
||||
useEffect(()=>{
|
||||
(async()=>{
|
||||
try {
|
||||
@@ -172,11 +233,8 @@ function App(){
|
||||
setApiUrlInput(currentApiUrl)
|
||||
} catch {}
|
||||
|
||||
try { const m = await api.getMembers(); setAllMembers(m) } catch {}
|
||||
try {
|
||||
const bp = (await api.getProjects()).map(normalizeProject)
|
||||
if(bp.length > 0){ setProjects(bp); setLoaded(true); return }
|
||||
} catch {}
|
||||
const serverProjects = await refreshFromApi()
|
||||
if(serverProjects?.length > 0){ setLoaded(true); return }
|
||||
let ps
|
||||
try{
|
||||
const r = await persist.get(SKEY)
|
||||
@@ -335,30 +393,35 @@ function App(){
|
||||
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 className="app-header" 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('mytasks')} style={{ ...btn(), padding: '6px 14px', fontSize: 12, fontWeight: 500, background: view==='mytasks' ? 'rgba(99,102,241,0.12)' : 'transparent', color: view==='mytasks' ? '#818cf8' : '#64748b' }}>My Tasks</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 className="app-header-nav" style={{ display: 'flex', gap: 3 }}>
|
||||
<button onClick={()=>goTo('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={()=>goTo('mytasks')} style={{ ...btn(), padding: '6px 14px', fontSize: 12, fontWeight: 500, background: view==='mytasks' ? 'rgba(99,102,241,0.12)' : 'transparent', color: view==='mytasks' ? '#818cf8' : '#64748b' }}>My Tasks</button>
|
||||
<button onClick={()=>goTo('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>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button onClick={() => { setApiUrlMsg(''); setServerOpen(true) }} style={{ ...btn(), background: 'transparent', color: '#94a3b8', border: '1px solid #252540', padding: '7px 12px', fontSize: 12, fontWeight: 500 }}>
|
||||
Server
|
||||
</button>
|
||||
{view === 'projects' && <button onClick={()=>setEditing({name:'',description:'',status:'planning',color:COLORS[0],startDate:'',dueDate:'',members:[],tasks:[],milestones:[],features:[]})} style={{ ...btn(), background: '#6366f1', color: '#fff', padding: '8px 15px', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Plus size={14}/> New Project
|
||||
</button>}
|
||||
{view === 'projects' && <>
|
||||
<button onClick={() => openImport()} style={{ ...btn(), background: 'transparent', color: '#94a3b8', border: '1px solid #252540', padding: '7px 12px', fontSize: 12, fontWeight: 500 }}>
|
||||
Import CSV
|
||||
</button>
|
||||
<button onClick={()=>setEditing({name:'',description:'',status:'planning',color:COLORS[0],startDate:'',dueDate:'',members:[],tasks:[],milestones:[],features:[]})} style={{ ...btn(), background: '#6366f1', color: '#fff', padding: '8px 15px', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Plus size={14}/> New Project
|
||||
</button>
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '22px 24px', maxWidth: 1400, margin: '0 auto' }}>
|
||||
{view === 'mytasks' && <MyTasksView projects={projects} allMembers={allMembers} onSelectProject={id => { const p = projects.find(x => x.id === id); if(p) { setSel(p); setTab('tasks'); setView('projects') }}} />}
|
||||
<div className="page-content" style={{ padding: '22px 24px', maxWidth: 1400, margin: '0 auto' }}>
|
||||
{view === 'mytasks' && <MyTasksView projects={projects} allMembers={allMembers} onSelectProject={id => { const p = projects.find(x => x.id === id); if(p) { setSel(p); setTab('tasks'); goTo('projects') }}} />}
|
||||
{view === 'members' && <MembersPage members={allMembers}
|
||||
onAdd={async m => {
|
||||
const temp = { ...m, id: uid() }
|
||||
@@ -376,7 +439,7 @@ function App(){
|
||||
/>}
|
||||
{/* Stats */}
|
||||
{view === 'projects' && <>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: 14, marginBottom: 24 }}>
|
||||
<div className="stats-grid" 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>
|
||||
@@ -387,7 +450,7 @@ function App(){
|
||||
</div>
|
||||
|
||||
{/* Search + Filter */}
|
||||
<div style={{ display: 'flex', gap: 10, marginBottom: 20, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<div className="filter-row" style={{ display: 'flex', gap: 10, marginBottom: 20, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<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 }} />
|
||||
@@ -419,15 +482,27 @@ function App(){
|
||||
<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 }}>
|
||||
<div className="cards-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(310px,1fr))', gap: 18 }}>
|
||||
{visibleProjects.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} onTaskCreate={createTaskForProject} onTaskUpdate={updateTaskForProject} onTaskDelete={deleteTaskForProject} onFeatureCreate={createFeatureForProject} onFeatureUpdate={updateFeatureForProject} onFeatureDelete={deleteFeatureForProject} allMembers={allMembers} />}
|
||||
{sel && <Panel project={sel} tab={tab} setTab={setTab} onClose={() => setSel(null)} onEdit={() => setEditing(sel)} onImport={() => openImport(sel.id)} onChange={change} onTaskCreate={createTaskForProject} onTaskUpdate={updateTaskForProject} onTaskDelete={deleteTaskForProject} onFeatureCreate={createFeatureForProject} onFeatureUpdate={updateFeatureForProject} onFeatureDelete={deleteFeatureForProject} allMembers={allMembers} />}
|
||||
{editing !== null && <FormModal project={editing} onSave={save} onClose={() => setEditing(null)} />}
|
||||
{importOpen && <ImportModal
|
||||
projects={projects}
|
||||
initialMode={importDefaults.mode}
|
||||
initialProjectId={importDefaults.targetProjectId}
|
||||
onClose={() => setImportOpen(false)}
|
||||
onApplied={async projectId => {
|
||||
await refreshFromApi(projectId)
|
||||
setView('projects')
|
||||
setTab('tasks')
|
||||
setImportOpen(false)
|
||||
}}
|
||||
/>}
|
||||
{delId && (
|
||||
<Overlay>
|
||||
<div style={{ background: '#0d0d1a', border: '1px solid #181828', borderRadius: 14, padding: 26, width: 340 }}>
|
||||
@@ -536,7 +611,7 @@ function Card({ project: p, onOpen, onEdit, onDel }){
|
||||
}
|
||||
|
||||
// ── Detail Panel ──
|
||||
function Panel({ project: p, tab, setTab, onClose, onEdit, onChange, onTaskCreate, onTaskUpdate, onTaskDelete, onFeatureCreate, onFeatureUpdate, onFeatureDelete, allMembers = [] }){
|
||||
function Panel({ project: p, tab, setTab, onClose, onEdit, onImport, onChange, onTaskCreate, onTaskUpdate, onTaskDelete, onFeatureCreate, onFeatureUpdate, onFeatureDelete, allMembers = [] }){
|
||||
const pr = prog(p.tasks), s = STATUS[p.status] || STATUS.planning
|
||||
const upTask = (id, u) => {
|
||||
const task = p.tasks.find(t => t.id === id)
|
||||
@@ -571,12 +646,13 @@ function Panel({ project: p, tab, setTab, onClose, onEdit, onChange, onTaskCreat
|
||||
const delFeature = featureId => onFeatureDelete?.(p.id, featureId)
|
||||
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 className="panel-backdrop" style={{ flex: 1, background: 'rgba(0,0,0,0.55)' }} onClick={onClose} />
|
||||
<div className="panel-drawer" style={{ width: 540, background: '#090910', borderLeft: '1px solid #181828', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="panel-header" 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={onImport} style={{ ...btn(), background: 'transparent', color: '#94a3b8', border: '1px solid #252540', padding: '5px 11px', fontSize: 12, fontWeight: 500 }}>Import CSV</button>
|
||||
<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>
|
||||
@@ -1034,6 +1110,207 @@ function FormModal({ project, onSave, onClose }){
|
||||
)
|
||||
}
|
||||
|
||||
function ImportModal({ projects, initialMode = 'new-project', initialProjectId = '', onClose, onApplied }){
|
||||
const [mode, setMode] = useState(initialMode)
|
||||
const [targetProjectId, setTargetProjectId] = useState(initialProjectId)
|
||||
const [createMissingMembers, setCreateMissingMembers] = useState(true)
|
||||
const [duplicateStrategy, setDuplicateStrategy] = useState(initialMode === 'existing-project' ? 'skip' : 'create')
|
||||
const [file, setFile] = useState(null)
|
||||
const [preview, setPreview] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
|
||||
const resetPreview = () => {
|
||||
setPreview(null)
|
||||
setMsg('')
|
||||
}
|
||||
|
||||
const runPreview = async () => {
|
||||
if (!file) {
|
||||
setMsg('Choose a CSV file to preview.')
|
||||
return
|
||||
}
|
||||
if (mode === 'existing-project' && !targetProjectId) {
|
||||
setMsg('Select a target project for task import.')
|
||||
return
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
try {
|
||||
const result = await api.previewImport({
|
||||
file,
|
||||
mode,
|
||||
targetProjectId,
|
||||
createMissingMembers,
|
||||
duplicateStrategy,
|
||||
})
|
||||
setPreview(result)
|
||||
if (result.errors?.length) setMsg('Preview found issues that need to be fixed in the CSV.')
|
||||
} catch (error) {
|
||||
setPreview(null)
|
||||
setMsg(explainApiError(error))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const applyImport = async () => {
|
||||
if (!preview || preview.errors?.length) return
|
||||
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
try {
|
||||
const result = await api.applyImport({
|
||||
...preview,
|
||||
mode,
|
||||
options: {
|
||||
...preview.options,
|
||||
targetProjectId,
|
||||
createMissingMembers,
|
||||
duplicateStrategy,
|
||||
},
|
||||
})
|
||||
await onApplied?.(result?.project?.id)
|
||||
} catch (error) {
|
||||
setMsg(explainApiError(error))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const previewTasks = preview?.tasks?.slice(0, 8) || []
|
||||
|
||||
return (
|
||||
<Overlay>
|
||||
<div style={{ background: '#0d0d1a', border: '1px solid #181828', borderRadius: 14, padding: 24, width: '100%', maxWidth: 760, maxHeight: '88vh', overflowY: 'auto' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 17, fontWeight: 700, color: '#f1f5f9' }}>Import CSV</div>
|
||||
<div style={{ fontSize: 12, color: '#64748b', marginTop: 4 }}>Phase 1 supports CSV task import. Markdown project briefs can sit on top of this flow next.</div>
|
||||
</div>
|
||||
<button onClick={onClose} style={{ ...btn(), background: 'transparent', color: '#64748b', padding: 4 }}><X size={17} /></button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
|
||||
<div>
|
||||
<Lbl>Import Mode</Lbl>
|
||||
<select value={mode} onChange={e => { setMode(e.target.value); resetPreview(); if (e.target.value === 'new-project') setDuplicateStrategy('create') }} style={inp()}>
|
||||
<option value="new-project">Create a new project from CSV</option>
|
||||
<option value="existing-project">Add tasks to an existing project</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Lbl>Duplicate Handling</Lbl>
|
||||
<select value={duplicateStrategy} onChange={e => { setDuplicateStrategy(e.target.value); resetPreview() }} style={inp()}>
|
||||
<option value="skip">Skip duplicates</option>
|
||||
<option value="create">Create all rows</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'existing-project' && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Lbl>Target Project</Lbl>
|
||||
<select value={targetProjectId} onChange={e => { setTargetProjectId(e.target.value); resetPreview() }} style={inp()}>
|
||||
<option value="">Select a project…</option>
|
||||
{projects.map(project => <option key={project.id} value={project.id}>{project.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Lbl>CSV File</Lbl>
|
||||
<input type="file" accept=".csv,text/csv" onChange={e => { setFile(e.target.files?.[0] || null); resetPreview() }} style={inp({ padding: '7px 9px' })} />
|
||||
</div>
|
||||
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: '#94a3b8', marginBottom: 14 }}>
|
||||
<input type="checkbox" checked={createMissingMembers} onChange={e => { setCreateMissingMembers(e.target.checked); resetPreview() }} />
|
||||
Create missing roster members and assign them to the project when assignee details are present.
|
||||
</label>
|
||||
|
||||
<div style={{ background: '#090910', border: '1px solid #181828', borderRadius: 10, padding: 12, marginBottom: 14 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#94a3b8', marginBottom: 6 }}>Supported CSV columns</div>
|
||||
<div style={{ fontSize: 11, color: '#64748b', lineHeight: 1.55 }}>
|
||||
title, description, status, priority, start_date, due_date, assigned_to_name, assigned_to_email, assigned_to_role, estimated_hours, recurrence, subtasks, project_name, project_description, project_status, project_color, project_start_date, project_due_date
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#475569', marginTop: 8 }}>Use <span style={{ color: '#94a3b8' }}>subtasks</span> with values separated by <span style={{ color: '#94a3b8' }}>|</span> or <span style={{ color: '#94a3b8' }}>;</span>.</div>
|
||||
</div>
|
||||
|
||||
{msg && <div style={{ fontSize: 12, color: preview?.errors?.length ? '#fca5a5' : '#94a3b8', marginBottom: 12 }}>{msg}</div>}
|
||||
|
||||
{preview && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: 10, marginBottom: 12 }}>
|
||||
{[
|
||||
['Rows', preview.summary?.rowCount || 0],
|
||||
['Tasks To Import', preview.summary?.importableTaskCount || 0],
|
||||
['Duplicates', preview.summary?.duplicateCount || 0],
|
||||
['Members To Create', preview.summary?.memberCreateCount || 0],
|
||||
].map(([label, value]) => (
|
||||
<div key={label} style={{ background: '#090910', border: '1px solid #181828', borderRadius: 10, padding: '11px 12px' }}>
|
||||
<div style={{ fontSize: 11, color: '#475569' }}>{label}</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, color: '#f1f5f9', marginTop: 5 }}>{value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ background: '#090910', border: '1px solid #181828', borderRadius: 10, padding: 12, marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#94a3b8', marginBottom: 6 }}>{preview.mode === 'existing-project' ? 'Target project' : 'New project preview'}</div>
|
||||
<div style={{ fontSize: 14, color: '#f1f5f9', fontWeight: 600 }}>{preview.project?.name || 'Untitled project'}</div>
|
||||
{preview.project?.description && <div style={{ fontSize: 12, color: '#64748b', marginTop: 4 }}>{preview.project.description}</div>}
|
||||
</div>
|
||||
|
||||
{preview.errors?.length > 0 && (
|
||||
<div style={{ background: 'rgba(127,29,29,0.22)', border: '1px solid rgba(248,113,113,0.35)', borderRadius: 10, padding: 12, marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#fecaca', marginBottom: 6 }}>Errors</div>
|
||||
{preview.errors.map(error => <div key={error} style={{ fontSize: 12, color: '#fecaca', marginBottom: 4 }}>{error}</div>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview.warnings?.length > 0 && (
|
||||
<div style={{ background: 'rgba(120,53,15,0.22)', border: '1px solid rgba(251,191,36,0.35)', borderRadius: 10, padding: 12, marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#fde68a', marginBottom: 6 }}>Warnings</div>
|
||||
{preview.warnings.slice(0, 6).map(warning => <div key={warning} style={{ fontSize: 12, color: '#fde68a', marginBottom: 4 }}>{warning}</div>)}
|
||||
{preview.warnings.length > 6 && <div style={{ fontSize: 11, color: '#fcd34d' }}>+ {preview.warnings.length - 6} more warnings</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ background: '#090910', border: '1px solid #181828', borderRadius: 10, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1.7fr 0.8fr 0.8fr 1fr', gap: 10, padding: '10px 12px', borderBottom: '1px solid #181828', fontSize: 10, fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||
<div>Task</div>
|
||||
<div>Status</div>
|
||||
<div>Assignee</div>
|
||||
<div>Action</div>
|
||||
</div>
|
||||
{previewTasks.map(task => (
|
||||
<div key={`${task.rowNumber}-${task.title}`} style={{ display: 'grid', gridTemplateColumns: '1.7fr 0.8fr 0.8fr 1fr', gap: 10, padding: '10px 12px', borderBottom: '1px solid #181828', fontSize: 12, color: '#e2e8f0' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{task.title}</div>
|
||||
<div style={{ fontSize: 10, color: '#64748b', marginTop: 4 }}>Row {task.rowNumber}{task.dueDate ? ` · Due ${fmt(task.dueDate)}` : ''}</div>
|
||||
</div>
|
||||
<div style={{ color: '#94a3b8' }}>{task.status}</div>
|
||||
<div style={{ color: '#94a3b8' }}>{task.assignee?.name || task.assignee?.email || 'Unassigned'}</div>
|
||||
<div style={{ color: task.willImport ? '#86efac' : '#fca5a5' }}>{task.willImport ? task.assignmentAction.replace(/-/g, ' ') : 'skip duplicate'}</div>
|
||||
</div>
|
||||
))}
|
||||
{preview.tasks?.length > previewTasks.length && <div style={{ padding: '10px 12px', fontSize: 11, color: '#64748b' }}>Showing {previewTasks.length} of {preview.tasks.length} rows</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button onClick={onClose} style={{ ...btn(), padding: '8px 14px', background: 'transparent', border: '1px solid #181828', color: '#94a3b8', fontWeight: 500 }}>Close</button>
|
||||
<button onClick={runPreview} disabled={busy} style={{ ...btn(), padding: '8px 14px', background: '#181828', color: '#e2e8f0', opacity: busy ? 0.7 : 1 }}>Preview</button>
|
||||
<button onClick={applyImport} disabled={busy || !preview || preview.errors?.length > 0} style={{ ...btn(), padding: '8px 14px', background: '#6366f1', color: '#fff', opacity: busy || !preview || preview.errors?.length > 0 ? 0.6 : 1 }}>
|
||||
{busy ? 'Working…' : `Import ${preview?.summary?.importableTaskCount || 0} Tasks`}
|
||||
</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>
|
||||
|
||||
|
||||
@@ -121,6 +121,23 @@ export const api = {
|
||||
updateProject: (id, d) => request(`/projects/${id}`, { method: 'PUT', body: d, write: true }),
|
||||
deleteProject: id => request(`/projects/${id}`, { method: 'DELETE', write: true }),
|
||||
|
||||
// Imports
|
||||
previewImport: ({ file, mode, targetProjectId = '', createMissingMembers = true, duplicateStrategy = 'skip' }) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
fd.append('mode', mode)
|
||||
fd.append('targetProjectId', targetProjectId)
|
||||
fd.append('createMissingMembers', String(createMissingMembers))
|
||||
fd.append('duplicateStrategy', duplicateStrategy)
|
||||
return request('/import/preview', {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
formData: true,
|
||||
write: true,
|
||||
})
|
||||
},
|
||||
applyImport: preview => request('/import/apply', { method: 'POST', body: { preview }, write: true }),
|
||||
|
||||
// Project members
|
||||
assignMemberToProject: (projectId, memberId) =>
|
||||
request(`/projects/${projectId}/members/${memberId}`, { method: 'POST', write: true }),
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
|
||||
import './styles.css'
|
||||
|
||||
// Electron uses file:// which doesn't support history routing — fall back to hash
|
||||
const isElectron = typeof window !== 'undefined' && window?.process?.type === 'renderer'
|
||||
|
||||
const Root = () => (
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<Root />
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
||||
@@ -6,3 +6,74 @@ select,input{color-scheme:dark}
|
||||
::-webkit-scrollbar-thumb{background:#1e2030;border-radius:3px}
|
||||
|
||||
#root{min-height:100vh}
|
||||
|
||||
/* ── Mobile responsive ───────────────────────────────────────── */
|
||||
|
||||
/* Stats grid: 2 cols on small screens */
|
||||
@media (max-width: 640px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr) !important;
|
||||
}
|
||||
|
||||
/* Header: stack nav below brand on very small screens */
|
||||
.app-header {
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 12px 14px !important;
|
||||
}
|
||||
.app-header-nav {
|
||||
order: 3;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
}
|
||||
.app-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Panel drawer full-screen on mobile */
|
||||
.panel-drawer {
|
||||
width: 100% !important;
|
||||
border-left: none !important;
|
||||
}
|
||||
.panel-backdrop {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Content padding */
|
||||
.page-content {
|
||||
padding: 14px 12px !important;
|
||||
}
|
||||
|
||||
/* Project cards: single column */
|
||||
.cards-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
|
||||
/* Filters wrap */
|
||||
.filter-row {
|
||||
flex-direction: column !important;
|
||||
align-items: stretch !important;
|
||||
}
|
||||
.filter-row > * {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Medium screens: 2-col cards */
|
||||
@media (min-width: 641px) and (max-width: 900px) {
|
||||
.panel-drawer {
|
||||
width: 90% !important;
|
||||
max-width: 540px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Safe area insets for notched phones */
|
||||
@supports (padding: env(safe-area-inset-bottom)) {
|
||||
body {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,65 @@
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
// 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, '../', '')
|
||||
const isElectron = process.env.ELECTRON === '1'
|
||||
|
||||
return {
|
||||
base: './',
|
||||
plugins: [react()],
|
||||
base: isElectron ? './' : '/',
|
||||
plugins: [
|
||||
react(),
|
||||
// PWA disabled for Electron builds
|
||||
!isElectron && VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
injectRegister: 'auto',
|
||||
includeAssets: ['icons/icon.svg'],
|
||||
manifest: {
|
||||
name: 'Project Hub',
|
||||
short_name: 'Project Hub',
|
||||
description: 'All your projects, one place',
|
||||
theme_color: '#090910',
|
||||
background_color: '#090910',
|
||||
display: 'standalone',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
orientation: 'any',
|
||||
icons: [
|
||||
{
|
||||
src: '/icons/icon.svg',
|
||||
sizes: 'any',
|
||||
type: 'image/svg+xml',
|
||||
purpose: 'any',
|
||||
},
|
||||
{
|
||||
src: '/icons/icon.svg',
|
||||
sizes: 'any',
|
||||
type: 'image/svg+xml',
|
||||
purpose: 'maskable',
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2}'],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^\/api\//,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'api-cache',
|
||||
networkTimeoutSeconds: 5,
|
||||
expiration: { maxEntries: 200, maxAgeSeconds: 86400 },
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
devOptions: { enabled: false },
|
||||
}),
|
||||
].filter(Boolean),
|
||||
server: {
|
||||
host: env.VITE_DEV_HOST || '0.0.0.0',
|
||||
port: parseInt(env.PORT_FRONTEND_DEV) || 5173,
|
||||
|
||||
Reference in New Issue
Block a user