feat: implement Phase 1 Foundation for AutoScheduler GTD System
- Initialize npm workspaces monorepo (backend, frontend, shared-types) - Scaffold NestJS backend with modules: Auth, Users, Tasks, Projects, Inbox, Health - Create React frontend with Vite, TailwindCSS, Radix UI - Implement TypeORM entities: User, InboxItem, Task, Project - Add JWT authentication with Passport.js and bcrypt - Build Inbox capture API (POST /inbox, GET /inbox, POST /inbox/:id/process) - Create Inbox UI with quick-add form and GTD processing workflow modal - Configure Docker Compose stack (postgres, redis, backend, frontend) - Add health check endpoint with database/Redis status - Write unit tests for auth and inbox services Phase 1 features complete: - GTD Inbox Capture: Manual tasks via web form quick-add - GTD Processing Workflow: Interactive inbox processing interface Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
46
packages/frontend/src/App.tsx
Normal file
46
packages/frontend/src/App.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuthStore } from './store/auth';
|
||||
import { Layout } from './components/layout/Layout';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { RegisterPage } from './pages/RegisterPage';
|
||||
import { InboxPage } from './pages/InboxPage';
|
||||
import { CalendarPage } from './pages/CalendarPage';
|
||||
import { ProjectsPage } from './pages/ProjectsPage';
|
||||
import { SettingsPage } from './pages/SettingsPage';
|
||||
import { Toaster } from './components/ui/Toaster';
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const token = useAuthStore((state) => state.token);
|
||||
if (!token) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Layout />
|
||||
</PrivateRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/inbox" replace />} />
|
||||
<Route path="inbox" element={<InboxPage />} />
|
||||
<Route path="calendar" element={<CalendarPage />} />
|
||||
<Route path="projects" element={<ProjectsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<Toaster />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
211
packages/frontend/src/components/inbox/ProcessModal.tsx
Normal file
211
packages/frontend/src/components/inbox/ProcessModal.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import * as Select from '@radix-ui/react-select';
|
||||
import { X, ChevronDown, Check } from 'lucide-react';
|
||||
import { inboxApi } from '../../lib/api';
|
||||
import { Button } from '../ui/Button';
|
||||
import { Input } from '../ui/Input';
|
||||
import { cn } from '../../lib/utils';
|
||||
import {
|
||||
ProcessAction,
|
||||
TaskContext,
|
||||
TaskDomain,
|
||||
type InboxItemResponseDto,
|
||||
} from '@nick-tracker/shared-types';
|
||||
|
||||
interface ProcessModalProps {
|
||||
item: InboxItemResponseDto;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ProcessModal({ item, onClose }: ProcessModalProps) {
|
||||
const [action, setAction] = useState<ProcessAction | ''>('');
|
||||
const [title, setTitle] = useState(item.content);
|
||||
const [context, setContext] = useState<TaskContext | ''>('');
|
||||
const [domain, setDomain] = useState<TaskDomain | ''>('');
|
||||
const [priority, setPriority] = useState('');
|
||||
|
||||
const processMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!action) throw new Error('Please select an action');
|
||||
|
||||
return inboxApi.process(item.id, {
|
||||
action,
|
||||
title: title !== item.content ? title : undefined,
|
||||
context: context || undefined,
|
||||
domain: domain || undefined,
|
||||
priority: priority ? parseInt(priority) : undefined,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
processMutation.mutate();
|
||||
};
|
||||
|
||||
const needsDetails = action === ProcessAction.TASK ||
|
||||
action === ProcessAction.WAITING_FOR ||
|
||||
action === ProcessAction.SOMEDAY_MAYBE ||
|
||||
action === ProcessAction.TICKLER ||
|
||||
action === ProcessAction.PROJECT;
|
||||
|
||||
return (
|
||||
<Dialog.Root open onOpenChange={() => onClose()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
|
||||
<Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-background rounded-lg shadow-lg w-full max-w-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Dialog.Title className="text-lg font-semibold">
|
||||
Process Inbox Item
|
||||
</Dialog.Title>
|
||||
<Dialog.Close asChild>
|
||||
<button className="text-muted-foreground hover:text-foreground">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 p-3 bg-muted rounded-md">
|
||||
<p className="text-sm">{item.content}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">What is this?</label>
|
||||
<Select.Root value={action} onValueChange={(v) => setAction(v as ProcessAction)}>
|
||||
<Select.Trigger className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
<Select.Value placeholder="Select an action..." />
|
||||
<Select.Icon>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</Select.Icon>
|
||||
</Select.Trigger>
|
||||
<Select.Portal>
|
||||
<Select.Content className="overflow-hidden bg-background rounded-md border shadow-md">
|
||||
<Select.Viewport className="p-1">
|
||||
<SelectItem value={ProcessAction.TASK}>Next Action</SelectItem>
|
||||
<SelectItem value={ProcessAction.PROJECT}>Project (multi-step)</SelectItem>
|
||||
<SelectItem value={ProcessAction.WAITING_FOR}>Waiting For</SelectItem>
|
||||
<SelectItem value={ProcessAction.SOMEDAY_MAYBE}>Someday/Maybe</SelectItem>
|
||||
<SelectItem value={ProcessAction.TICKLER}>Tickler (future date)</SelectItem>
|
||||
<SelectItem value={ProcessAction.REFERENCE}>Reference Material</SelectItem>
|
||||
<SelectItem value={ProcessAction.TRASH}>Trash</SelectItem>
|
||||
</Select.Viewport>
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
{needsDetails && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Title</label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Enter a title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Domain</label>
|
||||
<Select.Root value={domain} onValueChange={(v) => setDomain(v as TaskDomain)}>
|
||||
<Select.Trigger className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
<Select.Value placeholder="Select domain..." />
|
||||
<Select.Icon>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</Select.Icon>
|
||||
</Select.Trigger>
|
||||
<Select.Portal>
|
||||
<Select.Content className="overflow-hidden bg-background rounded-md border shadow-md">
|
||||
<Select.Viewport className="p-1">
|
||||
<SelectItem value={TaskDomain.WORK}>Work</SelectItem>
|
||||
<SelectItem value={TaskDomain.HOMELAB}>Homelab</SelectItem>
|
||||
<SelectItem value={TaskDomain.DAILY_ROUTINES}>Daily Routines</SelectItem>
|
||||
<SelectItem value={TaskDomain.HOUSE}>House</SelectItem>
|
||||
<SelectItem value={TaskDomain.PROFESSIONAL_DEVELOPMENT}>Professional Development</SelectItem>
|
||||
</Select.Viewport>
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
{action !== ProcessAction.PROJECT && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Context</label>
|
||||
<Select.Root value={context} onValueChange={(v) => setContext(v as TaskContext)}>
|
||||
<Select.Trigger className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
<Select.Value placeholder="Select context..." />
|
||||
<Select.Icon>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</Select.Icon>
|
||||
</Select.Trigger>
|
||||
<Select.Portal>
|
||||
<Select.Content className="overflow-hidden bg-background rounded-md border shadow-md">
|
||||
<Select.Viewport className="p-1">
|
||||
<SelectItem value={TaskContext.DESK}>@desk</SelectItem>
|
||||
<SelectItem value={TaskContext.PHONE}>@phone</SelectItem>
|
||||
<SelectItem value={TaskContext.ERRAND}>@errand</SelectItem>
|
||||
<SelectItem value={TaskContext.HOMELAB}>@homelab</SelectItem>
|
||||
<SelectItem value={TaskContext.ANYWHERE}>@anywhere</SelectItem>
|
||||
</Select.Viewport>
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select.Root>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Priority (1-5)</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="5"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!action || (needsDetails && !domain) || processMutation.isPending}
|
||||
>
|
||||
{processMutation.isPending ? 'Processing...' : 'Process'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({ children, value, className }: { children: React.ReactNode; value: string; className?: string }) {
|
||||
return (
|
||||
<Select.Item
|
||||
value={value}
|
||||
className={cn(
|
||||
'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<Select.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</Select.ItemIndicator>
|
||||
</span>
|
||||
<Select.ItemText>{children}</Select.ItemText>
|
||||
</Select.Item>
|
||||
);
|
||||
}
|
||||
67
packages/frontend/src/components/layout/Layout.tsx
Normal file
67
packages/frontend/src/components/layout/Layout.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
||||
import { Inbox, Calendar, FolderKanban, Settings, LogOut } from 'lucide-react';
|
||||
import { useAuthStore } from '../../store/auth';
|
||||
import { Button } from '../ui/Button';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const navItems = [
|
||||
{ to: '/inbox', icon: Inbox, label: 'Inbox' },
|
||||
{ to: '/calendar', icon: Calendar, label: 'Calendar' },
|
||||
{ to: '/projects', icon: FolderKanban, label: 'Projects' },
|
||||
{ to: '/settings', icon: Settings, label: 'Settings' },
|
||||
];
|
||||
|
||||
export function Layout() {
|
||||
const navigate = useNavigate();
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex">
|
||||
<aside className="w-64 bg-muted border-r flex flex-col">
|
||||
<div className="p-6">
|
||||
<h1 className="text-xl font-bold">AutoScheduler</h1>
|
||||
<p className="text-sm text-muted-foreground">GTD System</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 px-4">
|
||||
<ul className="space-y-2">
|
||||
{navItems.map(({ to, icon: Icon, label }) => (
|
||||
<li key={to}>
|
||||
<NavLink
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
{label}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t">
|
||||
<Button variant="ghost" className="w-full justify-start" onClick={handleLogout}>
|
||||
<LogOut className="h-5 w-5 mr-3" />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 overflow-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
packages/frontend/src/components/ui/Button.tsx
Normal file
46
packages/frontend/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
|
||||
},
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
||||
55
packages/frontend/src/components/ui/Card.tsx
Normal file
55
packages/frontend/src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
));
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
23
packages/frontend/src/components/ui/Input.tsx
Normal file
23
packages/frontend/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
90
packages/frontend/src/components/ui/Toaster.tsx
Normal file
90
packages/frontend/src/components/ui/Toaster.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import * as React from 'react';
|
||||
import * as ToastPrimitive from '@radix-ui/react-toast';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const ToastProvider = ToastPrimitive.Provider;
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitive.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitive.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastViewport.displayName = ToastPrimitive.Viewport.displayName;
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Root> & {
|
||||
variant?: 'default' | 'destructive';
|
||||
}
|
||||
>(({ className, variant = 'default', ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all',
|
||||
variant === 'default' && 'border bg-background text-foreground',
|
||||
variant === 'destructive' &&
|
||||
'destructive group border-destructive bg-destructive text-destructive-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Toast.displayName = ToastPrimitive.Root.displayName;
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitive.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitive.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100',
|
||||
className,
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitive.Close>
|
||||
));
|
||||
ToastClose.displayName = ToastPrimitive.Close.displayName;
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitive.Title ref={ref} className={cn('text-sm font-semibold', className)} {...props} />
|
||||
));
|
||||
ToastTitle.displayName = ToastPrimitive.Title.displayName;
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm opacity-90', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastDescription.displayName = ToastPrimitive.Description.displayName;
|
||||
|
||||
export function Toaster() {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export { Toast, ToastClose, ToastTitle, ToastDescription };
|
||||
51
packages/frontend/src/index.css
Normal file
51
packages/frontend/src/index.css
Normal file
@@ -0,0 +1,51 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
99
packages/frontend/src/lib/api.ts
Normal file
99
packages/frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = useAuthStore.getState().token;
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
useAuthStore.getState().logout();
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export { api };
|
||||
|
||||
// Auth API
|
||||
export const authApi = {
|
||||
register: (data: { email: string; password: string; name: string; timezone: string }) =>
|
||||
api.post('/auth/register', data),
|
||||
login: (data: { email: string; password: string }) => api.post('/auth/login', data),
|
||||
logout: () => api.post('/auth/logout'),
|
||||
};
|
||||
|
||||
// Inbox API
|
||||
export const inboxApi = {
|
||||
getAll: () => api.get('/inbox'),
|
||||
create: (content: string) => api.post('/inbox', { content }),
|
||||
process: (
|
||||
id: string,
|
||||
data: {
|
||||
action: string;
|
||||
title?: string;
|
||||
context?: string;
|
||||
domain?: string;
|
||||
priority?: number;
|
||||
},
|
||||
) => api.post(`/inbox/${id}/process`, data),
|
||||
delete: (id: string) => api.delete(`/inbox/${id}`),
|
||||
};
|
||||
|
||||
// Tasks API
|
||||
export const tasksApi = {
|
||||
getAll: (params?: { status?: string; context?: string; domain?: string }) =>
|
||||
api.get('/tasks', { params }),
|
||||
getOne: (id: string) => api.get(`/tasks/${id}`),
|
||||
create: (data: {
|
||||
title: string;
|
||||
domain: string;
|
||||
context?: string;
|
||||
priority?: number;
|
||||
estimatedDuration?: number;
|
||||
dueDate?: string;
|
||||
projectId?: string;
|
||||
notes?: string;
|
||||
status?: string;
|
||||
}) => api.post('/tasks', data),
|
||||
update: (id: string, data: Record<string, unknown>) => api.patch(`/tasks/${id}`, data),
|
||||
delete: (id: string) => api.delete(`/tasks/${id}`),
|
||||
lock: (id: string) => api.post(`/tasks/${id}/lock`),
|
||||
unlock: (id: string) => api.post(`/tasks/${id}/unlock`),
|
||||
};
|
||||
|
||||
// Projects API
|
||||
export const projectsApi = {
|
||||
getAll: (params?: { status?: string; domain?: string }) => api.get('/projects', { params }),
|
||||
getOne: (id: string) => api.get(`/projects/${id}`),
|
||||
getTasks: (id: string) => api.get(`/projects/${id}/tasks`),
|
||||
create: (data: {
|
||||
name: string;
|
||||
domain: string;
|
||||
description?: string;
|
||||
desiredOutcome?: string;
|
||||
}) => api.post('/projects', data),
|
||||
update: (id: string, data: Record<string, unknown>) => api.patch(`/projects/${id}`, data),
|
||||
delete: (id: string) => api.delete(`/projects/${id}`),
|
||||
};
|
||||
|
||||
// User API
|
||||
export const userApi = {
|
||||
getMe: () => api.get('/users/me'),
|
||||
getPreferences: () => api.get('/users/preferences'),
|
||||
updatePreferences: (data: Record<string, unknown>) => api.patch('/users/preferences', data),
|
||||
};
|
||||
16
packages/frontend/src/lib/utils.test.ts
Normal file
16
packages/frontend/src/lib/utils.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { cn } from './utils';
|
||||
|
||||
describe('cn utility', () => {
|
||||
it('should merge class names', () => {
|
||||
expect(cn('foo', 'bar')).toBe('foo bar');
|
||||
});
|
||||
|
||||
it('should handle conditional classes', () => {
|
||||
expect(cn('foo', false && 'bar', 'baz')).toBe('foo baz');
|
||||
});
|
||||
|
||||
it('should merge tailwind classes correctly', () => {
|
||||
expect(cn('px-2 py-1', 'px-4')).toBe('py-1 px-4');
|
||||
});
|
||||
});
|
||||
6
packages/frontend/src/lib/utils.ts
Normal file
6
packages/frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
25
packages/frontend/src/main.tsx
Normal file
25
packages/frontend/src/main.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
18
packages/frontend/src/pages/CalendarPage.tsx
Normal file
18
packages/frontend/src/pages/CalendarPage.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
export function CalendarPage() {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2">Calendar</h1>
|
||||
<p className="text-muted-foreground">
|
||||
View and manage your scheduled tasks.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted rounded-lg p-12 text-center text-muted-foreground">
|
||||
Calendar view will be implemented in Phase 2 with FullCalendar integration.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
packages/frontend/src/pages/InboxPage.tsx
Normal file
128
packages/frontend/src/pages/InboxPage.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, ArrowRight, Trash2 } from 'lucide-react';
|
||||
import { inboxApi } from '../lib/api';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { Input } from '../components/ui/Input';
|
||||
import { Card, CardContent } from '../components/ui/Card';
|
||||
import { ProcessModal } from '../components/inbox/ProcessModal';
|
||||
import type { InboxItemResponseDto } from '@nick-tracker/shared-types';
|
||||
|
||||
export function InboxPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [newItem, setNewItem] = useState('');
|
||||
const [selectedItem, setSelectedItem] = useState<InboxItemResponseDto | null>(null);
|
||||
|
||||
const { data: items = [], isLoading } = useQuery({
|
||||
queryKey: ['inbox'],
|
||||
queryFn: async () => {
|
||||
const response = await inboxApi.getAll();
|
||||
return response.data as InboxItemResponseDto[];
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (content: string) => inboxApi.create(content),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['inbox'] });
|
||||
setNewItem('');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => inboxApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['inbox'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (newItem.trim()) {
|
||||
createMutation.mutate(newItem.trim());
|
||||
}
|
||||
};
|
||||
|
||||
const handleProcess = (item: InboxItemResponseDto) => {
|
||||
setSelectedItem(item);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setSelectedItem(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['inbox'] });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2">Inbox</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Capture everything on your mind. Process items to clarify what they mean.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mb-8 flex gap-2">
|
||||
<Input
|
||||
value={newItem}
|
||||
onChange={(e) => setNewItem(e.target.value)}
|
||||
placeholder="What's on your mind?"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button type="submit" disabled={createMutation.isPending || !newItem.trim()}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center text-muted-foreground py-8">Loading...</div>
|
||||
) : items.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
Your inbox is empty. Add items above to capture thoughts and tasks.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{items.map((item) => (
|
||||
<Card key={item.id}>
|
||||
<CardContent className="py-4 flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{item.content}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{new Date(item.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleProcess(item)}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4 mr-2" />
|
||||
Process
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => deleteMutation.mutate(item.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedItem && (
|
||||
<ProcessModal item={selectedItem} onClose={handleCloseModal} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
packages/frontend/src/pages/LoginPage.tsx
Normal file
89
packages/frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { authApi } from '../lib/api';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { Input } from '../components/ui/Input';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '../components/ui/Card';
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const setAuth = useAuthStore((state) => state.setAuth);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await authApi.login({ email, password });
|
||||
setAuth(response.data.token, response.data.user);
|
||||
navigate('/inbox');
|
||||
} catch (err) {
|
||||
setError('Invalid email or password');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-muted">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle>Sign In</CardTitle>
|
||||
<CardDescription>Enter your credentials to access your account</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="text-sm font-medium">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-sm font-medium">
|
||||
Password
|
||||
</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-4">
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Don't have an account?{' '}
|
||||
<Link to="/register" className="text-primary hover:underline">
|
||||
Register
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
packages/frontend/src/pages/ProjectsPage.tsx
Normal file
60
packages/frontend/src/pages/ProjectsPage.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { FolderKanban } from 'lucide-react';
|
||||
import { projectsApi } from '../lib/api';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/Card';
|
||||
import type { ProjectResponseDto } from '@nick-tracker/shared-types';
|
||||
|
||||
export function ProjectsPage() {
|
||||
const { data: projects = [], isLoading } = useQuery({
|
||||
queryKey: ['projects'],
|
||||
queryFn: async () => {
|
||||
const response = await projectsApi.getAll();
|
||||
return response.data as ProjectResponseDto[];
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2">Projects</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Multi-step outcomes requiring more than one action.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center text-muted-foreground py-8">Loading...</div>
|
||||
) : projects.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<FolderKanban className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">
|
||||
No projects yet. Process inbox items to create projects.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{projects.map((project) => (
|
||||
<Card key={project.id}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{project.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="px-2 py-0.5 bg-muted rounded">{project.domain}</span>
|
||||
<span className="px-2 py-0.5 bg-muted rounded">{project.status}</span>
|
||||
</div>
|
||||
{project.description && (
|
||||
<p className="mt-2 text-sm">{project.description}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
packages/frontend/src/pages/RegisterPage.tsx
Normal file
108
packages/frontend/src/pages/RegisterPage.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { authApi } from '../lib/api';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { Input } from '../components/ui/Input';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '../components/ui/Card';
|
||||
|
||||
export function RegisterPage() {
|
||||
const navigate = useNavigate();
|
||||
const setAuth = useAuthStore((state) => state.setAuth);
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const response = await authApi.register({ name, email, password, timezone });
|
||||
setAuth(response.data.token, response.data.user);
|
||||
navigate('/inbox');
|
||||
} catch (err) {
|
||||
setError('Registration failed. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-muted">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle>Create Account</CardTitle>
|
||||
<CardDescription>Start organizing your tasks with GTD</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="name" className="text-sm font-medium">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Your name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="text-sm font-medium">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-sm font-medium">
|
||||
Password
|
||||
</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Create a password"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Password must be at least 8 characters with uppercase, lowercase, and number.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-4">
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? 'Creating account...' : 'Create Account'}
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="text-primary hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
packages/frontend/src/pages/SettingsPage.tsx
Normal file
54
packages/frontend/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '../components/ui/Card';
|
||||
|
||||
export function SettingsPage() {
|
||||
const user = useAuthStore((state) => state.user);
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2">Settings</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your account and preferences.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profile</CardTitle>
|
||||
<CardDescription>Your account information</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Name</label>
|
||||
<p className="mt-1">{user?.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Email</label>
|
||||
<p className="mt-1">{user?.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Timezone</label>
|
||||
<p className="mt-1">{user?.timezone}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Integrations</CardTitle>
|
||||
<CardDescription>Connect your calendars and email</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
Calendar and email integrations will be available in Phase 3.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
packages/frontend/src/store/auth.ts
Normal file
24
packages/frontend/src/store/auth.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { UserResponseDto } from '@nick-tracker/shared-types';
|
||||
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
user: UserResponseDto | null;
|
||||
setAuth: (token: string, user: UserResponseDto) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
token: null,
|
||||
user: null,
|
||||
setAuth: (token, user) => set({ token, user }),
|
||||
logout: () => set({ token: null, user: null }),
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
},
|
||||
),
|
||||
);
|
||||
1
packages/frontend/src/test/setup.ts
Normal file
1
packages/frontend/src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
Reference in New Issue
Block a user