feat: PWA + full web app — service worker, routing, mobile responsive
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Ryan Lancaster
2026-03-18 17:42:34 -04:00
parent 758fa37c2a
commit 9cc677ce68
9 changed files with 4445 additions and 45 deletions

View File

@@ -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>

File diff suppressed because it is too large Load Diff

View File

@@ -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": {}
}

View 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

View File

@@ -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 }}>
{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>}
</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>

View File

@@ -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 }),

View File

@@ -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>
)

View File

@@ -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);
}
}

View File

@@ -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,