Create a frontend wireframe. #7

Merged
drew merged 5 commits from drew/frontend-wireframe into main 2025-09-23 04:08:46 +00:00
16 changed files with 1405 additions and 57 deletions
Showing only changes of commit 6ef9843835 - Show all commits

View file

@ -0,0 +1,64 @@
import React from 'react'
import {
Alert,
AlertTitle,
Box,
Button,
Container,
Typography,
Paper,
} from '@mui/material'
import {
Refresh as RefreshIcon,
BugReport as BugReportIcon,
} from '@mui/icons-material'
interface ErrorFallbackProps {
error: Error
resetError: () => void
}
export default function ErrorFallback({
error,
resetError,
}: ErrorFallbackProps) {
return (
<Container maxWidth="md" sx={{ py: 8 }}>
<Paper sx={{ p: 4, textAlign: 'center' }}>
<Box sx={{ mb: 3 }}>
<BugReportIcon sx={{ fontSize: 64, color: 'error.main', mb: 2 }} />
<Typography variant="h4" gutterBottom>
Something went wrong
</Typography>
<Typography variant="body1" color="text.secondary">
An unexpected error occurred. Please try refreshing the page or
contact support if the problem persists.
</Typography>
</Box>
<Alert severity="error" sx={{ mb: 3, textAlign: 'left' }}>
<AlertTitle>Error Details</AlertTitle>
{error.message}
</Alert>
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center' }}>
<Button
variant="contained"
startIcon={<RefreshIcon />}
onClick={resetError}
size="large"
>
Try Again
</Button>
<Button
variant="outlined"
onClick={() => window.location.reload()}
size="large"
>
Reload Page
</Button>
</Box>
</Paper>
</Container>
)
}

View file

@ -0,0 +1,237 @@
import React, { useState, useEffect } from 'react'
import {
AppBar,
Box,
CssBaseline,
Drawer,
IconButton,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Toolbar,
Typography,
useTheme,
Fab,
LinearProgress,
} from '@mui/material'
import {
Menu as MenuIcon,
Dashboard as DashboardIcon,
Add as AddIcon,
Settings as SettingsIcon,
DarkMode as DarkModeIcon,
LightMode as LightModeIcon,
} from '@mui/icons-material'
const drawerWidth = 240
interface LayoutProps {
children: React.ReactNode
loading?: boolean
}
export default function Layout({ children, loading = false }: LayoutProps) {
const theme = useTheme()
const [mobileOpen, setMobileOpen] = useState(false)
const [darkMode, setDarkMode] = useState(false)
// Check system preference and localStorage on mount
useEffect(() => {
const savedTheme = localStorage.getItem('theme')
const prefersDark = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches
setDarkMode(savedTheme === 'dark' || (!savedTheme && prefersDark))
}, [])
const handleDrawerToggle = () => {
setMobileOpen(!mobileOpen)
}
const handleThemeToggle = () => {
const newTheme = !darkMode
setDarkMode(newTheme)
localStorage.setItem('theme', newTheme ? 'dark' : 'light')
document.documentElement.classList.toggle('dark', newTheme)
}
const menuItems = [
{
text: 'Tasks',
icon: <DashboardIcon />,
path: '/',
},
{
text: 'Settings',
icon: <SettingsIcon />,
path: '/settings',
},
]
const drawer = (
<div>
<Toolbar>
<Typography
variant="h6"
noWrap
component="div"
sx={{ fontWeight: 700 }}
>
Captain's Log
</Typography>
</Toolbar>
<List>
{menuItems.map(item => (
<ListItem key={item.text} disablePadding>
<ListItemButton
component="a"
href={item.path}
sx={{
borderRadius: 2,
mx: 1,
my: 0.5,
'&:hover': {
backgroundColor: theme.palette.primary.main + '10',
},
}}
>
<ListItemIcon sx={{ color: theme.palette.primary.main }}>
{item.icon}
</ListItemIcon>
<ListItemText
primary={item.text}
primaryTypographyProps={{
fontWeight: 500,
}}
/>
</ListItemButton>
</ListItem>
))}
</List>
</div>
)
return (
<Box sx={{ display: 'flex' }}>
<CssBaseline />
{/* Loading indicator */}
{loading && (
<LinearProgress
sx={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: theme.zIndex.appBar + 1,
}}
/>
)}
{/* App Bar */}
<AppBar
position="fixed"
sx={{
width: { md: `calc(100% - ${drawerWidth}px)` },
ml: { md: `${drawerWidth}px` },
}}
>
<Toolbar>
<IconButton
color="inherit"
aria-label="open drawer"
edge="start"
onClick={handleDrawerToggle}
sx={{ mr: 2, display: { md: 'none' } }}
>
<MenuIcon />
</IconButton>
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
Captain's Log
</Typography>
<IconButton
color="inherit"
onClick={handleThemeToggle}
aria-label="toggle dark mode"
>
{darkMode ? <LightModeIcon /> : <DarkModeIcon />}
</IconButton>
</Toolbar>
</AppBar>
{/* Drawer */}
<Box
component="nav"
sx={{ width: { md: drawerWidth }, flexShrink: { md: 0 } }}
aria-label="navigation menu"
>
{/* Mobile drawer */}
<Drawer
variant="temporary"
open={mobileOpen}
onClose={handleDrawerToggle}
ModalProps={{
keepMounted: true, // Better open performance on mobile.
}}
sx={{
display: { xs: 'block', md: 'none' },
'& .MuiDrawer-paper': {
boxSizing: 'border-box',
width: drawerWidth,
},
}}
>
{drawer}
</Drawer>
{/* Desktop drawer */}
<Drawer
variant="permanent"
sx={{
display: { xs: 'none', md: 'block' },
'& .MuiDrawer-paper': {
boxSizing: 'border-box',
width: drawerWidth,
},
}}
open
>
{drawer}
</Drawer>
</Box>
{/* Main content */}
<Box
component="main"
sx={{
flexGrow: 1,
p: 3,
width: { md: `calc(100% - ${drawerWidth}px)` },
minHeight: '100vh',
backgroundColor: theme.palette.background.default,
}}
>
<Toolbar />
{children}
</Box>
{/* Quick capture FAB */}
<Fab
color="primary"
aria-label="add task"
sx={{
position: 'fixed',
bottom: 16,
right: 16,
zIndex: theme.zIndex.fab,
}}
>
<AddIcon />
</Fab>
</Box>
)
}

