Compare commits
No commits in common. "b09a072fb954df0a2b443d8da18a62afe2cff3f2" and "d60d834f38b078becb28e12a6d6348c19c82e9a7" have entirely different histories.
b09a072fb9
...
d60d834f38
8 changed files with 37 additions and 526 deletions
|
|
@ -1,432 +0,0 @@
|
||||||
import React, { useState, useMemo } from 'react'
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
FormControl,
|
|
||||||
InputLabel,
|
|
||||||
Select,
|
|
||||||
MenuItem,
|
|
||||||
Chip,
|
|
||||||
Stack,
|
|
||||||
Paper,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableContainer,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
useMediaQuery,
|
|
||||||
useTheme,
|
|
||||||
Checkbox,
|
|
||||||
Toolbar,
|
|
||||||
Button,
|
|
||||||
Dialog,
|
|
||||||
DialogTitle,
|
|
||||||
DialogContent,
|
|
||||||
DialogContentText,
|
|
||||||
DialogActions,
|
|
||||||
} from '@mui/material'
|
|
||||||
import { Delete as DeleteIcon } from '@mui/icons-material'
|
|
||||||
import { useTasks } from '~/hooks/useTasks'
|
|
||||||
import { TaskStatus, type Task } from '~/types/task'
|
|
||||||
import LoadingSpinner from './LoadingSpinner'
|
|
||||||
|
|
||||||
type SortOption =
|
|
||||||
| 'created_desc'
|
|
||||||
| 'created_asc'
|
|
||||||
| 'title_asc'
|
|
||||||
| 'title_desc'
|
|
||||||
| 'status'
|
|
||||||
|
|
||||||
interface TaskListProps {
|
|
||||||
className?: string
|
|
||||||
initialTasks?: Task[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TaskList({ className, initialTasks }: TaskListProps) {
|
|
||||||
const { tasks, loading, error, deleteTask } = useTasks({
|
|
||||||
autoFetch: !initialTasks,
|
|
||||||
initialData: initialTasks,
|
|
||||||
})
|
|
||||||
const [statusFilter, setStatusFilter] = useState<TaskStatus | 'all'>('all')
|
|
||||||
const [sortBy, setSortBy] = useState<SortOption>('created_desc')
|
|
||||||
const [selectedTaskIds, setSelectedTaskIds] = useState<Set<string>>(new Set())
|
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
|
||||||
const theme = useTheme()
|
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
|
||||||
|
|
||||||
const filteredAndSortedTasks = useMemo(() => {
|
|
||||||
let filteredTasks = tasks
|
|
||||||
|
|
||||||
// Apply status filter
|
|
||||||
if (statusFilter !== 'all') {
|
|
||||||
filteredTasks = tasks.filter(task => task.status === statusFilter)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply sorting
|
|
||||||
const sorted = [...filteredTasks].sort((a, b) => {
|
|
||||||
switch (sortBy) {
|
|
||||||
case 'created_desc':
|
|
||||||
return (
|
|
||||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
|
||||||
)
|
|
||||||
case 'created_asc':
|
|
||||||
return (
|
|
||||||
new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
|
||||||
)
|
|
||||||
case 'title_asc':
|
|
||||||
return a.title.localeCompare(b.title)
|
|
||||||
case 'title_desc':
|
|
||||||
return b.title.localeCompare(a.title)
|
|
||||||
case 'status': {
|
|
||||||
const statusOrder = {
|
|
||||||
[TaskStatus.Todo]: 0,
|
|
||||||
[TaskStatus.Backlog]: 1,
|
|
||||||
[TaskStatus.Done]: 2,
|
|
||||||
}
|
|
||||||
return statusOrder[a.status] - statusOrder[b.status]
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return sorted
|
|
||||||
}, [tasks, statusFilter, sortBy])
|
|
||||||
|
|
||||||
const getStatusColor = (status: TaskStatus) => {
|
|
||||||
switch (status) {
|
|
||||||
case TaskStatus.Todo:
|
|
||||||
return 'primary'
|
|
||||||
case TaskStatus.Done:
|
|
||||||
return 'success'
|
|
||||||
case TaskStatus.Backlog:
|
|
||||||
return 'default'
|
|
||||||
default:
|
|
||||||
return 'default'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatCompactDate = (dateString: string) => {
|
|
||||||
const date = new Date(dateString)
|
|
||||||
const now = new Date()
|
|
||||||
const diffInDays = Math.floor(
|
|
||||||
(now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (diffInDays === 0) return 'Today'
|
|
||||||
if (diffInDays === 1) return 'Yesterday'
|
|
||||||
if (diffInDays < 7) return `${diffInDays}d ago`
|
|
||||||
if (diffInDays < 30) return `${Math.floor(diffInDays / 7)}w ago`
|
|
||||||
if (diffInDays < 365) return `${Math.floor(diffInDays / 30)}mo ago`
|
|
||||||
return `${Math.floor(diffInDays / 365)}y ago`
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSelectAll = (checked: boolean) => {
|
|
||||||
if (checked) {
|
|
||||||
setSelectedTaskIds(new Set(filteredAndSortedTasks.map(task => task.id)))
|
|
||||||
} else {
|
|
||||||
setSelectedTaskIds(new Set())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSelectTask = (taskId: string, checked: boolean) => {
|
|
||||||
const newSelected = new Set(selectedTaskIds)
|
|
||||||
if (checked) {
|
|
||||||
newSelected.add(taskId)
|
|
||||||
} else {
|
|
||||||
newSelected.delete(taskId)
|
|
||||||
}
|
|
||||||
setSelectedTaskIds(newSelected)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBulkDelete = async () => {
|
|
||||||
try {
|
|
||||||
const results = await Promise.all(
|
|
||||||
Array.from(selectedTaskIds).map(taskId => deleteTask(taskId))
|
|
||||||
)
|
|
||||||
|
|
||||||
// Check if all deletions were successful
|
|
||||||
const allSuccessful = results.every(result => result === true)
|
|
||||||
|
|
||||||
if (allSuccessful) {
|
|
||||||
setSelectedTaskIds(new Set())
|
|
||||||
setDeleteDialogOpen(false)
|
|
||||||
} else {
|
|
||||||
console.error('Some tasks failed to delete')
|
|
||||||
// Clear selection of successfully deleted tasks
|
|
||||||
const failedTaskIds = Array.from(selectedTaskIds).filter(
|
|
||||||
(taskId, index) => !results[index]
|
|
||||||
)
|
|
||||||
setSelectedTaskIds(new Set(failedTaskIds))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting tasks:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAllSelected =
|
|
||||||
filteredAndSortedTasks.length > 0 &&
|
|
||||||
filteredAndSortedTasks.every(task => selectedTaskIds.has(task.id))
|
|
||||||
const isIndeterminate = selectedTaskIds.size > 0 && !isAllSelected
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <LoadingSpinner />
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ p: 2, textAlign: 'center' }}>
|
|
||||||
<Typography color="error" variant="h6">
|
|
||||||
Error loading tasks
|
|
||||||
</Typography>
|
|
||||||
<Typography color="text.secondary" variant="body2">
|
|
||||||
{error}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box className={className}>
|
|
||||||
{/* Filter and Sort Controls */}
|
|
||||||
<Paper sx={{ p: 2, mb: 2 }}>
|
|
||||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
|
||||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
|
||||||
<InputLabel>Status</InputLabel>
|
|
||||||
<Select
|
|
||||||
value={statusFilter}
|
|
||||||
label="Status"
|
|
||||||
onChange={e =>
|
|
||||||
setStatusFilter(e.target.value as TaskStatus | 'all')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MenuItem value="all">All Tasks</MenuItem>
|
|
||||||
<MenuItem value={TaskStatus.Todo}>Todo</MenuItem>
|
|
||||||
<MenuItem value={TaskStatus.Done}>Done</MenuItem>
|
|
||||||
<MenuItem value={TaskStatus.Backlog}>Backlog</MenuItem>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormControl size="small" sx={{ minWidth: 150 }}>
|
|
||||||
<InputLabel>Sort by</InputLabel>
|
|
||||||
<Select
|
|
||||||
value={sortBy}
|
|
||||||
label="Sort by"
|
|
||||||
onChange={e => setSortBy(e.target.value as SortOption)}
|
|
||||||
>
|
|
||||||
<MenuItem value="created_desc">Newest First</MenuItem>
|
|
||||||
<MenuItem value="created_asc">Oldest First</MenuItem>
|
|
||||||
<MenuItem value="title_asc">Title A-Z</MenuItem>
|
|
||||||
<MenuItem value="title_desc">Title Z-A</MenuItem>
|
|
||||||
<MenuItem value="status">By Status</MenuItem>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{ display: 'flex', alignItems: 'center' }}
|
|
||||||
>
|
|
||||||
{filteredAndSortedTasks.length} task
|
|
||||||
{filteredAndSortedTasks.length !== 1 ? 's' : ''}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{/* Bulk Actions Toolbar */}
|
|
||||||
{selectedTaskIds.size > 0 && (
|
|
||||||
<Paper sx={{ mb: 2 }}>
|
|
||||||
<Toolbar sx={{ pl: 2, pr: 1 }}>
|
|
||||||
<Typography
|
|
||||||
sx={{ flex: '1 1 100%' }}
|
|
||||||
color="inherit"
|
|
||||||
variant="subtitle1"
|
|
||||||
component="div"
|
|
||||||
>
|
|
||||||
{selectedTaskIds.size} task{selectedTaskIds.size > 1 ? 's' : ''}{' '}
|
|
||||||
selected
|
|
||||||
</Typography>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="error"
|
|
||||||
startIcon={<DeleteIcon />}
|
|
||||||
onClick={() => setDeleteDialogOpen(true)}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</Toolbar>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Task Table */}
|
|
||||||
{filteredAndSortedTasks.length === 0 ? (
|
|
||||||
<Paper sx={{ p: 4, textAlign: 'center' }}>
|
|
||||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
|
||||||
No tasks found
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
{statusFilter === 'all'
|
|
||||||
? 'Create your first task to get started!'
|
|
||||||
: `No ${statusFilter} tasks found.`}
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
) : (
|
|
||||||
<TableContainer component={Paper}>
|
|
||||||
<Table size="small" sx={{ '& .MuiTableCell-root': { py: 1 } }}>
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell padding="checkbox">
|
|
||||||
<Checkbox
|
|
||||||
color="primary"
|
|
||||||
indeterminate={isIndeterminate}
|
|
||||||
checked={isAllSelected}
|
|
||||||
onChange={e => handleSelectAll(e.target.checked)}
|
|
||||||
inputProps={{
|
|
||||||
'aria-label': 'select all tasks',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell sx={{ fontWeight: 'bold' }}>Title</TableCell>
|
|
||||||
{!isMobile && (
|
|
||||||
<TableCell sx={{ fontWeight: 'bold' }}>Description</TableCell>
|
|
||||||
)}
|
|
||||||
<TableCell sx={{ fontWeight: 'bold', width: 100 }}>
|
|
||||||
Status
|
|
||||||
</TableCell>
|
|
||||||
<TableCell sx={{ fontWeight: 'bold', width: 120 }}>
|
|
||||||
Created
|
|
||||||
</TableCell>
|
|
||||||
{!isMobile && (
|
|
||||||
<TableCell sx={{ fontWeight: 'bold', width: 120 }}>
|
|
||||||
Completed
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{filteredAndSortedTasks.map(task => (
|
|
||||||
<TableRow
|
|
||||||
key={task.id}
|
|
||||||
sx={{
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: 'action.hover',
|
|
||||||
},
|
|
||||||
'&:last-child td, &:last-child th': { border: 0 },
|
|
||||||
backgroundColor: selectedTaskIds.has(task.id)
|
|
||||||
? 'action.selected'
|
|
||||||
: 'inherit',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TableCell padding="checkbox">
|
|
||||||
<Checkbox
|
|
||||||
color="primary"
|
|
||||||
checked={selectedTaskIds.has(task.id)}
|
|
||||||
onChange={e =>
|
|
||||||
handleSelectTask(task.id, e.target.checked)
|
|
||||||
}
|
|
||||||
inputProps={{
|
|
||||||
'aria-labelledby': `task-${task.id}`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Box>
|
|
||||||
<Typography
|
|
||||||
id={`task-${task.id}`}
|
|
||||||
variant="body1"
|
|
||||||
component="div"
|
|
||||||
sx={{
|
|
||||||
textDecoration:
|
|
||||||
task.status === TaskStatus.Done
|
|
||||||
? 'line-through'
|
|
||||||
: 'none',
|
|
||||||
opacity: task.status === TaskStatus.Done ? 0.7 : 1,
|
|
||||||
fontWeight: 500,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{task.title}
|
|
||||||
</Typography>
|
|
||||||
{isMobile && task.description && (
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{ mt: 0.5 }}
|
|
||||||
>
|
|
||||||
{task.description}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</TableCell>
|
|
||||||
{!isMobile && (
|
|
||||||
<TableCell>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{
|
|
||||||
maxWidth: 300,
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{task.description || '—'}
|
|
||||||
</Typography>
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
<TableCell>
|
|
||||||
<Chip
|
|
||||||
label={task.status}
|
|
||||||
color={getStatusColor(task.status)}
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
{formatCompactDate(task.created_at)}
|
|
||||||
</Typography>
|
|
||||||
</TableCell>
|
|
||||||
{!isMobile && (
|
|
||||||
<TableCell>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
{task.completed_at
|
|
||||||
? formatCompactDate(task.completed_at)
|
|
||||||
: '—'}
|
|
||||||
</Typography>
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
|
||||||
<Dialog
|
|
||||||
open={deleteDialogOpen}
|
|
||||||
onClose={() => setDeleteDialogOpen(false)}
|
|
||||||
aria-labelledby="delete-dialog-title"
|
|
||||||
aria-describedby="delete-dialog-description"
|
|
||||||
>
|
|
||||||
<DialogTitle id="delete-dialog-title">
|
|
||||||
Delete {selectedTaskIds.size} task
|
|
||||||
{selectedTaskIds.size > 1 ? 's' : ''}?
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogContentText id="delete-dialog-description">
|
|
||||||
This action cannot be undone. Are you sure you want to delete the
|
|
||||||
selected task{selectedTaskIds.size > 1 ? 's' : ''}?
|
|
||||||
</DialogContentText>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
|
|
||||||
<Button onClick={handleBulkDelete} color="error" autoFocus>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -13,7 +13,6 @@ interface UseTasksState {
|
||||||
interface UseTasksActions {
|
interface UseTasksActions {
|
||||||
fetchTasks: () => Promise<void>
|
fetchTasks: () => Promise<void>
|
||||||
createTask: (data: CreateTaskRequest) => Promise<Task | null>
|
createTask: (data: CreateTaskRequest) => Promise<Task | null>
|
||||||
deleteTask: (id: string) => Promise<boolean>
|
|
||||||
refreshTasks: () => Promise<void>
|
refreshTasks: () => Promise<void>
|
||||||
clearError: () => void
|
clearError: () => void
|
||||||
getTaskById: (id: string) => Task | undefined
|
getTaskById: (id: string) => Task | undefined
|
||||||
|
|
@ -23,19 +22,18 @@ interface UseTasksActions {
|
||||||
interface UseTasksOptions {
|
interface UseTasksOptions {
|
||||||
autoFetch?: boolean
|
autoFetch?: boolean
|
||||||
refreshInterval?: number
|
refreshInterval?: number
|
||||||
initialData?: Task[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTasks(
|
export function useTasks(
|
||||||
options: UseTasksOptions = {}
|
options: UseTasksOptions = {}
|
||||||
): UseTasksState & UseTasksActions {
|
): UseTasksState & UseTasksActions {
|
||||||
const { autoFetch = true, refreshInterval, initialData } = options
|
const { autoFetch = true, refreshInterval } = options
|
||||||
|
|
||||||
const [state, setState] = useState<UseTasksState>({
|
const [state, setState] = useState<UseTasksState>({
|
||||||
tasks: initialData || [],
|
tasks: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
lastFetch: initialData ? new Date() : null,
|
lastFetch: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const clearError = useCallback(() => {
|
const clearError = useCallback(() => {
|
||||||
|
|
@ -91,27 +89,6 @@ export function useTasks(
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
const deleteTask = useCallback(async (id: string): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
await apiClient.deleteTask(id)
|
|
||||||
|
|
||||||
// Remove the task from the local state immediately
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
tasks: prev.tasks.filter(task => task.id !== id),
|
|
||||||
}))
|
|
||||||
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
const apiError = error as ApiError
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
error: apiError.message,
|
|
||||||
}))
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const refreshTasks = useCallback(async () => {
|
const refreshTasks = useCallback(async () => {
|
||||||
// Force refresh without showing loading state if tasks already exist
|
// Force refresh without showing loading state if tasks already exist
|
||||||
const showLoading = state.tasks.length === 0
|
const showLoading = state.tasks.length === 0
|
||||||
|
|
@ -176,7 +153,6 @@ export function useTasks(
|
||||||
...state,
|
...state,
|
||||||
fetchTasks,
|
fetchTasks,
|
||||||
createTask,
|
createTask,
|
||||||
deleteTask,
|
|
||||||
refreshTasks,
|
refreshTasks,
|
||||||
clearError,
|
clearError,
|
||||||
getTaskById,
|
getTaskById,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
import { type RouteConfig, index, route } from '@react-router/dev/routes'
|
import { type RouteConfig, index } from '@react-router/dev/routes'
|
||||||
|
|
||||||
export default [
|
export default [index('routes/home.tsx')] satisfies RouteConfig
|
||||||
index('routes/home.tsx'),
|
|
||||||
route('*', 'routes/$.tsx'),
|
|
||||||
] satisfies RouteConfig
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import { redirect } from 'react-router'
|
|
||||||
import type { Route } from './+types/$'
|
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
|
||||||
const url = new URL(request.url)
|
|
||||||
|
|
||||||
// Handle React DevTools and other development files
|
|
||||||
if (
|
|
||||||
url.pathname.endsWith('.js.map') ||
|
|
||||||
url.pathname.includes('installHook') ||
|
|
||||||
url.pathname.startsWith('/__') ||
|
|
||||||
url.pathname.startsWith('/node_modules/')
|
|
||||||
) {
|
|
||||||
// Return a 404 response for these dev-only requests
|
|
||||||
throw new Response('Not Found', { status: 404 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// For any other unmatched routes, redirect to home
|
|
||||||
return redirect('/')
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CatchAll() {
|
|
||||||
// This component should never render since we always redirect or throw
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
@ -4,12 +4,13 @@ import Home from './home'
|
||||||
|
|
||||||
describe('Home component', () => {
|
describe('Home component', () => {
|
||||||
it('should render task management interface', () => {
|
it('should render task management interface', () => {
|
||||||
const mockLoaderData = { tasks: [] }
|
render(<Home />)
|
||||||
render(<Home loaderData={mockLoaderData} />)
|
expect(screen.getByText(/Tasks/i)).toBeInTheDocument()
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('heading', { level: 1, name: /Tasks/i })
|
screen.getByText(/GTD-inspired task management system/i)
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Task Management Interface Coming Soon/i)
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
// TaskList component should be rendered with empty state
|
|
||||||
expect(screen.getByText(/No tasks found/i)).toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,43 @@
|
||||||
import type { Route } from './+types/home'
|
import type { Route } from './+types/home'
|
||||||
import { Box, Typography, Container } from '@mui/material'
|
import { Box, Typography, Container } from '@mui/material'
|
||||||
import { TaskList } from '~/components/TaskList'
|
|
||||||
import type { Task } from '~/types/task'
|
|
||||||
|
|
||||||
export function meta(_: Route.MetaArgs) {
|
export function meta(_: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: "Captain's Log - Tasks" },
|
{ title: "Captain's Log - Tasks" },
|
||||||
{ name: 'description', content: 'Task Dashboard' },
|
{ name: 'description', content: 'GTD-inspired task management system' },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loader(): Promise<{ tasks: Task[] }> {
|
export default function Home() {
|
||||||
try {
|
|
||||||
// Fetch tasks from the backend API during SSR
|
|
||||||
const apiUrl = process.env.API_URL || 'http://localhost:3000'
|
|
||||||
const response = await fetch(`${apiUrl}/api/tasks`, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error('Failed to fetch tasks:', response.statusText)
|
|
||||||
return { tasks: [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
const tasks = await response.json()
|
|
||||||
return { tasks }
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching tasks during SSR:', error)
|
|
||||||
return { tasks: [] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Home({ loaderData }: Route.ComponentProps) {
|
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="lg">
|
<Container maxWidth="lg">
|
||||||
<Box sx={{ py: 4 }}>
|
<Box sx={{ py: 4 }}>
|
||||||
<Typography variant="h1" component="h1" gutterBottom>
|
<Typography variant="h1" component="h1" gutterBottom>
|
||||||
Tasks
|
Tasks
|
||||||
</Typography>
|
</Typography>
|
||||||
<TaskList initialTasks={loaderData.tasks} />
|
<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>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,4 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
|
||||||
sourcemap: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ captains-log/
|
||||||
**UI Framework**: Use Material-UI (MUI) for consistent design system and components alongside CSS for custom styling.
|
**UI Framework**: Use Material-UI (MUI) for consistent design system and components alongside CSS for custom styling.
|
||||||
|
|
||||||
### Task 3.1: Main App Component and Routing
|
### Task 3.1: Main App Component and Routing
|
||||||
- [x] **Files**: `frontend/src/App.tsx`, `frontend/src/main.tsx`
|
- [ ] **Files**: `frontend/src/App.tsx`, `frontend/src/main.tsx`
|
||||||
- Setup React Router with basic navigation
|
- Setup React Router with basic navigation
|
||||||
- Create main layout with MUI AppBar/Drawer and task area
|
- Create main layout with MUI AppBar/Drawer and task area
|
||||||
- Implement responsive design with MUI breakpoints and CSS
|
- Implement responsive design with MUI breakpoints and CSS
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue