+
@@ -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 (
+
+
+
+
+
Import CSV
+
Phase 1 supports CSV task import. Markdown project briefs can sit on top of this flow next.
+
+
+
+
+
+
+ Import Mode
+
+
+
+ Duplicate Handling
+
+
+
+
+ {mode === 'existing-project' && (
+
+ Target Project
+
+
+ )}
+
+
+ CSV File
+ { setFile(e.target.files?.[0] || null); resetPreview() }} style={inp({ padding: '7px 9px' })} />
+
+
+
+
+
+
Supported CSV columns
+
+ 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
+
+
Use subtasks with values separated by | or ;.
+
+
+ {msg &&
{msg}
}
+
+ {preview && (
+
+
+ {[
+ ['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]) => (
+
+ ))}
+
+
+
+
{preview.mode === 'existing-project' ? 'Target project' : 'New project preview'}
+
{preview.project?.name || 'Untitled project'}
+ {preview.project?.description &&
{preview.project.description}
}
+
+
+ {preview.errors?.length > 0 && (
+
+
Errors
+ {preview.errors.map(error =>
{error}
)}
+
+ )}
+
+ {preview.warnings?.length > 0 && (
+
+
Warnings
+ {preview.warnings.slice(0, 6).map(warning =>
{warning}
)}
+ {preview.warnings.length > 6 &&
+ {preview.warnings.length - 6} more warnings
}
+
+ )}
+
+
+
+
Task
+
Status
+
Assignee
+
Action
+
+ {previewTasks.map(task => (
+
+
+
{task.title}
+
Row {task.rowNumber}{task.dueDate ? ` · Due ${fmt(task.dueDate)}` : ''}
+
+
{task.status}
+
{task.assignee?.name || task.assignee?.email || 'Unassigned'}
+
{task.willImport ? task.assignmentAction.replace(/-/g, ' ') : 'skip duplicate'}
+
+ ))}
+ {preview.tasks?.length > previewTasks.length &&
Showing {previewTasks.length} of {preview.tasks.length} rows
}
+
+
+ )}
+
+
+
+
+
+
+
+
+ )
+}
+
const Lbl = ({ children }) =>
{children}
const Overlay = ({ children }) =>
{children}
diff --git a/frontend/src/api.js b/frontend/src/api.js
index 890dca9..91a536c 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -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 }),
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
index e6f55f4..192187d 100644
--- a/frontend/src/main.jsx
+++ b/frontend/src/main.jsx
@@ -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 = () => (
+
+
+
+)
+
createRoot(document.getElementById('root')).render(
-
+
)
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index a6310ec..698f0d2 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -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);
+ }
+}
+
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index 6d05445..43a9ae1 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -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,