View file

@ -0,0 +1,53 @@
import React from 'react'
import { Box, CircularProgress, Typography, Skeleton } from '@mui/material'
interface LoadingSpinnerProps {
size?: number
message?: string
variant?: 'spinner' | 'skeleton'
}
export default function LoadingSpinner({
size = 40,
message = 'Loading...',
variant = 'spinner',
}: LoadingSpinnerProps) {
if (variant === 'skeleton') {
return (
<Box sx={{ p: 2 }}>
<Skeleton variant="text" width="60%" height={32} sx={{ mb: 2 }} />
<Skeleton
variant="rectangular"
width="100%"
height={120}
sx={{ mb: 1 }}
/>
<Skeleton
variant="rectangular"
width="100%"
height={120}
sx={{ mb: 1 }}
/>
<Skeleton variant="rectangular" width="100%" height={120} />
</Box>
)
}
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 4,
gap: 2,
}}
>
<CircularProgress size={size} thickness={4} />
<Typography variant="body2" color="text.secondary">
{message}
</Typography>
</Box>
)
}

View file

@ -1,5 +1,5 @@
import { useState, useCallback, useRef, useEffect } from 'react' import { useState, useCallback, useRef, useEffect } from 'react'
import { ApiError } from '~/types/task' import type { ApiError } from '~/types/task'
interface UseApiState<T> { interface UseApiState<T> {
data: T | null data: T | null

View file

@ -1,7 +1,8 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react' import { renderHook, act, waitFor } from '@testing-library/react'
import { useTask } from './useTask' import { useTask } from './useTask'
import { Task, TaskStatus, UpdateTaskRequest } from '~/types/task' import type { Task, UpdateTaskRequest } from '~/types/task'
import { TaskStatus } from '~/types/task'
import { apiClient } from '~/services/api' import { apiClient } from '~/services/api'
// Mock the API client // Mock the API client
@ -13,7 +14,7 @@ vi.mock('~/services/api', () => ({
}, },
})) }))
const mockApiClient = apiClient as { const mockApiClient = apiClient as unknown as {
getTask: ReturnType<typeof vi.fn> getTask: ReturnType<typeof vi.fn>
updateTask: ReturnType<typeof vi.fn> updateTask: ReturnType<typeof vi.fn>
deleteTask: ReturnType<typeof vi.fn> deleteTask: ReturnType<typeof vi.fn>

View file

@ -1,5 +1,5 @@
import { useState, useCallback } from 'react' import { useState, useCallback } from 'react'
import { Task, UpdateTaskRequest, ApiError } from '~/types/task' import type { Task, UpdateTaskRequest, ApiError } from '~/types/task'
import { apiClient } from '~/services/api' import { apiClient } from '~/services/api'
interface UseTaskState { interface UseTaskState {

View file

@ -1,7 +1,8 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react' import { renderHook, act, waitFor } from '@testing-library/react'
import { useTasks } from './useTasks' import { useTasks } from './useTasks'
import { Task, TaskStatus, CreateTaskRequest } from '~/types/task' import type { Task, CreateTaskRequest } from '~/types/task'
import { TaskStatus } from '~/types/task'
import { apiClient } from '~/services/api' import { apiClient } from '~/services/api'
// Mock the API client // Mock the API client
@ -12,7 +13,7 @@ vi.mock('~/services/api', () => ({
}, },
})) }))
const mockApiClient = apiClient as { const mockApiClient = apiClient as unknown as {
listTasks: ReturnType<typeof vi.fn> listTasks: ReturnType<typeof vi.fn>
createTask: ReturnType<typeof vi.fn> createTask: ReturnType<typeof vi.fn>
} }

