feat: update ProjectE application
This commit is contained in:
@@ -67,6 +67,13 @@ export function AnalyticsCharts({
|
||||
habitData,
|
||||
activeTab,
|
||||
}: AnalyticsChartsProps) {
|
||||
const hasActivity = timeData.some((day) => day.tasks > 0 || day.habits > 0 || day.time > 0);
|
||||
const hasRecentActivity = timeData.slice(-7).some((day) => day.tasks > 0 || day.habits > 0);
|
||||
const activitySummary = timeData
|
||||
.filter((day) => day.tasks > 0 || day.habits > 0 || day.time > 0)
|
||||
.map((day) => `${day.date}: ${day.tasks} tasks completed, ${day.habits} habits logged, ${day.time} minutes tracked.`)
|
||||
.join(' ');
|
||||
|
||||
if (activeTab === 'trends') {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
@@ -79,43 +86,23 @@ export function AnalyticsCharts({
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={timeData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(var(--border))"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
/>
|
||||
<Tooltip contentStyle={chartTooltipStyle} />
|
||||
<Legend />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="tasks"
|
||||
stackId="1"
|
||||
stroke="#3b82f6"
|
||||
fill="#3b82f6"
|
||||
fillOpacity={0.6}
|
||||
name="Tasks Completed"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="habits"
|
||||
stackId="1"
|
||||
stroke="#10b981"
|
||||
fill="#10b981"
|
||||
fillOpacity={0.6}
|
||||
name="Habits Logged"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
{!hasActivity ? (
|
||||
<p className="py-8 text-center text-muted-foreground">No completed tasks or habit logs in the last 30 days.</p>
|
||||
) : (
|
||||
<div role="img" aria-label={`Productivity trend. ${activitySummary}`}>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={timeData} aria-hidden="true">
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
||||
<XAxis dataKey="date" stroke="hsl(var(--muted-foreground))" fontSize={12} />
|
||||
<YAxis stroke="hsl(var(--muted-foreground))" fontSize={12} />
|
||||
<Tooltip contentStyle={chartTooltipStyle} />
|
||||
<Legend />
|
||||
<Area type="monotone" dataKey="tasks" stackId="1" stroke="#3b82f6" fill="#3b82f6" fillOpacity={0.6} name="Tasks Completed" />
|
||||
<Area type="monotone" dataKey="habits" stackId="1" stroke="#10b981" fill="#10b981" fillOpacity={0.6} name="Habits Logged" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -128,37 +115,21 @@ export function AnalyticsCharts({
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={timeData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(var(--border))"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
label={{
|
||||
value: 'Minutes',
|
||||
angle: -90,
|
||||
position: 'insideLeft',
|
||||
}}
|
||||
/>
|
||||
<Tooltip contentStyle={chartTooltipStyle} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="time"
|
||||
stroke="#8b5cf6"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: '#8b5cf6', r: 3 }}
|
||||
name="Time (minutes)"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
{timeData.some((day) => day.time > 0) ? (
|
||||
<div role="img" aria-label={`Time tracked over the last 30 days. ${activitySummary}`}>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={timeData} aria-hidden="true">
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
||||
<XAxis dataKey="date" stroke="hsl(var(--muted-foreground))" fontSize={12} />
|
||||
<YAxis stroke="hsl(var(--muted-foreground))" fontSize={12} label={{ value: 'Minutes', angle: -90, position: 'insideLeft' }} />
|
||||
<Tooltip contentStyle={chartTooltipStyle} />
|
||||
<Line type="monotone" dataKey="time" stroke="#8b5cf6" strokeWidth={2} dot={{ fill: '#8b5cf6', r: 3 }} name="Time (minutes)" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-8 text-center text-muted-foreground">No time tracked in the last 30 days.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -182,8 +153,9 @@ export function AnalyticsCharts({
|
||||
No habits tracked
|
||||
</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={habitData} layout="vertical">
|
||||
<div role="img" aria-label={`Habit streaks: ${habitData.map((habit) => `${habit.name}, ${habit.streak} days`).join('; ')}.`}>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={habitData} layout="vertical">
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(var(--border))"
|
||||
@@ -207,8 +179,9 @@ export function AnalyticsCharts({
|
||||
radius={[0, 4, 4, 0]}
|
||||
name="Current Streak (days)"
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -239,6 +212,11 @@ export function AnalyticsCharts({
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all"
|
||||
style={{ width: `${habit.score}%` }}
|
||||
role="progressbar"
|
||||
aria-label={`${habit.name} score`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={habit.score}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -269,6 +247,7 @@ export function AnalyticsCharts({
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex items-center gap-8">
|
||||
<p className="sr-only">Time by domain: {domainData.map((domain) => `${domain.name}, ${domain.value} minutes`).join('; ')}.</p>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
@@ -304,8 +283,12 @@ export function AnalyticsCharts({
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={timeData.slice(-7)}>
|
||||
{!hasRecentActivity ? (
|
||||
<p className="py-8 text-center text-muted-foreground">No task or habit activity in the last 7 days.</p>
|
||||
) : (
|
||||
<div role="img" aria-label={`Daily activity for the last 7 days. ${activitySummary}`}>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={timeData.slice(-7)}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(var(--border))"
|
||||
@@ -333,8 +316,10 @@ export function AnalyticsCharts({
|
||||
name="Habits"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Calendar, dateFnsLocalizer } from 'react-big-calendar';
|
||||
import 'react-big-calendar/lib/css/react-big-calendar.css';
|
||||
import { format, parse, startOfWeek, getDay } from 'date-fns';
|
||||
import { enUS } from 'date-fns/locale/en-US';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
const locales = {
|
||||
'en-US': enUS,
|
||||
@@ -22,9 +23,10 @@ interface CalendarEvent {
|
||||
title: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
type: 'task' | 'habit' | 'project' | 'milestone';
|
||||
type: 'task' | 'project' | 'milestone';
|
||||
domain: string;
|
||||
color: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
interface BigCalendarWrapperProps {
|
||||
@@ -44,11 +46,9 @@ function eventStyleGetter(event: CalendarEvent) {
|
||||
};
|
||||
}
|
||||
|
||||
function handleSelectEvent(event: CalendarEvent) {
|
||||
console.log('Selected event:', event);
|
||||
}
|
||||
|
||||
export function BigCalendarWrapper({ events }: BigCalendarWrapperProps) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Calendar
|
||||
localizer={localizer}
|
||||
@@ -57,7 +57,7 @@ export function BigCalendarWrapper({ events }: BigCalendarWrapperProps) {
|
||||
endAccessor="end"
|
||||
style={{ height: 600 }}
|
||||
eventPropGetter={eventStyleGetter}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
onSelectEvent={(event) => router.push(event.href)}
|
||||
views={['month', 'week', 'day']}
|
||||
defaultView="month"
|
||||
popup
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
type ItemType = 'task' | 'project' | 'habit';
|
||||
|
||||
const labels = {
|
||||
task: { title: 'New task', field: 'Task title' },
|
||||
project: { title: 'New project', field: 'Project name' },
|
||||
habit: { title: 'New habit', field: 'Habit name' },
|
||||
} as const;
|
||||
|
||||
export function CreateItemDialog({
|
||||
type,
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreated,
|
||||
}: {
|
||||
type: ItemType;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
const [domain, setDomain] = useState('General');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const copy = labels[type];
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
|
||||
const body =
|
||||
type === 'task'
|
||||
? { title: name, domain, status: 'todo', priority: 'medium', tags: [] }
|
||||
: type === 'project'
|
||||
? { name, domain, status: 'active', tags: [] }
|
||||
: {
|
||||
name,
|
||||
domain,
|
||||
frequency: 'daily',
|
||||
difficulty: 'medium',
|
||||
completion_mode: 'quick',
|
||||
goal_per_period: 1,
|
||||
active: true,
|
||||
tags: [],
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/${type === 'task' ? 'tasks' : `${type}s`}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Unable to create item');
|
||||
}
|
||||
|
||||
setName('');
|
||||
onOpenChange(false);
|
||||
onCreated();
|
||||
} catch {
|
||||
setError(`Unable to create this ${type}. Please try again.`);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copy.title}</DialogTitle>
|
||||
<DialogDescription>Give it a name and choose where it belongs.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${type}-name`}>{copy.field}</Label>
|
||||
<Input
|
||||
id={`${type}-name`}
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${type}-domain`}>Domain</Label>
|
||||
<Input
|
||||
id={`${type}-domain`}
|
||||
value={domain}
|
||||
onChange={(event) => setDomain(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive" role="alert">{error}</p>}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? 'Creating...' : `Create ${type}`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import ReactGridLayout from 'react-grid-layout';
|
||||
import { ResponsiveGridLayout, useContainerWidth, verticalCompactor } from 'react-grid-layout';
|
||||
import type { Layout } from 'react-grid-layout';
|
||||
import 'react-grid-layout/css/styles.css';
|
||||
import 'react-resizable/css/styles.css';
|
||||
|
||||
// WidthProvider and Responsive are namespace exports from react-grid-layout.
|
||||
// With @types/react-grid-layout's `export =` pattern, we access them via the module.
|
||||
const WidthProvider = (
|
||||
ReactGridLayout as unknown as {
|
||||
WidthProvider: <P extends React.ComponentType<React.ComponentProps<P>>>(
|
||||
component: P
|
||||
) => React.ComponentType<React.ComponentProps<P> & { measureBeforeMount?: boolean }>;
|
||||
}
|
||||
).WidthProvider;
|
||||
|
||||
const Responsive = (
|
||||
ReactGridLayout as unknown as {
|
||||
Responsive: React.ComponentType<ReactGridLayout.ResponsiveProps>;
|
||||
}
|
||||
).Responsive;
|
||||
|
||||
const ResponsiveGridLayout = WidthProvider(Responsive);
|
||||
|
||||
interface ResponsiveGridProps {
|
||||
layout: ReactGridLayout.Layout[];
|
||||
onLayoutChange: (newLayout: ReactGridLayout.Layout[]) => void;
|
||||
layout: Layout;
|
||||
onLayoutChange: (newLayout: Layout) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -33,19 +16,26 @@ export default function ResponsiveGrid({
|
||||
onLayoutChange,
|
||||
children,
|
||||
}: ResponsiveGridProps) {
|
||||
const { width, containerRef, mounted } = useContainerWidth();
|
||||
|
||||
return (
|
||||
<ResponsiveGridLayout
|
||||
className="layout"
|
||||
layouts={{ lg: layout }}
|
||||
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
||||
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
|
||||
rowHeight={80}
|
||||
onLayoutChange={onLayoutChange}
|
||||
draggableHandle=".widget-drag-handle"
|
||||
compactType="vertical"
|
||||
isResizable
|
||||
>
|
||||
{children}
|
||||
</ResponsiveGridLayout>
|
||||
<div ref={containerRef}>
|
||||
{mounted && (
|
||||
<ResponsiveGridLayout
|
||||
className="layout"
|
||||
width={width}
|
||||
layouts={{ lg: layout }}
|
||||
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
||||
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
|
||||
rowHeight={80}
|
||||
onLayoutChange={(_layout, _layouts) => onLayoutChange(_layout)}
|
||||
dragConfig={{ handle: '.widget-drag-handle' }}
|
||||
compactor={verticalCompactor}
|
||||
resizeConfig={{ enabled: true }}
|
||||
>
|
||||
{children}
|
||||
</ResponsiveGridLayout>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ export function ProjectProgressWidget() {
|
||||
{project.progress}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={project.progress} className="h-2" />
|
||||
<Progress value={project.progress} className="h-2" aria-label={`${project.name} progress: ${project.progress}%`} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function QuickAddWidget() {
|
||||
function handleQuickAdd() {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'k', metaKey: true })
|
||||
);
|
||||
}
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Card className="h-full border-0 shadow-none">
|
||||
@@ -25,7 +22,7 @@ export function QuickAddWidget() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="justify-start"
|
||||
onClick={handleQuickAdd}
|
||||
onClick={() => router.push('/tasks?new=true')}
|
||||
>
|
||||
New task
|
||||
</Button>
|
||||
@@ -33,7 +30,7 @@ export function QuickAddWidget() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="justify-start"
|
||||
onClick={handleQuickAdd}
|
||||
onClick={() => router.push('/habits?new=true')}
|
||||
>
|
||||
New habit
|
||||
</Button>
|
||||
@@ -41,7 +38,7 @@ export function QuickAddWidget() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="justify-start"
|
||||
onClick={handleQuickAdd}
|
||||
onClick={() => router.push('/notes?new=true')}
|
||||
>
|
||||
New note
|
||||
</Button>
|
||||
|
||||
@@ -59,7 +59,7 @@ export function HabitCard({ habit, onComplete }: HabitCardProps) {
|
||||
<span className="text-muted-foreground">Score</span>
|
||||
<span className="font-semibold">{habit.score}/100</span>
|
||||
</div>
|
||||
<Progress value={habit.score} className="h-2" />
|
||||
<Progress value={habit.score} className="h-2" aria-label={`${habit.name} score: ${habit.score} out of 100`} />
|
||||
</div>
|
||||
|
||||
{/* Frequency badge */}
|
||||
|
||||
@@ -89,7 +89,7 @@ export function HabitCompletionDialog({
|
||||
<Input
|
||||
id="quantity"
|
||||
type="number"
|
||||
placeholder="e.g., 30 minutes, 10 pages"
|
||||
placeholder="e.g., 30"
|
||||
value={quantity ?? ''}
|
||||
onChange={(e) =>
|
||||
setQuantity(e.target.value ? Number(e.target.value) : undefined)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import CalendarHeatmap from 'react-calendar-heatmap';
|
||||
import type { Habit } from '@project-e/shared';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface HeatmapValue {
|
||||
date: Date | string;
|
||||
@@ -16,6 +17,7 @@ interface HabitHeatmapProps {
|
||||
export function HabitHeatmap({ habits }: HabitHeatmapProps) {
|
||||
const [values, setValues] = useState<HeatmapValue[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHeatmapData();
|
||||
@@ -23,6 +25,8 @@ export function HabitHeatmap({ habits }: HabitHeatmapProps) {
|
||||
}, [habits.length]);
|
||||
|
||||
async function fetchHeatmapData() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const oneYearAgo = new Date();
|
||||
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
|
||||
@@ -30,28 +34,30 @@ export function HabitHeatmap({ habits }: HabitHeatmapProps) {
|
||||
const response = await fetch(
|
||||
`/api/habit-logs?start=${oneYearAgo.toISOString()}`
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const logs: Array<{ logged_at: string }> = data.items || [];
|
||||
|
||||
// Group by date
|
||||
const byDate: Record<string, number> = {};
|
||||
logs.forEach((log) => {
|
||||
const date = new Date(log.logged_at).toISOString().split('T')[0];
|
||||
byDate[date] = (byDate[date] || 0) + 1;
|
||||
});
|
||||
|
||||
const heatmapValues: HeatmapValue[] = Object.entries(byDate).map(
|
||||
([date, count]) => ({
|
||||
date,
|
||||
count,
|
||||
})
|
||||
);
|
||||
|
||||
setValues(heatmapValues);
|
||||
if (!response.ok) {
|
||||
throw new Error('Habit logs could not be loaded.');
|
||||
}
|
||||
const data = await response.json();
|
||||
const logs: Array<{ logged_at: string }> = data.items || [];
|
||||
|
||||
// Group logs by calendar date.
|
||||
const byDate: Record<string, number> = {};
|
||||
logs.forEach((log) => {
|
||||
const date = new Date(log.logged_at).toISOString().split('T')[0];
|
||||
byDate[date] = (byDate[date] || 0) + 1;
|
||||
});
|
||||
|
||||
const heatmapValues: HeatmapValue[] = Object.entries(byDate).map(
|
||||
([date, count]) => ({
|
||||
date,
|
||||
count,
|
||||
})
|
||||
);
|
||||
|
||||
setValues(heatmapValues);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch heatmap data:', error);
|
||||
setError('Habit activity could not be loaded.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -61,12 +67,26 @@ export function HabitHeatmap({ habits }: HabitHeatmapProps) {
|
||||
return <p className="text-sm text-muted-foreground">Loading...</p>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-3 text-sm text-muted-foreground" role="alert">
|
||||
<p>{error}</p>
|
||||
<Button variant="outline" size="sm" onClick={fetchHeatmapData}>Retry</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const oneYearAgo = new Date();
|
||||
oneYearAgo.setFullYear(today.getFullYear() - 1);
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<p className="sr-only">
|
||||
Habit completion heatmap for the past year. {values.length === 0
|
||||
? 'No habit completions recorded.'
|
||||
: values.map((value) => `${new Date(value.date).toLocaleDateString()}: ${value.count} completion${value.count === 1 ? '' : 's'}.`).join(' ')}
|
||||
</p>
|
||||
<CalendarHeatmap
|
||||
startDate={oneYearAgo}
|
||||
endDate={today}
|
||||
|
||||
@@ -9,6 +9,16 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
interface Agent {
|
||||
id: string;
|
||||
@@ -24,20 +34,27 @@ export function SettingsAgents() {
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [newAgentName, setNewAgentName] = useState('');
|
||||
const [newAgentTier, setNewAgentTier] = useState('read_only');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [agentToDelete, setAgentToDelete] = useState<Agent | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAgents();
|
||||
}, []);
|
||||
|
||||
async function fetchAgents() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch('/api/agents');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setAgents(data.items || []);
|
||||
}
|
||||
if (!response.ok) throw new Error('Unable to load agents.');
|
||||
const data = await response.json();
|
||||
setAgents(data.items || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch agents:', error);
|
||||
setError('Unable to load agents. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -46,8 +63,11 @@ export function SettingsAgents() {
|
||||
async function createAgent() {
|
||||
if (!newAgentName.trim()) return;
|
||||
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
setStatus(null);
|
||||
try {
|
||||
await fetch('/api/agents', {
|
||||
const response = await fetch('/api/agents', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -56,22 +76,36 @@ export function SettingsAgents() {
|
||||
status: 'active',
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error('Unable to create agent.');
|
||||
setNewAgentName('');
|
||||
setCreateDialogOpen(false);
|
||||
fetchAgents();
|
||||
setStatus('Agent created successfully.');
|
||||
await fetchAgents();
|
||||
} catch (error) {
|
||||
console.error('Failed to create agent:', error);
|
||||
setError('Unable to create agent. Please try again.');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAgent(id: string) {
|
||||
if (!confirm('Are you sure? This will revoke the agent\'s access.')) return;
|
||||
async function deleteAgent() {
|
||||
if (!agentToDelete) return;
|
||||
|
||||
setDeletingId(agentToDelete.id);
|
||||
setError(null);
|
||||
setStatus(null);
|
||||
try {
|
||||
await fetch(`/api/agents/${id}`, { method: 'DELETE' });
|
||||
fetchAgents();
|
||||
const response = await fetch(`/api/agents/${agentToDelete.id}`, { method: 'DELETE' });
|
||||
if (!response.ok) throw new Error('Unable to delete agent.');
|
||||
setAgentToDelete(null);
|
||||
setStatus('Agent deleted successfully.');
|
||||
await fetchAgents();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete agent:', error);
|
||||
setError('Unable to delete agent. Please try again.');
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +166,13 @@ export function SettingsAgents() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error && (
|
||||
<div className="mb-4 flex items-center justify-between gap-3 text-sm text-destructive" role="alert">
|
||||
<span>{error}</span>
|
||||
<Button variant="outline" size="sm" onClick={fetchAgents} disabled={loading}>Retry</Button>
|
||||
</div>
|
||||
)}
|
||||
{status && <p className="mb-4 text-sm text-muted-foreground" role="status">{status}</p>}
|
||||
{loading ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
Loading agents...
|
||||
@@ -173,8 +214,9 @@ export function SettingsAgents() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => deleteAgent(agent.id)}
|
||||
onClick={() => setAgentToDelete(agent)}
|
||||
aria-label={`Delete agent: ${agent.name}`}
|
||||
disabled={deletingId === agent.id}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" aria-hidden="true" />
|
||||
</Button>
|
||||
@@ -183,6 +225,20 @@ export function SettingsAgents() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<AlertDialog open={!!agentToDelete} onOpenChange={(open) => !open && setAgentToDelete(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete {agentToDelete?.name}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>This will revoke the agent's access.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={!!deletingId}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={deleteAgent} disabled={!!deletingId}>
|
||||
{deletingId ? 'Deleting...' : 'Delete agent'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,16 @@ import { Plus, Trash2 } from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
interface Domain {
|
||||
id: string;
|
||||
@@ -18,20 +28,27 @@ export function SettingsDomains() {
|
||||
const [domains, setDomains] = useState<Domain[]>([]);
|
||||
const [newDomainName, setNewDomainName] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [domainToDelete, setDomainToDelete] = useState<Domain | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDomains();
|
||||
}, []);
|
||||
|
||||
async function fetchDomains() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch('/api/domains?sort=sort_order');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setDomains(data.items || []);
|
||||
}
|
||||
if (!response.ok) throw new Error('Unable to load domains.');
|
||||
const data = await response.json();
|
||||
setDomains(data.items || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch domains:', error);
|
||||
setError('Unable to load domains. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -40,8 +57,11 @@ export function SettingsDomains() {
|
||||
async function addDomain() {
|
||||
if (!newDomainName.trim()) return;
|
||||
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
setStatus(null);
|
||||
try {
|
||||
await fetch('/api/domains', {
|
||||
const response = await fetch('/api/domains', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -51,21 +71,35 @@ export function SettingsDomains() {
|
||||
sort_order: domains.length,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error('Unable to add domain.');
|
||||
setNewDomainName('');
|
||||
fetchDomains();
|
||||
setStatus('Domain added successfully.');
|
||||
await fetchDomains();
|
||||
} catch (error) {
|
||||
console.error('Failed to add domain:', error);
|
||||
setError('Unable to add domain. Please try again.');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDomain(id: string) {
|
||||
if (!confirm('Are you sure? This cannot be undone.')) return;
|
||||
async function deleteDomain() {
|
||||
if (!domainToDelete) return;
|
||||
|
||||
setDeletingId(domainToDelete.id);
|
||||
setError(null);
|
||||
setStatus(null);
|
||||
try {
|
||||
await fetch(`/api/domains/${id}`, { method: 'DELETE' });
|
||||
fetchDomains();
|
||||
const response = await fetch(`/api/domains/${domainToDelete.id}`, { method: 'DELETE' });
|
||||
if (!response.ok) throw new Error('Unable to delete domain.');
|
||||
setDomainToDelete(null);
|
||||
setStatus('Domain deleted successfully.');
|
||||
await fetchDomains();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete domain:', error);
|
||||
setError('Unable to delete domain. Please try again.');
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +110,13 @@ export function SettingsDomains() {
|
||||
<CardDescription>Manage your workspace domains (e.g., Personal, Work, OTS).</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<div className="flex items-center justify-between gap-3 text-sm text-destructive" role="alert">
|
||||
<span>{error}</span>
|
||||
<Button variant="outline" size="sm" onClick={fetchDomains} disabled={loading}>Retry</Button>
|
||||
</div>
|
||||
)}
|
||||
{status && <p className="text-sm text-muted-foreground" role="status">{status}</p>}
|
||||
{/* Existing domains */}
|
||||
<div className="space-y-2">
|
||||
{loading ? (
|
||||
@@ -93,8 +134,9 @@ export function SettingsDomains() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => deleteDomain(domain.id)}
|
||||
onClick={() => setDomainToDelete(domain)}
|
||||
aria-label={`Delete domain: ${domain.name}`}
|
||||
disabled={deletingId === domain.id}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" aria-hidden="true" />
|
||||
</Button>
|
||||
@@ -114,12 +156,27 @@ export function SettingsDomains() {
|
||||
value={newDomainName}
|
||||
onChange={(e) => setNewDomainName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addDomain()}
|
||||
disabled={creating}
|
||||
/>
|
||||
<Button onClick={addDomain}>
|
||||
<Button onClick={addDomain} disabled={creating || !newDomainName.trim()}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Add
|
||||
{creating ? 'Adding...' : 'Add'}
|
||||
</Button>
|
||||
</div>
|
||||
<AlertDialog open={!!domainToDelete} onOpenChange={(open) => !open && setDomainToDelete(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete {domainToDelete?.name}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>This cannot be undone.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={!!deletingId}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={deleteDomain} disabled={!!deletingId}>
|
||||
{deletingId ? 'Deleting...' : 'Delete domain'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -66,16 +66,20 @@ export function SettingsImportExport() {
|
||||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||
const [confirmImport, setConfirmImport] = useState(false);
|
||||
const [pendingImportFile, setPendingImportFile] = useState<File | null>(null);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
|
||||
// ── Export ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleExport() {
|
||||
setExporting(true);
|
||||
setExportProgress(0);
|
||||
setExportError(null);
|
||||
let progressInterval: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
try {
|
||||
// Simulate progress while fetching
|
||||
const progressInterval = setInterval(() => {
|
||||
progressInterval = setInterval(() => {
|
||||
setExportProgress((prev) => Math.min(prev + 10, 90));
|
||||
}, 200);
|
||||
|
||||
@@ -86,6 +90,7 @@ export function SettingsImportExport() {
|
||||
});
|
||||
|
||||
clearInterval(progressInterval);
|
||||
progressInterval = undefined;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Export failed');
|
||||
@@ -108,8 +113,10 @@ export function SettingsImportExport() {
|
||||
toast.success('Export completed successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to export:', error);
|
||||
setExportError('Failed to export data. Please try again.');
|
||||
toast.error('Failed to export data');
|
||||
} finally {
|
||||
if (progressInterval) clearInterval(progressInterval);
|
||||
setExporting(false);
|
||||
setExportProgress(0);
|
||||
}
|
||||
@@ -131,6 +138,7 @@ export function SettingsImportExport() {
|
||||
|
||||
setPendingImportFile(file);
|
||||
setImportResult(null);
|
||||
setImportError(null);
|
||||
setConfirmImport(true);
|
||||
}
|
||||
|
||||
@@ -140,18 +148,21 @@ export function SettingsImportExport() {
|
||||
setConfirmImport(false);
|
||||
setImporting(true);
|
||||
setImportProgress(0);
|
||||
setImportError(null);
|
||||
let progressInterval: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
try {
|
||||
const text = await pendingImportFile.text();
|
||||
const data = JSON.parse(text);
|
||||
|
||||
if (!data.version) {
|
||||
setImportError('Invalid file. Select a valid Project E export and try again.');
|
||||
toast.error('Invalid file — missing version field. Is this a valid Project E export?');
|
||||
return;
|
||||
}
|
||||
|
||||
// Simulate progress
|
||||
const progressInterval = setInterval(() => {
|
||||
progressInterval = setInterval(() => {
|
||||
setImportProgress((prev) => Math.min(prev + 5, 90));
|
||||
}, 300);
|
||||
|
||||
@@ -162,16 +173,25 @@ export function SettingsImportExport() {
|
||||
});
|
||||
|
||||
clearInterval(progressInterval);
|
||||
progressInterval = undefined;
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
toast.error(errorData.error?.message || 'Import failed');
|
||||
let message = 'Import failed. Please try again.';
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
message = errorData.error?.message || message;
|
||||
} catch {
|
||||
// Use the default message when the server does not return JSON.
|
||||
}
|
||||
setImportError(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const result: ImportResult = await response.json();
|
||||
setImportProgress(100);
|
||||
setImportResult(result);
|
||||
setPendingImportFile(null);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(`Import complete: ${result.imported} records imported`);
|
||||
@@ -182,10 +202,11 @@ export function SettingsImportExport() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to import:', error);
|
||||
setImportError('Failed to parse or import the file. Please try again.');
|
||||
toast.error('Failed to parse import file. Please check the format.');
|
||||
} finally {
|
||||
if (progressInterval) clearInterval(progressInterval);
|
||||
setImporting(false);
|
||||
setPendingImportFile(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,7 +276,7 @@ export function SettingsImportExport() {
|
||||
{/* Progress */}
|
||||
{exporting && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<Progress value={exportProgress} className="h-2" />
|
||||
<Progress value={exportProgress} className="h-2" aria-label={`Export progress: ${exportProgress}%`} />
|
||||
<p className="text-xs text-muted-foreground">Exporting... {exportProgress}%</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -272,6 +293,7 @@ export function SettingsImportExport() {
|
||||
)}
|
||||
{exporting ? 'Exporting...' : 'Export to JSON'}
|
||||
</Button>
|
||||
{exportError && <p className="mt-2 text-sm text-destructive" role="alert">{exportError}</p>}
|
||||
</div>
|
||||
|
||||
{/* ── Import Section ─────────────────────────────────────────────────── */}
|
||||
@@ -284,7 +306,7 @@ export function SettingsImportExport() {
|
||||
{/* Progress */}
|
||||
{importing && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<Progress value={importProgress} className="h-2" />
|
||||
<Progress value={importProgress} className="h-2" aria-label={`Import progress: ${importProgress}%`} />
|
||||
<p className="text-xs text-muted-foreground">Importing... {importProgress}%</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -356,6 +378,12 @@ export function SettingsImportExport() {
|
||||
</span>
|
||||
</Button>
|
||||
</label>
|
||||
{importError && (
|
||||
<div className="mt-2 flex items-center gap-3 text-sm text-destructive" role="alert">
|
||||
<span>{importError}</span>
|
||||
{pendingImportFile && <Button variant="outline" size="sm" onClick={executeImport} disabled={importing}>Retry import</Button>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Import Confirmation Dialog ─────────────────────────────────────── */}
|
||||
|
||||
@@ -21,7 +21,10 @@ export function ShortcutsHelp() {
|
||||
if (
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable
|
||||
target.tagName === 'SELECT' ||
|
||||
target.tagName === 'BUTTON' ||
|
||||
target.isContentEditable ||
|
||||
target.closest('[role="dialog"], [role="menu"], [role="listbox"], [role="combobox"]')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,13 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
|
||||
const navItems = [
|
||||
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
@@ -46,13 +53,88 @@ const workspaceItems = [
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const { collapsed, toggle } = useSidebarStore();
|
||||
const { collapsed, toggle, mobileOpen, setMobileOpen } = useSidebarStore();
|
||||
|
||||
const navigation = (isCollapsed: boolean, onNavigate?: () => void) => (
|
||||
<ScrollArea className="flex-1 py-2">
|
||||
<nav className="flex flex-col gap-1 px-2" aria-label="Primary">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
|
||||
const link = (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
{!isCollapsed && <span>{item.label}</span>}
|
||||
</Link>
|
||||
);
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<Tooltip key={item.href}>
|
||||
<TooltipTrigger asChild>{link}</TooltipTrigger>
|
||||
<TooltipContent side="right">{item.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return link;
|
||||
})}
|
||||
</nav>
|
||||
<Separator className="my-3" />
|
||||
<nav className="flex flex-col gap-1 px-2" aria-label="Workspace">
|
||||
{!isCollapsed && (
|
||||
<p className="px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Workspace
|
||||
</p>
|
||||
)}
|
||||
{workspaceItems.map((item) => {
|
||||
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
|
||||
const link = (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
{!isCollapsed && <span>{item.label}</span>}
|
||||
</Link>
|
||||
);
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<Tooltip key={item.href}>
|
||||
<TooltipTrigger asChild>{link}</TooltipTrigger>
|
||||
<TooltipContent side="right">{item.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return link;
|
||||
})}
|
||||
</nav>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<aside
|
||||
className={cn(
|
||||
'flex flex-col border-r bg-card transition-all duration-200',
|
||||
'hidden flex-col border-r bg-card transition-all duration-200 md:flex',
|
||||
collapsed ? 'w-16' : 'w-60'
|
||||
)}
|
||||
aria-label="Main navigation"
|
||||
@@ -80,82 +162,17 @@ export function Sidebar() {
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Navigation */}
|
||||
<ScrollArea className="flex-1 py-2">
|
||||
<nav className="flex flex-col gap-1 px-2" aria-label="Primary">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
|
||||
const link = (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
</Link>
|
||||
);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Tooltip key={item.href}>
|
||||
<TooltipTrigger asChild>{link}</TooltipTrigger>
|
||||
<TooltipContent side="right">{item.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return link;
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<Separator className="my-3" />
|
||||
|
||||
<nav className="flex flex-col gap-1 px-2" aria-label="Workspace">
|
||||
{!collapsed && (
|
||||
<p className="px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Workspace
|
||||
</p>
|
||||
)}
|
||||
{workspaceItems.map((item) => {
|
||||
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
|
||||
const link = (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
</Link>
|
||||
);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Tooltip key={item.href}>
|
||||
<TooltipTrigger asChild>{link}</TooltipTrigger>
|
||||
<TooltipContent side="right">{item.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return link;
|
||||
})}
|
||||
</nav>
|
||||
</ScrollArea>
|
||||
{navigation(collapsed)}
|
||||
</aside>
|
||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||
<SheetContent side="left" className="flex w-72 flex-col p-0 md:hidden">
|
||||
<SheetHeader className="border-b px-4 py-4 pr-12">
|
||||
<SheetTitle>Project E</SheetTitle>
|
||||
<SheetDescription>Navigate your workspace.</SheetDescription>
|
||||
</SheetHeader>
|
||||
{navigation(false, () => setMobileOpen(false))}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTitle
|
||||
} from '@/components/ui/sheet';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -16,8 +17,18 @@ import {
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectValue
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
@@ -42,7 +53,7 @@ export function TaskDetailPanel({
|
||||
task,
|
||||
open,
|
||||
onOpenChange,
|
||||
onUpdate,
|
||||
onUpdate
|
||||
}: TaskDetailPanelProps) {
|
||||
const [title, setTitle] = useState(task.title);
|
||||
const [description, setDescription] = useState(task.description || '');
|
||||
@@ -51,11 +62,13 @@ export function TaskDetailPanel({
|
||||
const [domain, setDomain] = useState(task.domain);
|
||||
const [dueDate, setDueDate] = useState(task.due_date || '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await fetch(`/api/tasks/${task.id}`, {
|
||||
const response = await fetch(`/api/tasks/${task.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -64,27 +77,37 @@ export function TaskDetailPanel({
|
||||
status,
|
||||
priority,
|
||||
domain,
|
||||
due_date: dueDate || null,
|
||||
}),
|
||||
due_date: dueDate || null
|
||||
})
|
||||
});
|
||||
if (!response.ok) throw new Error('Unable to save task');
|
||||
onUpdate();
|
||||
onOpenChange(false);
|
||||
toast.success('Task saved');
|
||||
} catch (error) {
|
||||
console.error('Failed to update task:', error);
|
||||
toast.error('Unable to save task');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm('Are you sure you want to delete this task?')) return;
|
||||
|
||||
setDeleting(true);
|
||||
try {
|
||||
await fetch(`/api/tasks/${task.id}`, { method: 'DELETE' });
|
||||
const response = await fetch(`/api/tasks/${task.id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!response.ok) throw new Error('Unable to delete task');
|
||||
onUpdate();
|
||||
setDeleteOpen(false);
|
||||
onOpenChange(false);
|
||||
toast.success('Task deleted');
|
||||
} catch (error) {
|
||||
console.error('Failed to delete task:', error);
|
||||
toast.error('Unable to delete task');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,13 +212,37 @@ export function TaskDetailPanel({
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
className="ml-auto"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete task?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This permanently deletes "{task.title}".
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
handleDelete();
|
||||
}}
|
||||
disabled={deleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleting ? 'Deleting...' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
@@ -2,17 +2,30 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
KeyboardSensor,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
useDroppable
|
||||
} from '@dnd-kit/core';
|
||||
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar, MoreHorizontal } from 'lucide-react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select';
|
||||
import { Calendar, GripVertical } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { TaskDetailPanel } from './task-detail-panel';
|
||||
|
||||
interface Task {
|
||||
@@ -30,25 +43,27 @@ interface Task {
|
||||
const columns = [
|
||||
{ id: 'todo', title: 'To Do', color: 'bg-slate-500' },
|
||||
{ id: 'in_progress', title: 'In Progress', color: 'bg-blue-500' },
|
||||
{ id: 'done', title: 'Done', color: 'bg-green-500' },
|
||||
{ id: 'done', title: 'Done', color: 'bg-green-500' }
|
||||
];
|
||||
|
||||
function DraggableTask({
|
||||
task,
|
||||
onClick,
|
||||
onStatusChange
|
||||
}: {
|
||||
task: Task;
|
||||
onClick: () => void;
|
||||
onStatusChange: (task: Task, status: Task['status']) => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
||||
useDraggable({
|
||||
id: task.id,
|
||||
data: { task },
|
||||
data: { task }
|
||||
});
|
||||
|
||||
const style = transform
|
||||
? {
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`
|
||||
}
|
||||
: undefined;
|
||||
|
||||
@@ -56,11 +71,7 @@ function DraggableTask({
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={`cursor-grab active:cursor-grabbing ${
|
||||
isDragging ? 'opacity-50' : ''
|
||||
}`}
|
||||
className={isDragging ? 'opacity-50' : ''}
|
||||
>
|
||||
<Card className="mb-2 hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-4">
|
||||
@@ -77,12 +88,32 @@ function DraggableTask({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-11 w-11 shrink-0"
|
||||
aria-label={`More options for ${task.title}`}
|
||||
className="h-11 w-11 shrink-0 cursor-grab active:cursor-grabbing"
|
||||
aria-label={`Drag ${task.title}`}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" aria-hidden="true" />
|
||||
<GripVertical className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<Select
|
||||
value={task.status}
|
||||
onValueChange={(status) =>
|
||||
onStatusChange(task, status as Task['status'])
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="mb-2 h-9"
|
||||
aria-label={`Move ${task.title} to a status`}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todo">To Do</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="done">Done</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge
|
||||
variant={
|
||||
@@ -118,12 +149,14 @@ function DroppableColumn({
|
||||
color,
|
||||
tasks,
|
||||
onTaskClick,
|
||||
onStatusChange
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
color: string;
|
||||
tasks: Task[];
|
||||
onTaskClick: (task: Task) => void;
|
||||
onStatusChange: (task: Task, status: Task['status']) => void;
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id });
|
||||
|
||||
@@ -132,9 +165,7 @@ function DroppableColumn({
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<div className={`h-2 w-2 rounded-full ${color}`} aria-hidden="true" />
|
||||
<h3 className="font-semibold">{title}</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({tasks.length})
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">({tasks.length})</span>
|
||||
</div>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
@@ -149,6 +180,7 @@ function DroppableColumn({
|
||||
key={task.id}
|
||||
task={task}
|
||||
onClick={() => onTaskClick(task)}
|
||||
onStatusChange={onStatusChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -161,6 +193,12 @@ export function TasksKanbanView() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTask, setActiveTask] = useState<Task | null>(null);
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates
|
||||
})
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
@@ -169,12 +207,14 @@ export function TasksKanbanView() {
|
||||
async function fetchTasks() {
|
||||
try {
|
||||
const response = await fetch('/api/tasks?sort=-created');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setTasks(data.items || []);
|
||||
if (!response.ok) {
|
||||
throw new Error('Unable to load tasks');
|
||||
}
|
||||
const data = await response.json();
|
||||
setTasks(data.items || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tasks:', error);
|
||||
toast.error('Unable to load tasks');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -182,23 +222,18 @@ export function TasksKanbanView() {
|
||||
|
||||
async function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
if (!over) {
|
||||
setActiveTask(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const task = active.data.current?.task as Task;
|
||||
const newStatus = over.id as Task['status'];
|
||||
|
||||
if (task.status !== newStatus) {
|
||||
try {
|
||||
await fetch(`/api/tasks/${task.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
});
|
||||
fetchTasks();
|
||||
} catch (error) {
|
||||
console.error('Failed to update task status:', error);
|
||||
}
|
||||
await updateTaskStatus(task, newStatus);
|
||||
}
|
||||
setActiveTask(null);
|
||||
}
|
||||
|
||||
function handleDragStart(event: DragStartEvent) {
|
||||
@@ -206,13 +241,39 @@ export function TasksKanbanView() {
|
||||
setActiveTask(task);
|
||||
}
|
||||
|
||||
async function updateTaskStatus(task: Task, status: Task['status']) {
|
||||
if (task.status === status) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/tasks/${task.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status })
|
||||
});
|
||||
if (!response.ok) throw new Error('Unable to move task');
|
||||
|
||||
await fetchTasks();
|
||||
toast.success(
|
||||
`Moved ${task.title} to ${columns.find((column) => column.id === status)?.title}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to update task status:', error);
|
||||
toast.error(`Unable to move ${task.title}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-muted-foreground">Loading tasks...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DndContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={() => setActiveTask(null)}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{columns.map((column) => (
|
||||
<DroppableColumn
|
||||
@@ -222,6 +283,7 @@ export function TasksKanbanView() {
|
||||
color={column.color}
|
||||
tasks={tasks.filter((t) => t.status === column.id)}
|
||||
onTaskClick={setSelectedTask}
|
||||
onStatusChange={updateTaskStatus}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -7,12 +7,14 @@ import {
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableRow
|
||||
} from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
|
||||
import { Calendar, MoreHorizontal } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { TaskDetailPanel } from './task-detail-panel';
|
||||
|
||||
interface Task {
|
||||
@@ -39,12 +41,14 @@ export function TasksListView() {
|
||||
async function fetchTasks() {
|
||||
try {
|
||||
const response = await fetch('/api/tasks?sort=-created');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setTasks(data.items || []);
|
||||
if (!response.ok) {
|
||||
throw new Error('Unable to load tasks');
|
||||
}
|
||||
const data = await response.json();
|
||||
setTasks(data.items || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tasks:', error);
|
||||
toast.error('Unable to load tasks');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -53,14 +57,19 @@ export function TasksListView() {
|
||||
async function toggleTaskComplete(task: Task) {
|
||||
const newStatus = task.status === 'done' ? 'todo' : 'done';
|
||||
try {
|
||||
await fetch(`/api/tasks/${task.id}`, {
|
||||
const response = await fetch(`/api/tasks/${task.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
body: JSON.stringify({ status: newStatus })
|
||||
});
|
||||
fetchTasks();
|
||||
if (!response.ok) throw new Error('Unable to update task');
|
||||
await fetchTasks();
|
||||
toast.success(
|
||||
`Marked ${task.title} as ${newStatus === 'done' ? 'complete' : 'incomplete'}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle task:', error);
|
||||
toast.error(`Unable to update ${task.title}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,77 +79,82 @@ export function TasksListView() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
<TableHead>Task</TableHead>
|
||||
<TableHead>Priority</TableHead>
|
||||
<TableHead>Domain</TableHead>
|
||||
<TableHead>Due Date</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tasks.map((task) => (
|
||||
<TableRow key={task.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={task.status === 'done'}
|
||||
onCheckedChange={() => toggleTaskComplete(task)}
|
||||
aria-label={`Mark "${task.title}" as ${task.status === 'done' ? 'incomplete' : 'complete'}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<button
|
||||
onClick={() => setSelectedTask(task)}
|
||||
className={`text-left font-medium hover:underline ${
|
||||
task.status === 'done'
|
||||
? 'line-through text-muted-foreground'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{task.title}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
task.priority === 'urgent'
|
||||
? 'destructive'
|
||||
: task.priority === 'high'
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{task.priority}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{task.domain}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{task.due_date && (
|
||||
<span className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{new Date(task.due_date).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-11 w-11"
|
||||
aria-label={`More options for ${task.title}`}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<ScrollArea className="w-full">
|
||||
<div className="min-w-[700px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
<TableHead>Task</TableHead>
|
||||
<TableHead>Priority</TableHead>
|
||||
<TableHead>Domain</TableHead>
|
||||
<TableHead>Due Date</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tasks.map((task) => (
|
||||
<TableRow key={task.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={task.status === 'done'}
|
||||
onCheckedChange={() => toggleTaskComplete(task)}
|
||||
aria-label={`Mark "${task.title}" as ${task.status === 'done' ? 'incomplete' : 'complete'}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<button
|
||||
onClick={() => setSelectedTask(task)}
|
||||
className={`text-left font-medium hover:underline ${
|
||||
task.status === 'done'
|
||||
? 'line-through text-muted-foreground'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{task.title}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
task.priority === 'urgent'
|
||||
? 'destructive'
|
||||
: task.priority === 'high'
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{task.priority}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{task.domain}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{task.due_date && (
|
||||
<span className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{new Date(task.due_date).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-11 w-11"
|
||||
aria-label={`More options for ${task.title}`}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
|
||||
{selectedTask && (
|
||||
<TaskDetailPanel
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { Search, Bell, Plus, Menu } from 'lucide-react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useSidebarStore } from '@/lib/stores/use-sidebar-store';
|
||||
import { CommandPalette } from '@/components/command-palette';
|
||||
|
||||
export function TopBar() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { setMobileOpen } = useSidebarStore();
|
||||
const creation = pathname.startsWith('/projects')
|
||||
? { href: '/projects?new=true', label: 'New project' }
|
||||
: pathname.startsWith('/habits')
|
||||
? { href: '/habits?new=true', label: 'New habit' }
|
||||
: pathname.startsWith('/tasks')
|
||||
? { href: '/tasks?new=true', label: 'New task' }
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -46,14 +56,16 @@ export function TopBar() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'k', metaKey: true })
|
||||
);
|
||||
if (creation) {
|
||||
router.push(creation.href);
|
||||
return;
|
||||
}
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }));
|
||||
}}
|
||||
aria-label="Quick add"
|
||||
aria-label={creation ? `Create ${creation.label.toLowerCase()}` : 'Quick add'}
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" aria-hidden="true" />
|
||||
Quick add
|
||||
{creation?.label ?? 'Quick add'}
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="icon" aria-label="Notifications">
|
||||
|
||||
Reference in New Issue
Block a user