View file

@ -1,5 +1,6 @@
import { useState, useCallback, useEffect } from 'react' import { useState, useCallback, useEffect } from 'react'
import { Task, CreateTaskRequest, TaskStatus, ApiError } from '~/types/task' import type { Task, CreateTaskRequest, ApiError } from '~/types/task'
import { TaskStatus } from '~/types/task'
import { apiClient } from '~/services/api' import { apiClient } from '~/services/api'
interface UseTasksState { interface UseTasksState {

View file

@ -6,8 +6,12 @@ import {
Scripts, Scripts,
ScrollRestoration, ScrollRestoration,
} from 'react-router' } from 'react-router'
import { ThemeProvider } from '@mui/material/styles'
import { CssBaseline } from '@mui/material'
import type { Route } from './+types/root' import type { Route } from './+types/root'
import { theme, darkTheme } from './theme'
import AppLayout from './components/Layout'
import './app.css' import './app.css'
export const links: Route.LinksFunction = () => [ export const links: Route.LinksFunction = () => [
@ -42,7 +46,20 @@ export function Layout({ children }: { children: React.ReactNode }) {
} }
export default function App() { export default function App() {
return <Outlet /> const isDarkMode =
typeof window !== 'undefined' &&
(localStorage.getItem('theme') === 'dark' ||
(!localStorage.getItem('theme') &&
window.matchMedia('(prefers-color-scheme: dark)').matches))
return (
<ThemeProvider theme={isDarkMode ? darkTheme : theme}>
<CssBaseline />
<AppLayout>
<Outlet />
</AppLayout>
</ThemeProvider>
)
} }
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
@ -61,15 +78,26 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
stack = error.stack stack = error.stack
} }
const isDarkMode =
typeof window !== 'undefined' &&
(localStorage.getItem('theme') === 'dark' ||
(!localStorage.getItem('theme') &&
window.matchMedia('(prefers-color-scheme: dark)').matches))
return ( return (
<main className="pt-16 p-4 container mx-auto"> <ThemeProvider theme={isDarkMode ? darkTheme : theme}>
<h1>{message}</h1> <CssBaseline />
<p>{details}</p> <AppLayout>
{stack && ( <main className="pt-16 p-4 container mx-auto">
<pre className="w-full p-4 overflow-x-auto"> <h1>{message}</h1>
<code>{stack}</code> <p>{details}</p>
</pre> {stack && (
)} <pre className="w-full p-4 overflow-x-auto">
</main> <code>{stack}</code>
</pre>
)}
</main>
</AppLayout>
</ThemeProvider>
) )
} }

View file

@ -3,8 +3,14 @@ import { describe, it, expect } from 'vitest'
import Home from './home' import Home from './home'
describe('Home component', () => { describe('Home component', () => {
it('should render welcome component', () => { it('should render task management interface', () => {
render(<Home />) render(<Home />)
expect(screen.getByText(/React Router/i)).toBeInTheDocument() expect(screen.getByText(/Tasks/i)).toBeInTheDocument()
expect(
screen.getByText(/GTD-inspired task management system/i)
).toBeInTheDocument()
expect(
screen.getByText(/Task Management Interface Coming Soon/i)
).toBeInTheDocument()
}) })
}) })

View file

@ -1,13 +1,44 @@
import type { Route } from './+types/home' import type { Route } from './+types/home'
import { Welcome } from '../welcome/welcome' import { Box, Typography, Container } from '@mui/material'
export function meta(_: Route.MetaArgs) { export function meta(_: Route.MetaArgs) {
return [ return [
{ title: 'New React Router App' }, { title: "Captain's Log - Tasks" },
{ name: 'description', content: 'Welcome to React Router!' }, { name: 'description', content: 'GTD-inspired task management system' },
] ]
} }
export default function Home() { export default function Home() {
return <Welcome /> return (
<Container maxWidth="lg">
<Box sx={{ py: 4 }}>
<Typography variant="h1" component="h1" gutterBottom>
Tasks
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
Your GTD-inspired task management system. Capture everything, see only
what matters.
</Typography>
<Box
sx={{
p: 4,
textAlign: 'center',
color: 'text.secondary',
border: '2px dashed',
borderColor: 'grey.300',
borderRadius: 2,
}}
>
<Typography variant="h6" gutterBottom>
Task Management Interface Coming Soon
</Typography>
<Typography variant="body2">
The task list, task cards, and quick capture components will be
implemented in the next phase.
</Typography>
</Box>
</Box>
</Container>
)
} }

View file

@ -1,11 +1,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { apiClient } from './api' import { apiClient } from './api'
import { import type { Task, CreateTaskRequest, UpdateTaskRequest } from '~/types/task'
Task, import { TaskStatus } from '~/types/task'
TaskStatus,
CreateTaskRequest,
UpdateTaskRequest,
} from '~/types/task'
// Mock fetch globally // Mock fetch globally
const mockFetch = vi.fn() const mockFetch = vi.fn()

View file

@ -1,4 +1,4 @@
import { import type {
Task, Task,
CreateTaskRequest, CreateTaskRequest,
UpdateTaskRequest, UpdateTaskRequest,

280
frontend/app/theme.ts Normal file
View file

@ -0,0 +1,280 @@
import { createTheme } from '@mui/material/styles'
declare module '@mui/material/styles' {
interface Theme {
custom: {
task: {
todo: string
done: string
backlog: string
}
}
}
interface ThemeOptions {
custom?: {
task?: {
todo?: string
done?: string
backlog?: string
}
}
}
}
export const theme = createTheme({
palette: {
mode: 'light',
primary: {
50: '#eff6ff',
100: '#dbeafe',
500: '#3b82f6',
main: '#3b82f6',
contrastText: '#ffffff',
},
background: {
default: '#f9fafb',
paper: '#ffffff',
},
text: {
primary: '#111827',
secondary: '#6b7280',
},
grey: {
50: '#f9fafb',
100: '#f3f4f6',
200: '#e5e7eb',
300: '#d1d5db',
600: '#4b5563',
700: '#374151',
800: '#1f2937',
900: '#111827',
},
},
typography: {
fontFamily: '"Inter", ui-sans-serif, system-ui, sans-serif',
h1: {
fontSize: '2rem',
fontWeight: 700,
lineHeight: 1.2,
},
h2: {
fontSize: '1.5rem',
fontWeight: 600,
lineHeight: 1.3,
},
h3: {
fontSize: '1.25rem',
fontWeight: 600,
lineHeight: 1.4,
},
body1: {
fontSize: '1rem',
lineHeight: 1.6,
},
body2: {
fontSize: '0.875rem',
lineHeight: 1.5,
},
},
shape: {
borderRadius: 12,
},
components: {
MuiAppBar: {
styleOverrides: {
root: {
backgroundColor: '#ffffff',
color: '#111827',
boxShadow:
'0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
borderBottom: '1px solid #e5e7eb',
},
},
},
MuiDrawer: {
styleOverrides: {
paper: {
backgroundColor: '#ffffff',
borderRight: '1px solid #e5e7eb',
},
},
},
MuiButton: {
styleOverrides: {
root: {
textTransform: 'none',
borderRadius: '0.75rem',
fontWeight: 500,
},
contained: {
boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
'&:hover': {
boxShadow:
'0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
},
},
},
},
MuiCard: {
styleOverrides: {
root: {
borderRadius: '0.75rem',
boxShadow:
'0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
border: '1px solid #e5e7eb',
'&:hover': {
boxShadow:
'0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
borderColor: '#d1d5db',
},
},
},
},
MuiTextField: {
styleOverrides: {
root: {
'& .MuiOutlinedInput-root': {
borderRadius: '0.75rem',
},
},
},
},
},
custom: {
task: {
todo: '#3b82f6',
done: '#22c55e',
backlog: '#6b7280',
},
},
})
export const darkTheme = createTheme({
palette: {
mode: 'dark',
primary: {
50: '#eff6ff',
100: '#dbeafe',
500: '#3b82f6',
main: '#3b82f6',
contrastText: '#ffffff',
},
background: {
default: '#030712',
paper: '#1f2937',
},
text: {
primary: '#f9fafb',
secondary: '#9ca3af',
},
grey: {
50: '#f9fafb',
100: '#f3f4f6',
200: '#e5e7eb',
300: '#d1d5db',
600: '#4b5563',
700: '#374151',
800: '#1f2937',
900: '#111827',
},
},
typography: {
fontFamily: '"Inter", ui-sans-serif, system-ui, sans-serif',
h1: {
fontSize: '2rem',
fontWeight: 700,
lineHeight: 1.2,
},
h2: {
fontSize: '1.5rem',
fontWeight: 600,
lineHeight: 1.3,
},
h3: {
fontSize: '1.25rem',
fontWeight: 600,
lineHeight: 1.4,
},
body1: {
fontSize: '1rem',
lineHeight: 1.6,
},
body2: {
fontSize: '0.875rem',
lineHeight: 1.5,
},
},
shape: {
borderRadius: 12,
},
components: {
MuiAppBar: {
styleOverrides: {
root: {
backgroundColor: '#1f2937',
color: '#f9fafb',
boxShadow:
'0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
borderBottom: '1px solid #374151',
},
},
},
MuiDrawer: {
styleOverrides: {
paper: {
backgroundColor: '#1f2937',
borderRight: '1px solid #374151',
},
},
},
MuiButton: {
styleOverrides: {
root: {
textTransform: 'none',
borderRadius: '0.75rem',
fontWeight: 500,
},
contained: {
boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
'&:hover': {
boxShadow:
'0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
},
},
},
},
MuiCard: {
styleOverrides: {
root: {
backgroundColor: '#1f2937',
borderRadius: '0.75rem',
boxShadow:
'0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
border: '1px solid #374151',
'&:hover': {
boxShadow:
'0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
borderColor: '#4b5563',
},
},
},
},
MuiTextField: {
styleOverrides: {
root: {
'& .MuiOutlinedInput-root': {
borderRadius: '0.75rem',
},
},
},
},
},
custom: {
task: {
todo: '#3b82f6',
done: '#22c55e',
backlog: '#6b7280',
},
},
})

File diff suppressed because it is too large Load diff

View file

@ -16,6 +16,10 @@
"typecheck": "react-router typegen && tsc" "typecheck": "react-router typegen && tsc"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.2",
"@mui/material": "^7.3.2",
"@react-router/node": "^7.7.1", "@react-router/node": "^7.7.1",
"@react-router/serve": "^7.7.1", "@react-router/serve": "^7.7.1",
"isbot": "^5.1.27", "isbot": "^5.1.27",