feat: update ProjectE application
This commit is contained in:
@@ -17,4 +17,4 @@ EXPOSE 8090
|
|||||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=10s \
|
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=10s \
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8090/api/health || exit 1
|
CMD wget --no-verbose --tries=1 --spider http://localhost:8090/api/health || exit 1
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/pocketbase", "serve", "--http=0.0.0.0:8090", "--dir=/pb_data", "--publicDir=/pb_public", "--migrationDir=/pb_migrations"]
|
ENTRYPOINT ["/usr/local/bin/pocketbase", "serve", "--http=0.0.0.0:8090", "--dir=/pb_data", "--publicDir=/pb_public", "--migrationsDir=/pb_migrations"]
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ export default function LoginPage() {
|
|||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
setErrorMessage('');
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -32,6 +34,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
router.push('/dashboard');
|
router.push('/dashboard');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
setErrorMessage(error instanceof Error ? error.message : 'Unable to sign in. Check your credentials and try again.');
|
||||||
handleApiError(error, 'Login failed');
|
handleApiError(error, 'Login failed');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -47,6 +50,11 @@ export default function LoginPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
{errorMessage && (
|
||||||
|
<div id="login-error" role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="email">Email</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -56,6 +64,7 @@ export default function LoginPage() {
|
|||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
|
aria-describedby={errorMessage ? 'login-error' : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -66,6 +75,7 @@ export default function LoginPage() {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
|
aria-describedby={errorMessage ? 'login-error' : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -43,7 +43,14 @@ export default function AgentsPage() {
|
|||||||
const [agents, setAgents] = useState<Agent[]>([]);
|
const [agents, setAgents] = useState<Agent[]>([]);
|
||||||
const [activity, setActivity] = useState<AgentActivity[]>([]);
|
const [activity, setActivity] = useState<AgentActivity[]>([]);
|
||||||
const [agentTasks, setAgentTasks] = useState<AgentTask[]>([]);
|
const [agentTasks, setAgentTasks] = useState<AgentTask[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [agentsLoading, setAgentsLoading] = useState(true);
|
||||||
|
const [activityLoading, setActivityLoading] = useState(true);
|
||||||
|
const [agentTasksLoading, setAgentTasksLoading] = useState(true);
|
||||||
|
const [agentsError, setAgentsError] = useState<string | null>(null);
|
||||||
|
const [activityError, setActivityError] = useState<string | null>(null);
|
||||||
|
const [agentTasksError, setAgentTasksError] = useState<string | null>(null);
|
||||||
|
const [undoingActivityId, setUndoingActivityId] = useState<string | null>(null);
|
||||||
|
const [feedback, setFeedback] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||||
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
|
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -53,51 +60,76 @@ export default function AgentsPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function fetchAgents() {
|
async function fetchAgents() {
|
||||||
|
setAgentsLoading(true);
|
||||||
|
setAgentsError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/agents');
|
const response = await fetch('/api/agents');
|
||||||
if (response.ok) {
|
if (!response.ok) {
|
||||||
const data = await response.json();
|
throw new Error('Unable to load agents.');
|
||||||
setAgents(data.items || []);
|
|
||||||
}
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
setAgents(data.items || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch agents:', error);
|
console.error('Failed to fetch agents:', error);
|
||||||
|
setAgentsError('Unable to load agents. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setAgentsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchActivity() {
|
async function fetchActivity() {
|
||||||
|
setActivityLoading(true);
|
||||||
|
setActivityError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/agent-activity?sort=-created&perPage=50');
|
const response = await fetch('/api/agent-activity?sort=-created&perPage=50');
|
||||||
if (response.ok) {
|
if (!response.ok) {
|
||||||
const data = await response.json();
|
throw new Error('Unable to load agent activity.');
|
||||||
setActivity(data.items || []);
|
|
||||||
}
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
setActivity(data.items || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch activity:', error);
|
console.error('Failed to fetch activity:', error);
|
||||||
|
setActivityError('Unable to load agent activity. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setActivityLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAgentTasks() {
|
async function fetchAgentTasks() {
|
||||||
|
setAgentTasksLoading(true);
|
||||||
|
setAgentTasksError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/agent-tasks?sort=-created&perPage=50');
|
const response = await fetch('/api/agent-tasks?sort=-created&perPage=50');
|
||||||
if (response.ok) {
|
if (!response.ok) {
|
||||||
const data = await response.json();
|
throw new Error('Unable to load agent tasks.');
|
||||||
setAgentTasks(data.items || []);
|
|
||||||
}
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
setAgentTasks(data.items || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch agent tasks:', error);
|
console.error('Failed to fetch agent tasks:', error);
|
||||||
|
setAgentTasksError('Unable to load agent tasks. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setAgentTasksLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function undoActivity(activityId: string) {
|
async function undoActivity(activityId: string) {
|
||||||
|
setUndoingActivityId(activityId);
|
||||||
|
setFeedback(null);
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/agent-activity/${activityId}/undo`, {
|
const response = await fetch(`/api/agent-activity/${activityId}/undo`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
fetchActivity();
|
if (!response.ok) {
|
||||||
|
throw new Error('Unable to undo this activity.');
|
||||||
|
}
|
||||||
|
setFeedback({ type: 'success', message: 'Activity undone successfully.' });
|
||||||
|
await fetchActivity();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to undo activity:', error);
|
console.error('Failed to undo activity:', error);
|
||||||
|
setFeedback({ type: 'error', message: 'Unable to undo this activity. Please try again.' });
|
||||||
|
} finally {
|
||||||
|
setUndoingActivityId(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,10 +162,6 @@ export default function AgentsPage() {
|
|||||||
return labels[action] || action;
|
return labels[action] || action;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <p className="text-muted-foreground">Loading agent activity...</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
@@ -141,6 +169,16 @@ export default function AgentsPage() {
|
|||||||
<p className="mt-1 text-muted-foreground">
|
<p className="mt-1 text-muted-foreground">
|
||||||
Every agent action, visible and reversible.
|
Every agent action, visible and reversible.
|
||||||
</p>
|
</p>
|
||||||
|
{feedback && (
|
||||||
|
<p
|
||||||
|
className={`mt-3 text-sm ${
|
||||||
|
feedback.type === 'success' ? 'text-green-600' : 'text-red-600'
|
||||||
|
}`}
|
||||||
|
role={feedback.type === 'error' ? 'alert' : 'status'}
|
||||||
|
>
|
||||||
|
{feedback.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[300px_1fr]">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[300px_1fr]">
|
||||||
@@ -150,10 +188,22 @@ export default function AgentsPage() {
|
|||||||
<CardTitle className="text-base">Agents</CardTitle>
|
<CardTitle className="text-base">Agents</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{agents.length === 0 ? (
|
{agentsLoading ? (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
<p className="py-8 text-center text-sm text-muted-foreground">Loading agents...</p>
|
||||||
No agents configured
|
) : agentsError ? (
|
||||||
</p>
|
<div className="py-8 text-center">
|
||||||
|
<p className="text-sm text-red-600" role="alert">{agentsError}</p>
|
||||||
|
<Button variant="outline" size="sm" className="mt-3" onClick={fetchAgents}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : agents.length === 0 ? (
|
||||||
|
<div className="py-8 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">No agents configured</p>
|
||||||
|
<Button variant="outline" size="sm" className="mt-3" onClick={fetchAgents}>
|
||||||
|
Refresh agents
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{agents.map((agent) => (
|
{agents.map((agent) => (
|
||||||
@@ -217,10 +267,22 @@ export default function AgentsPage() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="activity" className="mt-4">
|
<TabsContent value="activity" className="mt-4">
|
||||||
{activity.length === 0 ? (
|
{activityLoading ? (
|
||||||
<p className="py-8 text-center text-muted-foreground">
|
<p className="py-8 text-center text-muted-foreground">Loading agent activity...</p>
|
||||||
No agent activity yet
|
) : activityError ? (
|
||||||
</p>
|
<div className="py-8 text-center">
|
||||||
|
<p className="text-sm text-red-600" role="alert">{activityError}</p>
|
||||||
|
<Button variant="outline" size="sm" className="mt-3" onClick={fetchActivity}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : activity.length === 0 ? (
|
||||||
|
<div className="py-8 text-center">
|
||||||
|
<p className="text-muted-foreground">No agent activity yet</p>
|
||||||
|
<Button variant="outline" size="sm" className="mt-3" onClick={fetchActivity}>
|
||||||
|
Refresh activity
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{activity.map((item) => (
|
{activity.map((item) => (
|
||||||
@@ -277,11 +339,12 @@ export default function AgentsPage() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => undoActivity(item.id)}
|
onClick={() => undoActivity(item.id)}
|
||||||
|
disabled={undoingActivityId !== null}
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
aria-label={`Undo activity: ${getActionLabel(item.action)} ${item.entity_type}`}
|
aria-label={`Undo activity: ${getActionLabel(item.action)} ${item.entity_type}`}
|
||||||
>
|
>
|
||||||
<RotateCcw className="mr-1 h-3 w-3" aria-hidden="true" />
|
<RotateCcw className="mr-1 h-3 w-3" aria-hidden="true" />
|
||||||
Undo
|
{undoingActivityId === item.id ? 'Undoing...' : 'Undo'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -291,10 +354,22 @@ export default function AgentsPage() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="tasks" className="mt-4">
|
<TabsContent value="tasks" className="mt-4">
|
||||||
{agentTasks.length === 0 ? (
|
{agentTasksLoading ? (
|
||||||
<p className="py-8 text-center text-muted-foreground">
|
<p className="py-8 text-center text-muted-foreground">Loading agent tasks...</p>
|
||||||
No agent tasks yet
|
) : agentTasksError ? (
|
||||||
</p>
|
<div className="py-8 text-center">
|
||||||
|
<p className="text-sm text-red-600" role="alert">{agentTasksError}</p>
|
||||||
|
<Button variant="outline" size="sm" className="mt-3" onClick={fetchAgentTasks}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : agentTasks.length === 0 ? (
|
||||||
|
<div className="py-8 text-center">
|
||||||
|
<p className="text-muted-foreground">No agent tasks yet</p>
|
||||||
|
<Button variant="outline" size="sm" className="mt-3" onClick={fetchAgentTasks}>
|
||||||
|
Refresh tasks
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{agentTasks.map((task) => (
|
{agentTasks.map((task) => (
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Tabs,
|
Tabs,
|
||||||
TabsContent,
|
TabsContent,
|
||||||
@@ -65,88 +66,110 @@ interface HabitData {
|
|||||||
consistency: number;
|
consistency: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function countByDate(records: Array<Record<string, unknown>>, field: string) {
|
||||||
|
return records.reduce<Record<string, number>>((counts, record) => {
|
||||||
|
const value = record[field];
|
||||||
|
if (typeof value === 'string' && !Number.isNaN(new Date(value).getTime())) {
|
||||||
|
const date = new Date(value).toISOString().slice(0, 10);
|
||||||
|
counts[date] = (counts[date] || 0) + 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
export default function AnalyticsPage() {
|
export default function AnalyticsPage() {
|
||||||
const [analytics, setAnalytics] = useState<AnalyticsData | null>(null);
|
const [analytics, setAnalytics] = useState<AnalyticsData | null>(null);
|
||||||
const [timeData, setTimeData] = useState<TimeData[]>([]);
|
const [timeData, setTimeData] = useState<TimeData[]>([]);
|
||||||
const [domainData, setDomainData] = useState<DomainData[]>([]);
|
const [domainData, setDomainData] = useState<DomainData[]>([]);
|
||||||
const [habitData, setHabitData] = useState<HabitData[]>([]);
|
const [habitData, setHabitData] = useState<HabitData[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAnalytics();
|
fetchAnalytics();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function fetchAnalytics() {
|
async function fetchAnalytics() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
// Fetch overall analytics
|
const startDate = new Date();
|
||||||
const analyticsResponse = await fetch('/api/analytics?period=30');
|
startDate.setDate(startDate.getDate() - 29);
|
||||||
if (analyticsResponse.ok) {
|
const start = startDate.toISOString();
|
||||||
const analyticsData = await analyticsResponse.json();
|
const [analyticsResponse, timeResponse, habitsResponse, tasksResponse, habitLogsResponse] = await Promise.all([
|
||||||
setAnalytics(analyticsData);
|
fetch('/api/analytics?period=30'),
|
||||||
|
fetch(`/api/time-summary?start=${encodeURIComponent(start)}`),
|
||||||
|
fetch('/api/habits/streaks'),
|
||||||
|
fetch('/api/tasks?perPage=500'),
|
||||||
|
fetch(`/api/habit-logs?start=${encodeURIComponent(start)}`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (![analyticsResponse, timeResponse, habitsResponse, tasksResponse, habitLogsResponse].every((response) => response.ok)) {
|
||||||
|
throw new Error('One or more analytics sources could not be loaded.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch time summary
|
const [analyticsData, timeSummary, habitsData, tasksData, habitLogsData] = await Promise.all([
|
||||||
const timeResponse = await fetch('/api/time-summary?period=30');
|
analyticsResponse.json(),
|
||||||
if (timeResponse.ok) {
|
timeResponse.json(),
|
||||||
const timeSummary = await timeResponse.json();
|
habitsResponse.json(),
|
||||||
|
tasksResponse.json(),
|
||||||
|
habitLogsResponse.json(),
|
||||||
|
]);
|
||||||
|
|
||||||
// Transform to domain data for pie chart
|
const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#06b6d4'];
|
||||||
const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#06b6d4'];
|
const domains: DomainData[] = Object.entries(timeSummary.byDomain || {}).map(([name, value], index) => ({
|
||||||
const domains: DomainData[] = Object.entries(
|
name,
|
||||||
timeSummary.byDomain || {}
|
value: value as number,
|
||||||
).map(([name, value], index) => ({
|
color: COLORS[index % COLORS.length],
|
||||||
name,
|
}));
|
||||||
value: value as number,
|
const habits: HabitData[] = (habitsData.streaks || []).map(
|
||||||
color: COLORS[index % COLORS.length],
|
(s: { habit: { name: string; score?: number }; current_streak: number }) => ({
|
||||||
}));
|
name: s.habit.name,
|
||||||
setDomainData(domains);
|
streak: s.current_streak,
|
||||||
}
|
score: s.habit.score || 0,
|
||||||
|
consistency: 0,
|
||||||
// Fetch habit streaks
|
})
|
||||||
const habitsResponse = await fetch('/api/habits/streaks');
|
);
|
||||||
if (habitsResponse.ok) {
|
const completedByDate = countByDate(tasksData.items || [], 'completed_at');
|
||||||
const habitsData = await habitsResponse.json();
|
const habitsByDate = countByDate(habitLogsData.items || [], 'logged_at');
|
||||||
const habits: HabitData[] = (habitsData.streaks || []).map(
|
const minutesByDate = timeSummary.byDate || {};
|
||||||
(s: {
|
const dailyData = Array.from({ length: 30 }, (_, index) => {
|
||||||
habit: { name: string; score?: number };
|
const date = new Date(startDate);
|
||||||
current_streak: number;
|
date.setDate(startDate.getDate() + index);
|
||||||
best_streak: number;
|
const key = date.toISOString().slice(0, 10);
|
||||||
}) => ({
|
|
||||||
name: s.habit.name,
|
|
||||||
streak: s.current_streak,
|
|
||||||
score: s.habit.score || 0,
|
|
||||||
consistency: 0, // Would need to calculate from logs
|
|
||||||
})
|
|
||||||
);
|
|
||||||
setHabitData(habits);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate sample time data (would come from API in production)
|
|
||||||
const sampleTimeData: TimeData[] = Array.from({ length: 30 }, (_, i) => {
|
|
||||||
const date = new Date();
|
|
||||||
date.setDate(date.getDate() - (29 - i));
|
|
||||||
return {
|
return {
|
||||||
date: date.toLocaleDateString('en-US', {
|
date: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
||||||
month: 'short',
|
tasks: completedByDate[key] || 0,
|
||||||
day: 'numeric',
|
habits: habitsByDate[key] || 0,
|
||||||
}),
|
time: minutesByDate[key] || 0,
|
||||||
tasks: Math.floor(Math.random() * 10) + 2,
|
|
||||||
habits: Math.floor(Math.random() * 5) + 1,
|
|
||||||
time: Math.floor(Math.random() * 180) + 30,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
setTimeData(sampleTimeData);
|
|
||||||
|
setAnalytics(analyticsData);
|
||||||
|
setDomainData(domains);
|
||||||
|
setHabitData(habits);
|
||||||
|
setTimeData(dailyData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch analytics:', error);
|
console.error('Failed to fetch analytics:', error);
|
||||||
|
setError('Analytics could not be loaded. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading || !analytics) {
|
if (loading) {
|
||||||
return <p className="text-muted-foreground">Loading analytics...</p>;
|
return <p className="text-muted-foreground">Loading analytics...</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error || !analytics) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 py-20 text-center">
|
||||||
|
<p className="text-muted-foreground" role="alert">{error || 'Analytics are unavailable.'}</p>
|
||||||
|
<Button onClick={fetchAnalytics}>Retry</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
|
|||||||
@@ -27,16 +27,17 @@ interface CalendarEvent {
|
|||||||
title: string;
|
title: string;
|
||||||
start: Date;
|
start: Date;
|
||||||
end: Date;
|
end: Date;
|
||||||
type: 'task' | 'habit' | 'project' | 'milestone';
|
type: 'task' | 'project' | 'milestone';
|
||||||
domain: string;
|
domain: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
href: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CalendarPage() {
|
export default function CalendarPage() {
|
||||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showTasks, setShowTasks] = useState(true);
|
const [showTasks, setShowTasks] = useState(true);
|
||||||
const [showHabits, setShowHabits] = useState(true);
|
|
||||||
const [showProjects, setShowProjects] = useState(true);
|
const [showProjects, setShowProjects] = useState(true);
|
||||||
const [showMilestones, setShowMilestones] = useState(true);
|
const [showMilestones, setShowMilestones] = useState(true);
|
||||||
const [selectedDomains, setSelectedDomains] = useState<string[]>([]);
|
const [selectedDomains, setSelectedDomains] = useState<string[]>([]);
|
||||||
@@ -46,18 +47,24 @@ export default function CalendarPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function fetchEvents() {
|
async function fetchEvents() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
// Fetch tasks with due dates
|
const [tasksResponse, projectsResponse, milestonesResponse] = await Promise.all([
|
||||||
const tasksResponse = await fetch('/api/tasks?filter=due_date!%3D%22%22&perPage=500');
|
fetch('/api/tasks?filter=due_date!%3D%22%22&perPage=500'),
|
||||||
const tasksData = tasksResponse.ok ? await tasksResponse.json() : { items: [] };
|
fetch('/api/projects?filter=due_date!%3D%22%22&perPage=500'),
|
||||||
|
fetch('/api/milestones?filter=due_date!%3D%22%22&perPage=500'),
|
||||||
|
]);
|
||||||
|
|
||||||
// Fetch projects with target dates
|
if (!tasksResponse.ok || !projectsResponse.ok || !milestonesResponse.ok) {
|
||||||
const projectsResponse = await fetch('/api/projects?filter=target_date!%3D%22%22&perPage=500');
|
throw new Error('One or more calendar sources could not be loaded.');
|
||||||
const projectsData = projectsResponse.ok ? await projectsResponse.json() : { items: [] };
|
}
|
||||||
|
|
||||||
// Fetch milestones with target dates
|
const [tasksData, projectsData, milestonesData] = await Promise.all([
|
||||||
const milestonesResponse = await fetch('/api/milestones?filter=target_date!%3D%22%22&perPage=500');
|
tasksResponse.json(),
|
||||||
const milestonesData = milestonesResponse.ok ? await milestonesResponse.json() : { items: [] };
|
projectsResponse.json(),
|
||||||
|
milestonesResponse.json(),
|
||||||
|
]);
|
||||||
|
|
||||||
const calendarEvents: CalendarEvent[] = [];
|
const calendarEvents: CalendarEvent[] = [];
|
||||||
|
|
||||||
@@ -73,7 +80,8 @@ export default function CalendarPage() {
|
|||||||
end: date,
|
end: date,
|
||||||
type: 'task',
|
type: 'task',
|
||||||
domain: task.domain ?? 'personal',
|
domain: task.domain ?? 'personal',
|
||||||
color: '#3b82f6', // blue
|
color: '#3b82f6',
|
||||||
|
href: '/tasks',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,16 +90,17 @@ export default function CalendarPage() {
|
|||||||
// Add projects
|
// Add projects
|
||||||
if (projectsData.items) {
|
if (projectsData.items) {
|
||||||
for (const project of projectsData.items) {
|
for (const project of projectsData.items) {
|
||||||
if (project.target_date) {
|
if (project.due_date) {
|
||||||
const date = new Date(project.target_date);
|
const date = new Date(project.due_date);
|
||||||
calendarEvents.push({
|
calendarEvents.push({
|
||||||
id: `project-${project.id}`,
|
id: `project-${project.id}`,
|
||||||
title: `📁 ${project.name}`,
|
title: project.name,
|
||||||
start: date,
|
start: date,
|
||||||
end: date,
|
end: date,
|
||||||
type: 'project',
|
type: 'project',
|
||||||
domain: project.domain ?? 'personal',
|
domain: project.domain ?? 'personal',
|
||||||
color: '#8b5cf6', // purple
|
color: '#8b5cf6',
|
||||||
|
href: `/projects/${project.id}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,16 +109,17 @@ export default function CalendarPage() {
|
|||||||
// Add milestones
|
// Add milestones
|
||||||
if (milestonesData.items) {
|
if (milestonesData.items) {
|
||||||
for (const milestone of milestonesData.items) {
|
for (const milestone of milestonesData.items) {
|
||||||
if (milestone.target_date) {
|
if (milestone.due_date) {
|
||||||
const date = new Date(milestone.target_date);
|
const date = new Date(milestone.due_date);
|
||||||
calendarEvents.push({
|
calendarEvents.push({
|
||||||
id: `milestone-${milestone.id}`,
|
id: `milestone-${milestone.id}`,
|
||||||
title: `🎯 ${milestone.name}`,
|
title: milestone.name || milestone.title,
|
||||||
start: date,
|
start: date,
|
||||||
end: date,
|
end: date,
|
||||||
type: 'milestone',
|
type: 'milestone',
|
||||||
domain: milestone.domain ?? 'work',
|
domain: milestone.domain ?? 'work',
|
||||||
color: '#f59e0b', // amber
|
color: '#f59e0b',
|
||||||
|
href: milestone.project_id ? `/projects/${milestone.project_id}` : '/projects',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,6 +128,7 @@ export default function CalendarPage() {
|
|||||||
setEvents(calendarEvents);
|
setEvents(calendarEvents);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch calendar events:', error);
|
console.error('Failed to fetch calendar events:', error);
|
||||||
|
setError('Calendar events could not be loaded. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -127,7 +138,6 @@ export default function CalendarPage() {
|
|||||||
return events.filter((event) => {
|
return events.filter((event) => {
|
||||||
// Filter by type
|
// Filter by type
|
||||||
if (event.type === 'task' && !showTasks) return false;
|
if (event.type === 'task' && !showTasks) return false;
|
||||||
if (event.type === 'habit' && !showHabits) return false;
|
|
||||||
if (event.type === 'project' && !showProjects) return false;
|
if (event.type === 'project' && !showProjects) return false;
|
||||||
if (event.type === 'milestone' && !showMilestones) return false;
|
if (event.type === 'milestone' && !showMilestones) return false;
|
||||||
|
|
||||||
@@ -138,7 +148,7 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [events, showTasks, showHabits, showProjects, showMilestones, selectedDomains]);
|
}, [events, showTasks, showProjects, showMilestones, selectedDomains]);
|
||||||
|
|
||||||
function toggleDomain(domain: string) {
|
function toggleDomain(domain: string) {
|
||||||
setSelectedDomains((prev) =>
|
setSelectedDomains((prev) =>
|
||||||
@@ -154,6 +164,15 @@ export default function CalendarPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 py-20 text-center">
|
||||||
|
<p className="text-muted-foreground" role="alert">{error}</p>
|
||||||
|
<Button onClick={fetchEvents}>Retry</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
@@ -188,17 +207,6 @@ export default function CalendarPage() {
|
|||||||
Tasks
|
Tasks
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
|
||||||
id="habits"
|
|
||||||
checked={showHabits}
|
|
||||||
onCheckedChange={(checked) => setShowHabits(checked === true)}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="habits" className="flex items-center gap-2">
|
|
||||||
<div className="h-3 w-3 rounded" style={{ backgroundColor: '#10b981' }} />
|
|
||||||
Habits
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="projects"
|
id="projects"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React, { Suspense } from 'react';
|
|||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { useDashboardStore } from '@/lib/stores/use-dashboard-store';
|
import { useDashboardStore } from '@/lib/stores/use-dashboard-store';
|
||||||
import { WidgetErrorBoundary } from '@/components/widget-error-boundary';
|
import { WidgetErrorBoundary } from '@/components/widget-error-boundary';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
// Lazy load react-grid-layout (client-only, ~45KB)
|
// Lazy load react-grid-layout (client-only, ~45KB)
|
||||||
const ResponsiveGridLayout = dynamic(
|
const ResponsiveGridLayout = dynamic(
|
||||||
@@ -125,6 +126,7 @@ const widgetComponents: Record<string, React.ComponentType> = {
|
|||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { widgets, setWidgets } = useDashboardStore();
|
const { widgets, setWidgets } = useDashboardStore();
|
||||||
|
const [layoutAnnouncement, setLayoutAnnouncement] = React.useState('');
|
||||||
|
|
||||||
const layout = widgets.map((w) => ({
|
const layout = widgets.map((w) => ({
|
||||||
i: w.id,
|
i: w.id,
|
||||||
@@ -134,7 +136,7 @@ export default function DashboardPage() {
|
|||||||
h: w.h,
|
h: w.h,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function handleLayoutChange(newLayout: { i: string; x: number; y: number; w: number; h: number }[]) {
|
function handleLayoutChange(newLayout: ReadonlyArray<{ i: string; x: number; y: number; w: number; h: number }>) {
|
||||||
const updated = widgets.map((w) => {
|
const updated = widgets.map((w) => {
|
||||||
const layoutItem = newLayout.find((l) => l.i === w.id);
|
const layoutItem = newLayout.find((l) => l.i === w.id);
|
||||||
if (layoutItem) {
|
if (layoutItem) {
|
||||||
@@ -151,6 +153,31 @@ export default function DashboardPage() {
|
|||||||
setWidgets(updated);
|
setWidgets(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function moveWidget(id: string, direction: -1 | 1) {
|
||||||
|
const ordered = [...widgets].sort((a, b) => a.y - b.y || a.x - b.x);
|
||||||
|
const index = ordered.findIndex((widget) => widget.id === id);
|
||||||
|
const targetIndex = index + direction;
|
||||||
|
if (index < 0 || targetIndex < 0 || targetIndex >= ordered.length) return;
|
||||||
|
|
||||||
|
const current = ordered[index];
|
||||||
|
const target = ordered[targetIndex];
|
||||||
|
setWidgets(widgets.map((widget) => {
|
||||||
|
if (widget.id === current.id) return { ...widget, x: target.x, y: target.y };
|
||||||
|
if (widget.id === target.id) return { ...widget, x: current.x, y: current.y };
|
||||||
|
return widget;
|
||||||
|
}));
|
||||||
|
setLayoutAnnouncement(`${current.type} moved ${direction < 0 ? 'earlier' : 'later'} on the dashboard.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeWidget(id: string, direction: -1 | 1) {
|
||||||
|
const widget = widgets.find((item) => item.id === id);
|
||||||
|
if (!widget) return;
|
||||||
|
const width = Math.max(2, Math.min(12, widget.w + direction));
|
||||||
|
if (width === widget.w) return;
|
||||||
|
setWidgets(widgets.map((item) => item.id === id ? { ...item, w: width } : item));
|
||||||
|
setLayoutAnnouncement(`${widget.type} is now ${width} columns wide.`);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
@@ -158,6 +185,35 @@ export default function DashboardPage() {
|
|||||||
<p className="mt-1 text-muted-foreground">Your day, at a glance.</p>
|
<p className="mt-1 text-muted-foreground">Your day, at a glance.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<details className="mb-4 rounded-lg border bg-card p-3">
|
||||||
|
<summary className="cursor-pointer text-sm font-medium">Customize dashboard layout</summary>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Use these controls to reorder or resize widgets without dragging.
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{[...widgets].sort((a, b) => a.y - b.y || a.x - b.x).map((widget, index, ordered) => (
|
||||||
|
<div key={widget.id} className="flex items-center justify-between gap-3 rounded-md bg-muted/50 px-3 py-2">
|
||||||
|
<span className="text-sm">{widget.type}</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => moveWidget(widget.id, -1)} disabled={index === 0}>
|
||||||
|
Move earlier
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => moveWidget(widget.id, 1)} disabled={index === ordered.length - 1}>
|
||||||
|
Move later
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => resizeWidget(widget.id, -1)} disabled={widget.w <= 2}>
|
||||||
|
Narrower
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => resizeWidget(widget.id, 1)} disabled={widget.w >= 12}>
|
||||||
|
Wider
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<p className="sr-only" aria-live="polite">{layoutAnnouncement}</p>
|
||||||
|
|
||||||
<ResponsiveGridLayout layout={layout} onLayoutChange={handleLayoutChange}>
|
<ResponsiveGridLayout layout={layout} onLayoutChange={handleLayoutChange}>
|
||||||
{widgets.map((widget) => {
|
{widgets.map((widget) => {
|
||||||
const WidgetComponent = widgetComponents[widget.id];
|
const WidgetComponent = widgetComponents[widget.id];
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|||||||
import { HabitCard } from '@/components/habits/habit-card';
|
import { HabitCard } from '@/components/habits/habit-card';
|
||||||
import { HabitCompletionDialog } from '@/components/habits/habit-completion-dialog';
|
import { HabitCompletionDialog } from '@/components/habits/habit-completion-dialog';
|
||||||
import type { Habit } from '@project-e/shared';
|
import type { Habit } from '@project-e/shared';
|
||||||
|
import { CreateItemDialog } from '@/components/create-item-dialog';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
// Lazy load react-calendar-heatmap (~15KB)
|
// Lazy load react-calendar-heatmap (~15KB)
|
||||||
const HabitHeatmap = dynamic(
|
const HabitHeatmap = dynamic(
|
||||||
@@ -28,22 +30,36 @@ interface HabitWithMeta extends Habit {
|
|||||||
export default function HabitsPage() {
|
export default function HabitsPage() {
|
||||||
const [habits, setHabits] = useState<HabitWithMeta[]>([]);
|
const [habits, setHabits] = useState<HabitWithMeta[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedHabit, setSelectedHabit] = useState<HabitWithMeta | null>(null);
|
const [selectedHabit, setSelectedHabit] = useState<HabitWithMeta | null>(null);
|
||||||
const [completionDialogOpen, setCompletionDialogOpen] = useState(false);
|
const [completionDialogOpen, setCompletionDialogOpen] = useState(false);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchHabits();
|
fetchHabits();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (new URLSearchParams(window.location.search).get('new') === 'true') setCreateOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function handleCreateOpenChange(open: boolean) {
|
||||||
|
setCreateOpen(open);
|
||||||
|
if (!open && new URLSearchParams(window.location.search).get('new') === 'true') {
|
||||||
|
window.history.replaceState(null, '', '/habits');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchHabits() {
|
async function fetchHabits() {
|
||||||
try {
|
try {
|
||||||
|
setError(null);
|
||||||
const response = await fetch('/api/habits');
|
const response = await fetch('/api/habits');
|
||||||
if (response.ok) {
|
if (!response.ok) throw new Error('Unable to load habits.');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setHabits(data.items || []);
|
setHabits(data.items || []);
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch habits:', error);
|
console.error('Failed to fetch habits:', error);
|
||||||
|
setError('Unable to load habits. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -63,15 +79,18 @@ export default function HabitsPage() {
|
|||||||
data: { mood?: number; value?: number; notes?: string }
|
data: { mood?: number; value?: number; notes?: string }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/habits/${habitId}/logs`, {
|
const response = await fetch(`/api/habits/${habitId}/logs`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
|
if (!response.ok) throw new Error('Unable to save habit completion.');
|
||||||
fetchHabits();
|
fetchHabits();
|
||||||
setCompletionDialogOpen(false);
|
setCompletionDialogOpen(false);
|
||||||
|
toast.success('Habit completed.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to log habit:', error);
|
console.error('Failed to log habit:', error);
|
||||||
|
toast.error('Unable to save habit completion. Please try again.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +99,7 @@ export default function HabitsPage() {
|
|||||||
habits.length > 0 ? Math.round((completedCount / habits.length) * 100) : 0;
|
habits.length > 0 ? Math.round((completedCount / habits.length) * 100) : 0;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <p className="text-muted-foreground">Loading habits...</p>;
|
return <p role="status" className="text-muted-foreground">Loading habits...</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -92,12 +111,19 @@ export default function HabitsPage() {
|
|||||||
Small actions, visible momentum.
|
Small actions, visible momentum.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button>
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
New habit
|
New habit
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div role="alert" className="mb-6 flex items-center justify-between gap-4 rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||||
|
<span>{error}</span>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchHabits}>Retry</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Summary banner */}
|
{/* Summary banner */}
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<CardContent className="flex items-center justify-between p-6">
|
<CardContent className="flex items-center justify-between p-6">
|
||||||
@@ -116,7 +142,14 @@ export default function HabitsPage() {
|
|||||||
|
|
||||||
{/* Habit cards grid */}
|
{/* Habit cards grid */}
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{habits.map((habit) => (
|
{habits.length === 0 && !error ? (
|
||||||
|
<Card className="md:col-span-2 lg:col-span-3">
|
||||||
|
<CardContent className="py-10 text-center">
|
||||||
|
<p className="text-muted-foreground">No habits yet. Start with one small action.</p>
|
||||||
|
<Button className="mt-4" onClick={() => setCreateOpen(true)}>Create a habit</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : habits.map((habit) => (
|
||||||
<HabitCard
|
<HabitCard
|
||||||
key={habit.id}
|
key={habit.id}
|
||||||
habit={habit}
|
habit={habit}
|
||||||
@@ -153,6 +186,12 @@ export default function HabitsPage() {
|
|||||||
onSubmit={(data) => logHabitCompletion(selectedHabit.id, data)}
|
onSubmit={(data) => logHabitCompletion(selectedHabit.id, data)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<CreateItemDialog
|
||||||
|
type="habit"
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={handleCreateOpenChange}
|
||||||
|
onCreated={fetchHabits}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, Suspense } from 'react';
|
import { useEffect, useRef, useState, Suspense } from 'react';
|
||||||
import { Plus, FileText, Link2, GitBranch } from 'lucide-react';
|
import { Plus, FileText, Link2, GitBranch, Trash2 } from 'lucide-react';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { DailyNoteButton } from '@/components/notes/daily-note-button';
|
import { DailyNoteButton } from '@/components/notes/daily-note-button';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
|
||||||
// Lazy load TipTap editor (~80KB TipTap + extensions)
|
// Lazy load TipTap editor (~80KB TipTap + extensions)
|
||||||
const NoteEditor = dynamic(
|
const NoteEditor = dynamic(
|
||||||
@@ -55,9 +67,30 @@ export default function NotesPage() {
|
|||||||
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
|
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
|
||||||
const [backlinks, setBacklinks] = useState<Backlink[]>([]);
|
const [backlinks, setBacklinks] = useState<Backlink[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [backlinksError, setBacklinksError] = useState<string | null>(null);
|
||||||
|
const [backlinksLoading, setBacklinksLoading] = useState(false);
|
||||||
|
const [saveStatus, setSaveStatus] = useState<'Saved' | 'Saving' | 'Failed'>('Saved');
|
||||||
|
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const createdFromQuery = useRef(false);
|
||||||
|
const pendingSave = useRef<{ id: string; updates: Partial<Note> } | null>(null);
|
||||||
|
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const saveVersion = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchNotes();
|
fetchNotes();
|
||||||
|
return () => {
|
||||||
|
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (new URLSearchParams(window.location.search).get('new') !== 'true' || createdFromQuery.current) return;
|
||||||
|
createdFromQuery.current = true;
|
||||||
|
createNote().finally(() => window.history.replaceState(null, '', '/notes'));
|
||||||
|
// Route-triggered creation should only run once per page visit.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -67,32 +100,36 @@ export default function NotesPage() {
|
|||||||
}, [selectedNote]);
|
}, [selectedNote]);
|
||||||
|
|
||||||
async function fetchNotes() {
|
async function fetchNotes() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/notes?sort=-updated');
|
const response = await fetch('/api/notes?sort=-updated');
|
||||||
if (response.ok) {
|
if (!response.ok) throw new Error('Unable to load notes.');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const notesList = data.items || [];
|
const notesList = data.items || [];
|
||||||
setNotes(notesList);
|
setNotes(notesList);
|
||||||
if (notesList.length > 0 && !selectedNote) {
|
setSelectedNote((current) => current || notesList[0] || null);
|
||||||
setSelectedNote(notesList[0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch notes:', error);
|
console.error('Failed to fetch notes:', error);
|
||||||
|
setError('Unable to load notes. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchBacklinks(noteId: string) {
|
async function fetchBacklinks(noteId: string) {
|
||||||
|
setBacklinksLoading(true);
|
||||||
|
setBacklinksError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/notes/${noteId}/backlinks`);
|
const response = await fetch(`/api/notes/${noteId}/backlinks`);
|
||||||
if (response.ok) {
|
if (!response.ok) throw new Error('Unable to load backlinks.');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setBacklinks(data.items || []);
|
setBacklinks(data.items || []);
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch backlinks:', error);
|
console.error('Failed to fetch backlinks:', error);
|
||||||
|
setBacklinksError('Unable to load backlinks.');
|
||||||
|
} finally {
|
||||||
|
setBacklinksLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,13 +144,14 @@ export default function NotesPage() {
|
|||||||
domain: 'personal',
|
domain: 'personal',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (!response.ok) throw new Error('Unable to create note.');
|
||||||
const newNote = await response.json();
|
const newNote = await response.json();
|
||||||
setNotes([newNote, ...notes]);
|
setNotes((current) => [newNote, ...current]);
|
||||||
setSelectedNote(newNote);
|
setSelectedNote(newNote);
|
||||||
}
|
toast.success('Note created');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create note:', error);
|
console.error('Failed to create note:', error);
|
||||||
|
toast.error('Unable to create note');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,45 +164,94 @@ export default function NotesPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Otherwise prepend it and select
|
// Otherwise prepend it and select
|
||||||
setNotes([note, ...notes]);
|
setNotes((current) => [note, ...current]);
|
||||||
setSelectedNote(note);
|
setSelectedNote(note);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateNote(noteId: string, updates: Partial<Note>) {
|
function scheduleSave(noteId: string, updates: Partial<Note>) {
|
||||||
|
const version = ++saveVersion.current;
|
||||||
|
pendingSave.current = {
|
||||||
|
id: noteId,
|
||||||
|
updates: { ...(pendingSave.current?.id === noteId ? pendingSave.current.updates : {}), ...updates },
|
||||||
|
};
|
||||||
|
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||||
|
setSaveStatus('Saving');
|
||||||
|
saveTimer.current = setTimeout(() => {
|
||||||
|
const save = pendingSave.current;
|
||||||
|
pendingSave.current = null;
|
||||||
|
if (save) updateNote(save.id, save.updates, version);
|
||||||
|
}, 700);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateNote(noteId: string, updates: Partial<Note>, version: number) {
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/notes/${noteId}`, {
|
const response = await fetch(`/api/notes/${noteId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(updates),
|
body: JSON.stringify(updates),
|
||||||
});
|
});
|
||||||
fetchNotes();
|
if (!response.ok) throw new Error('Unable to save note.');
|
||||||
|
const updated = await response.json();
|
||||||
|
setNotes((current) => current.map((note) => (note.id === noteId ? updated : note)));
|
||||||
|
if (saveVersion.current === version) setSaveStatus('Saved');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update note:', error);
|
console.error('Failed to update note:', error);
|
||||||
|
if (saveVersion.current === version) setSaveStatus('Failed');
|
||||||
|
toast.error('Unable to save note');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteNote(noteId: string) {
|
async function deleteNote() {
|
||||||
if (!confirm('Are you sure you want to delete this note?')) return;
|
if (!noteToDelete) return;
|
||||||
|
if (pendingSave.current?.id === noteToDelete.id && saveTimer.current) {
|
||||||
|
clearTimeout(saveTimer.current);
|
||||||
|
pendingSave.current = null;
|
||||||
|
}
|
||||||
|
++saveVersion.current;
|
||||||
|
setDeleting(true);
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/notes/${noteId}`, { method: 'DELETE' });
|
const response = await fetch(`/api/notes/${noteToDelete.id}`, { method: 'DELETE' });
|
||||||
const updatedNotes = notes.filter((n) => n.id !== noteId);
|
if (!response.ok) throw new Error('Unable to delete note.');
|
||||||
setNotes(updatedNotes);
|
setNotes((current) => current.filter((note) => note.id !== noteToDelete.id));
|
||||||
if (selectedNote?.id === noteId) {
|
setSelectedNote((selected) =>
|
||||||
setSelectedNote(updatedNotes[0] || null);
|
selected?.id === noteToDelete.id ? notes.find((note) => note.id !== noteToDelete.id) || null : selected
|
||||||
}
|
);
|
||||||
|
setNoteToDelete(null);
|
||||||
|
toast.success('Note deleted');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete note:', error);
|
console.error('Failed to delete note:', error);
|
||||||
|
toast.error('Unable to delete note');
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openBacklink(link: Backlink) {
|
||||||
|
const existing = notes.find((note) => note.id === link.id);
|
||||||
|
if (existing) return setSelectedNote(existing);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/notes/${link.id}`);
|
||||||
|
if (!response.ok) throw new Error('Unable to load linked note.');
|
||||||
|
const note = await response.json() as Note;
|
||||||
|
setNotes((current) => [note, ...current]);
|
||||||
|
setSelectedNote(note);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch linked note:', error);
|
||||||
|
toast.error('Unable to open linked note');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <p className="text-muted-foreground">Loading notes...</p>;
|
return <p className="text-muted-foreground" role="status">Loading notes...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div className="space-y-3"><p className="text-muted-foreground" role="alert">{error}</p><Button onClick={fetchNotes}>Retry</Button></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Notes</h1>
|
<h1 className="text-2xl font-bold">Notes</h1>
|
||||||
<p className="mt-1 text-muted-foreground">
|
<p className="mt-1 text-muted-foreground">
|
||||||
@@ -182,13 +269,17 @@ export default function NotesPage() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[250px_1fr_300px]">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[250px_1fr_300px]">
|
||||||
{/* Notes list */}
|
{/* Notes list */}
|
||||||
<Card className="h-[calc(100vh-200px)]">
|
<Card className="max-h-80 lg:h-[calc(100vh-200px)] lg:max-h-none">
|
||||||
<ScrollArea className="h-full">
|
<ScrollArea className="max-h-80 lg:h-full lg:max-h-none">
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
{notes.length === 0 ? (
|
{notes.length === 0 ? (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
<div className="py-8 text-center">
|
||||||
No notes yet
|
<p className="text-sm text-muted-foreground">No notes yet</p>
|
||||||
</p>
|
<Button className="mt-3" size="sm" onClick={createNote}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create your first note
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{notes.map((note) => (
|
{notes.map((note) => (
|
||||||
@@ -226,31 +317,42 @@ export default function NotesPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Note editor */}
|
{/* Note editor */}
|
||||||
<Card className="h-[calc(100vh-200px)]">
|
<Card className="min-h-[420px] lg:h-[calc(100vh-200px)]">
|
||||||
{selectedNote ? (
|
{selectedNote ? (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="border-b p-4">
|
<div className="border-b p-4">
|
||||||
<label htmlFor="note-title" className="sr-only">
|
<label htmlFor="note-title" className="sr-only">
|
||||||
Note title
|
Note title
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
id="note-title"
|
id="note-title"
|
||||||
type="text"
|
type="text"
|
||||||
value={selectedNote.title}
|
value={selectedNote.title}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
|
const title = e.target.value;
|
||||||
setSelectedNote({
|
setSelectedNote({
|
||||||
...selectedNote,
|
...selectedNote,
|
||||||
title: e.target.value,
|
title,
|
||||||
})
|
});
|
||||||
}
|
scheduleSave(selectedNote.id, { title });
|
||||||
onBlur={() =>
|
}}
|
||||||
updateNote(selectedNote.id, {
|
className="min-w-0 flex-1 text-xl font-semibold outline-none"
|
||||||
title: selectedNote.title,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="w-full text-xl font-semibold outline-none"
|
|
||||||
placeholder="Note title"
|
placeholder="Note title"
|
||||||
/>
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground" role="status">{saveStatus}</span>
|
||||||
|
<AlertDialog open={noteToDelete?.id === selectedNote.id} onOpenChange={(open) => !open && setNoteToDelete(null)}>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" aria-label={`Delete ${selectedNote.title}`} onClick={() => setNoteToDelete(selectedNote)}>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader><AlertDialogTitle>Delete {noteToDelete?.title}?</AlertDialogTitle><AlertDialogDescription>This permanently deletes this note.</AlertDialogDescription></AlertDialogHeader>
|
||||||
|
<AlertDialogFooter><AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel><AlertDialogAction onClick={deleteNote} disabled={deleting}>{deleting ? 'Deleting...' : 'Delete note'}</AlertDialogAction></AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto p-4">
|
<div className="flex-1 overflow-auto p-4">
|
||||||
<Suspense
|
<Suspense
|
||||||
@@ -264,14 +366,7 @@ export default function NotesPage() {
|
|||||||
>
|
>
|
||||||
<NoteEditor
|
<NoteEditor
|
||||||
content={selectedNote.content}
|
content={selectedNote.content}
|
||||||
onChange={(content) =>
|
onChange={(content) => { setSelectedNote({ ...selectedNote, content }); scheduleSave(selectedNote.id, { content }); }}
|
||||||
setSelectedNote({ ...selectedNote, content })
|
|
||||||
}
|
|
||||||
onBlur={() =>
|
|
||||||
updateNote(selectedNote.id, {
|
|
||||||
content: selectedNote.content,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
@@ -286,7 +381,7 @@ export default function NotesPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Backlinks and graph */}
|
{/* Backlinks and graph */}
|
||||||
<Card className="h-[calc(100vh-200px)]">
|
<Card className="min-h-[360px] lg:h-[calc(100vh-200px)]">
|
||||||
<Tabs defaultValue="backlinks" className="h-full">
|
<Tabs defaultValue="backlinks" className="h-full">
|
||||||
<div className="border-b p-2">
|
<div className="border-b p-2">
|
||||||
<TabsList className="w-full">
|
<TabsList className="w-full">
|
||||||
@@ -302,7 +397,7 @@ export default function NotesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TabsContent value="backlinks" className="h-full overflow-auto p-4">
|
<TabsContent value="backlinks" className="h-full overflow-auto p-4">
|
||||||
{backlinks.length === 0 ? (
|
{backlinksLoading ? <p className="py-8 text-center text-sm text-muted-foreground" role="status">Loading backlinks...</p> : backlinksError ? <div className="py-8 text-center"><p className="text-sm text-muted-foreground" role="alert">{backlinksError}</p><Button className="mt-3" size="sm" onClick={() => selectedNote && fetchBacklinks(selectedNote.id)}>Retry</Button></div> : backlinks.length === 0 ? (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
No backlinks
|
No backlinks
|
||||||
</p>
|
</p>
|
||||||
@@ -311,10 +406,7 @@ export default function NotesPage() {
|
|||||||
{backlinks.map((link) => (
|
{backlinks.map((link) => (
|
||||||
<button
|
<button
|
||||||
key={link.id}
|
key={link.id}
|
||||||
onClick={() => {
|
onClick={() => openBacklink(link)}
|
||||||
const note = notes.find((n) => n.id === link.id);
|
|
||||||
if (note) setSelectedNote(note);
|
|
||||||
}}
|
|
||||||
aria-label={`Open linked note: ${link.title}`}
|
aria-label={`Open linked note: ${link.title}`}
|
||||||
className="w-full rounded-lg border p-3 text-left transition-colors hover:bg-accent"
|
className="w-full rounded-lg border p-3 text-left transition-colors hover:bg-accent"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export default function ProjectDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<CheckCircle2 className="h-8 w-8 text-green-600" aria-hidden="true" />
|
<CheckCircle2 className="h-8 w-8 text-green-600" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<Progress value={project.progress} className="mt-2 h-2" />
|
<Progress value={project.progress} className="mt-2 h-2" aria-label={`${project.name} progress: ${project.progress}%`} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -361,6 +361,7 @@ export default function ProjectDetailPage() {
|
|||||||
: 0
|
: 0
|
||||||
}
|
}
|
||||||
className="h-1.5"
|
className="h-1.5"
|
||||||
|
aria-label={`${milestone.name} task progress: ${milestone.completed_tasks} of ${milestone.total_tasks}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { CreateItemDialog } from '@/components/create-item-dialog';
|
||||||
|
|
||||||
interface Project {
|
interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -23,27 +24,41 @@ interface Project {
|
|||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchProjects();
|
fetchProjects();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (new URLSearchParams(window.location.search).get('new') === 'true') setCreateOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function handleCreateOpenChange(open: boolean) {
|
||||||
|
setCreateOpen(open);
|
||||||
|
if (!open && new URLSearchParams(window.location.search).get('new') === 'true') {
|
||||||
|
window.history.replaceState(null, '', '/projects');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchProjects() {
|
async function fetchProjects() {
|
||||||
try {
|
try {
|
||||||
|
setError(null);
|
||||||
const response = await fetch('/api/projects?sort=-created');
|
const response = await fetch('/api/projects?sort=-created');
|
||||||
if (response.ok) {
|
if (!response.ok) throw new Error('Unable to load projects.');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setProjects(data.items || []);
|
setProjects(data.items || []);
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch projects:', error);
|
console.error('Failed to fetch projects:', error);
|
||||||
|
setError('Unable to load projects. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <p className="text-muted-foreground">Loading projects...</p>;
|
return <p role="status" className="text-muted-foreground">Loading projects...</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeProjects = projects.filter((p) => p.status === 'active');
|
const activeProjects = projects.filter((p) => p.status === 'active');
|
||||||
@@ -57,12 +72,19 @@ export default function ProjectsPage() {
|
|||||||
<h1 className="text-2xl font-bold">Projects</h1>
|
<h1 className="text-2xl font-bold">Projects</h1>
|
||||||
<p className="mt-1 text-muted-foreground">Every outcome has a home.</p>
|
<p className="mt-1 text-muted-foreground">Every outcome has a home.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button>
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
New project
|
New project
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div role="alert" className="mb-6 flex items-center justify-between gap-4 rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||||
|
<span>{error}</span>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchProjects}>Retry</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Active projects */}
|
{/* Active projects */}
|
||||||
{activeProjects.length > 0 && (
|
{activeProjects.length > 0 && (
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
@@ -107,9 +129,16 @@ export default function ProjectsPage() {
|
|||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
Create your first project to get started
|
Create your first project to get started
|
||||||
</p>
|
</p>
|
||||||
|
<Button className="mt-4" onClick={() => setCreateOpen(true)}>Create a project</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
<CreateItemDialog
|
||||||
|
type="project"
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={handleCreateOpenChange}
|
||||||
|
onCreated={fetchProjects}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -149,7 +178,7 @@ function ProjectCard({ project }: { project: Project }) {
|
|||||||
<span className="text-muted-foreground">Progress</span>
|
<span className="text-muted-foreground">Progress</span>
|
||||||
<span className="font-semibold">{project.progress}%</span>
|
<span className="font-semibold">{project.progress}%</span>
|
||||||
</div>
|
</div>
|
||||||
<Progress value={project.progress} className="h-2" />
|
<Progress value={project.progress} className="h-2" aria-label={`${project.name} progress: ${project.progress}%`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Task count */}
|
{/* Task count */}
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, Suspense } from 'react';
|
import { useEffect, useRef, useState, Suspense } from 'react';
|
||||||
import { Plus, FileBarChart, Calendar, Target, TrendingUp, Clock } from 'lucide-react';
|
import { Plus, FileBarChart, Calendar, Target, TrendingUp, Clock, Trash2 } from 'lucide-react';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
|
||||||
// Lazy load TipTap report editor (~80KB)
|
// Lazy load TipTap report editor (~80KB)
|
||||||
const ReportEditor = dynamic(
|
const ReportEditor = dynamic(
|
||||||
@@ -50,24 +62,34 @@ export default function ReportsPage() {
|
|||||||
const [selectedReport, setSelectedReport] = useState<Report | null>(null);
|
const [selectedReport, setSelectedReport] = useState<Report | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showTemplates, setShowTemplates] = useState(false);
|
const [showTemplates, setShowTemplates] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [saveStatus, setSaveStatus] = useState<'Saved' | 'Saving' | 'Failed'>('Saved');
|
||||||
|
const [reportToDelete, setReportToDelete] = useState<Report | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const pendingSave = useRef<{ id: string; updates: Partial<Report> } | null>(null);
|
||||||
|
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const saveVersion = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchReports();
|
fetchReports();
|
||||||
|
return () => {
|
||||||
|
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function fetchReports() {
|
async function fetchReports() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/reports?sort=-created');
|
const response = await fetch('/api/reports?sort=-created');
|
||||||
if (response.ok) {
|
if (!response.ok) throw new Error('Unable to load reports.');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const reportsList = data.items || [];
|
const reportsList = data.items || [];
|
||||||
setReports(reportsList);
|
setReports(reportsList);
|
||||||
if (reportsList.length > 0 && !selectedReport) {
|
setSelectedReport((current) => current || reportsList[0] || null);
|
||||||
setSelectedReport(reportsList[0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch reports:', error);
|
console.error('Failed to fetch reports:', error);
|
||||||
|
setError('Unable to load reports. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -86,42 +108,73 @@ export default function ReportsPage() {
|
|||||||
...overrides,
|
...overrides,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (!response.ok) throw new Error('Unable to create report.');
|
||||||
const newReport = await response.json();
|
const newReport = await response.json();
|
||||||
setReports([newReport, ...reports]);
|
setReports((current) => [newReport, ...current]);
|
||||||
setSelectedReport(newReport);
|
setSelectedReport(newReport);
|
||||||
setShowTemplates(false);
|
setShowTemplates(false);
|
||||||
}
|
toast.success('Report created');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create report:', error);
|
console.error('Failed to create report:', error);
|
||||||
|
toast.error('Unable to create report');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateReport(reportId: string, updates: Partial<Report>) {
|
function scheduleSave(reportId: string, updates: Partial<Report>) {
|
||||||
|
const version = ++saveVersion.current;
|
||||||
|
pendingSave.current = {
|
||||||
|
id: reportId,
|
||||||
|
updates: { ...(pendingSave.current?.id === reportId ? pendingSave.current.updates : {}), ...updates },
|
||||||
|
};
|
||||||
|
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||||
|
setSaveStatus('Saving');
|
||||||
|
saveTimer.current = setTimeout(() => {
|
||||||
|
const save = pendingSave.current;
|
||||||
|
pendingSave.current = null;
|
||||||
|
if (save) updateReport(save.id, save.updates, version);
|
||||||
|
}, 700);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateReport(reportId: string, updates: Partial<Report>, version: number) {
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/reports/${reportId}`, {
|
const response = await fetch(`/api/reports/${reportId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(updates),
|
body: JSON.stringify(updates),
|
||||||
});
|
});
|
||||||
fetchReports();
|
if (!response.ok) throw new Error('Unable to save report.');
|
||||||
|
const updated = await response.json();
|
||||||
|
setReports((current) => current.map((report) => (report.id === reportId ? updated : report)));
|
||||||
|
if (saveVersion.current === version) setSaveStatus('Saved');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update report:', error);
|
console.error('Failed to update report:', error);
|
||||||
|
if (saveVersion.current === version) setSaveStatus('Failed');
|
||||||
|
toast.error('Unable to save report');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteReport(reportId: string) {
|
async function deleteReport() {
|
||||||
if (!confirm('Are you sure you want to delete this report?')) return;
|
if (!reportToDelete) return;
|
||||||
|
if (pendingSave.current?.id === reportToDelete.id && saveTimer.current) {
|
||||||
|
clearTimeout(saveTimer.current);
|
||||||
|
pendingSave.current = null;
|
||||||
|
}
|
||||||
|
++saveVersion.current;
|
||||||
|
setDeleting(true);
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/reports/${reportId}`, { method: 'DELETE' });
|
const response = await fetch(`/api/reports/${reportToDelete.id}`, { method: 'DELETE' });
|
||||||
const updatedReports = reports.filter((r) => r.id !== reportId);
|
if (!response.ok) throw new Error('Unable to delete report.');
|
||||||
setReports(updatedReports);
|
setReports((current) => current.filter((report) => report.id !== reportToDelete.id));
|
||||||
if (selectedReport?.id === reportId) {
|
setSelectedReport((selected) =>
|
||||||
setSelectedReport(updatedReports[0] || null);
|
selected?.id === reportToDelete.id ? reports.find((report) => report.id !== reportToDelete.id) || null : selected
|
||||||
}
|
);
|
||||||
|
setReportToDelete(null);
|
||||||
|
toast.success('Report deleted');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete report:', error);
|
console.error('Failed to delete report:', error);
|
||||||
|
toast.error('Unable to delete report');
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +196,11 @@ export default function ReportsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <p className="text-muted-foreground">Loading reports...</p>;
|
return <p className="text-muted-foreground" role="status">Loading reports...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div className="space-y-3"><p className="text-muted-foreground" role="alert">{error}</p><Button onClick={fetchReports}>Retry</Button></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showTemplates) {
|
if (showTemplates) {
|
||||||
@@ -171,7 +228,7 @@ export default function ReportsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Reports</h1>
|
<h1 className="text-2xl font-bold">Reports</h1>
|
||||||
<p className="mt-1 text-muted-foreground">Step back and see what changed.</p>
|
<p className="mt-1 text-muted-foreground">Step back and see what changed.</p>
|
||||||
@@ -189,12 +246,16 @@ export default function ReportsPage() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[300px_1fr]">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[300px_1fr]">
|
||||||
{/* Reports list */}
|
{/* Reports list */}
|
||||||
<Card className="h-[calc(100vh-200px)] overflow-auto">
|
<Card className="max-h-80 overflow-auto lg:h-[calc(100vh-200px)] lg:max-h-none">
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
{reports.length === 0 ? (
|
{reports.length === 0 ? (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
<div className="py-8 text-center">
|
||||||
No reports yet
|
<p className="text-sm text-muted-foreground">No reports yet</p>
|
||||||
</p>
|
<Button className="mt-3" size="sm" onClick={() => createReport()}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create your first report
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{reports.map((report) => (
|
{reports.map((report) => (
|
||||||
@@ -236,26 +297,35 @@ export default function ReportsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Report editor */}
|
{/* Report editor */}
|
||||||
<Card className="h-[calc(100vh-200px)]">
|
<Card className="min-h-[420px] lg:h-[calc(100vh-200px)]">
|
||||||
{selectedReport ? (
|
{selectedReport ? (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="border-b p-4">
|
<div className="border-b p-4">
|
||||||
<label htmlFor="report-title" className="sr-only">
|
<label htmlFor="report-title" className="sr-only">
|
||||||
Report title
|
Report title
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
id="report-title"
|
id="report-title"
|
||||||
type="text"
|
type="text"
|
||||||
value={selectedReport.title}
|
value={selectedReport.title}
|
||||||
onChange={(e) =>
|
onChange={(e) => { const title = e.target.value; setSelectedReport({ ...selectedReport, title }); scheduleSave(selectedReport.id, { title }); }}
|
||||||
setSelectedReport({ ...selectedReport, title: e.target.value })
|
className="min-w-0 flex-1 text-xl font-semibold outline-none"
|
||||||
}
|
|
||||||
onBlur={() =>
|
|
||||||
updateReport(selectedReport.id, { title: selectedReport.title })
|
|
||||||
}
|
|
||||||
className="w-full text-xl font-semibold outline-none"
|
|
||||||
placeholder="Report title"
|
placeholder="Report title"
|
||||||
/>
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground" role="status">{saveStatus}</span>
|
||||||
|
<AlertDialog open={reportToDelete?.id === selectedReport.id} onOpenChange={(open) => !open && setReportToDelete(null)}>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" aria-label={`Delete ${selectedReport.title}`} onClick={() => setReportToDelete(selectedReport)}>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader><AlertDialogTitle>Delete {reportToDelete?.title}?</AlertDialogTitle><AlertDialogDescription>This permanently deletes this report.</AlertDialogDescription></AlertDialogHeader>
|
||||||
|
<AlertDialogFooter><AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel><AlertDialogAction onClick={deleteReport} disabled={deleting}>{deleting ? 'Deleting...' : 'Delete report'}</AlertDialogAction></AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
<div className="mt-2 flex gap-2">
|
<div className="mt-2 flex gap-2">
|
||||||
<Badge variant="outline">{selectedReport.report_type}</Badge>
|
<Badge variant="outline">{selectedReport.report_type}</Badge>
|
||||||
<Badge variant="outline">{selectedReport.domain}</Badge>
|
<Badge variant="outline">{selectedReport.domain}</Badge>
|
||||||
@@ -280,12 +350,7 @@ export default function ReportsPage() {
|
|||||||
>
|
>
|
||||||
<ReportEditor
|
<ReportEditor
|
||||||
content={selectedReport.content}
|
content={selectedReport.content}
|
||||||
onChange={(content) =>
|
onChange={(content) => { setSelectedReport({ ...selectedReport, content }); scheduleSave(selectedReport.id, { content }); }}
|
||||||
setSelectedReport({ ...selectedReport, content })
|
|
||||||
}
|
|
||||||
onBlur={() =>
|
|
||||||
updateReport(selectedReport.id, { content: selectedReport.content })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,16 @@ import {
|
|||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { AlertTriangle, Trash2 } from 'lucide-react';
|
import { AlertTriangle, Trash2 } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
|
||||||
interface ErrorLog {
|
interface ErrorLog {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -23,33 +33,46 @@ interface ErrorLog {
|
|||||||
export default function ErrorLogPage() {
|
export default function ErrorLogPage() {
|
||||||
const [errors, setErrors] = useState<ErrorLog[]>([]);
|
const [errors, setErrors] = useState<ErrorLog[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [clearing, setClearing] = useState(false);
|
||||||
|
const [confirmClear, setConfirmClear] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchErrors();
|
fetchErrors();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function fetchErrors() {
|
async function fetchErrors() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/error-logs?limit=50');
|
const response = await fetch('/api/error-logs?limit=50');
|
||||||
if (response.ok) {
|
if (!response.ok) throw new Error('Unable to load error logs.');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setErrors(data.items || []);
|
setErrors(data.items || []);
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch error logs:', error);
|
console.error('Failed to fetch error logs:', error);
|
||||||
|
setError('Unable to load error logs. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearErrors() {
|
async function clearErrors() {
|
||||||
|
setClearing(true);
|
||||||
|
setError(null);
|
||||||
|
setStatus(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/error-logs', { method: 'DELETE' });
|
const response = await fetch('/api/error-logs', { method: 'DELETE' });
|
||||||
if (response.ok) {
|
if (!response.ok) throw new Error('Unable to clear error logs.');
|
||||||
setErrors([]);
|
setErrors([]);
|
||||||
}
|
setConfirmClear(false);
|
||||||
|
setStatus('Error logs cleared successfully.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to clear error logs:', error);
|
console.error('Failed to clear error logs:', error);
|
||||||
|
setError('Unable to clear error logs. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setClearing(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,14 +98,35 @@ export default function ErrorLogPage() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
{errors.length > 0 && (
|
{errors.length > 0 && (
|
||||||
<Button variant="outline" size="sm" onClick={clearErrors} aria-label="Clear all error logs">
|
<AlertDialog open={confirmClear} onOpenChange={setConfirmClear}>
|
||||||
<Trash2 className="mr-2 h-4 w-4" aria-hidden="true" />
|
<Button variant="outline" size="sm" onClick={() => setConfirmClear(true)} aria-label="Clear all error logs">
|
||||||
Clear all
|
<Trash2 className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
</Button>
|
Clear all
|
||||||
|
</Button>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Clear all error logs?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>This permanently removes all displayed error logs.</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={clearing}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={clearErrors} disabled={clearing}>
|
||||||
|
{clearing ? 'Clearing...' : 'Clear all'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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={fetchErrors} disabled={loading}>Retry</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status && <p className="mb-4 text-sm text-muted-foreground" role="status">{status}</p>}
|
||||||
{errors.length === 0 ? (
|
{errors.length === 0 ? (
|
||||||
<p className="text-center text-muted-foreground py-8">
|
<p className="text-center text-muted-foreground py-8">
|
||||||
No errors logged
|
No errors logged
|
||||||
|
|||||||
@@ -26,39 +26,39 @@ export default function SettingsPage() {
|
|||||||
<p className="mt-1 text-muted-foreground">Tune Project E to fit your work.</p>
|
<p className="mt-1 text-muted-foreground">Tune Project E to fit your work.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="appearance" orientation="vertical" className="flex gap-6">
|
<Tabs defaultValue="appearance" orientation="vertical" className="flex flex-col gap-6 md:flex-row">
|
||||||
<TabsList className="flex w-[200px] flex-col gap-1 bg-transparent h-auto">
|
<TabsList className="flex h-auto w-full flex-row justify-start gap-1 overflow-x-auto bg-transparent p-0 md:w-[200px] md:flex-col">
|
||||||
<TabsTrigger value="appearance" className="justify-start gap-2">
|
<TabsTrigger value="appearance" className="shrink-0 justify-start gap-2">
|
||||||
<Palette className="h-4 w-4" aria-hidden="true" />
|
<Palette className="h-4 w-4" aria-hidden="true" />
|
||||||
Appearance
|
Appearance
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="domains" className="justify-start gap-2">
|
<TabsTrigger value="domains" className="shrink-0 justify-start gap-2">
|
||||||
<Globe className="h-4 w-4" aria-hidden="true" />
|
<Globe className="h-4 w-4" aria-hidden="true" />
|
||||||
Domains
|
Domains
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="shortcuts" className="justify-start gap-2">
|
<TabsTrigger value="shortcuts" className="shrink-0 justify-start gap-2">
|
||||||
<Keyboard className="h-4 w-4" aria-hidden="true" />
|
<Keyboard className="h-4 w-4" aria-hidden="true" />
|
||||||
Keyboard Shortcuts
|
Keyboard Shortcuts
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="agents" className="justify-start gap-2">
|
<TabsTrigger value="agents" className="shrink-0 justify-start gap-2">
|
||||||
<Bot className="h-4 w-4" aria-hidden="true" />
|
<Bot className="h-4 w-4" aria-hidden="true" />
|
||||||
Agents & Permissions
|
Agents & Permissions
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="webhooks" className="justify-start gap-2">
|
<TabsTrigger value="webhooks" className="shrink-0 justify-start gap-2">
|
||||||
<Webhook className="h-4 w-4" aria-hidden="true" />
|
<Webhook className="h-4 w-4" aria-hidden="true" />
|
||||||
Webhooks
|
Webhooks
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="import-export" className="justify-start gap-2">
|
<TabsTrigger value="import-export" className="shrink-0 justify-start gap-2">
|
||||||
<Download className="h-4 w-4" aria-hidden="true" />
|
<Download className="h-4 w-4" aria-hidden="true" />
|
||||||
Import & Export
|
Import & Export
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="error-log" className="justify-start gap-2">
|
<TabsTrigger value="error-log" className="shrink-0 justify-start gap-2">
|
||||||
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
|
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
|
||||||
Error Log
|
Error Log
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<TabsContent value="appearance">
|
<TabsContent value="appearance">
|
||||||
<SettingsAppearance />
|
<SettingsAppearance />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { LayoutGrid, List } from 'lucide-react';
|
import { LayoutGrid, List, Plus } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { TasksKanbanView } from '@/components/tasks/tasks-kanban-view';
|
import { TasksKanbanView } from '@/components/tasks/tasks-kanban-view';
|
||||||
import { TasksListView } from '@/components/tasks/tasks-list-view';
|
import { TasksListView } from '@/components/tasks/tasks-list-view';
|
||||||
|
import { CreateItemDialog } from '@/components/create-item-dialog';
|
||||||
|
|
||||||
export default function TasksPage() {
|
export default function TasksPage() {
|
||||||
const [view, setView] = useState<'kanban' | 'list'>('kanban');
|
const [view, setView] = useState<'kanban' | 'list'>('kanban');
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (new URLSearchParams(window.location.search).get('new') === 'true') setCreateOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function handleCreateOpenChange(open: boolean) {
|
||||||
|
setCreateOpen(open);
|
||||||
|
if (!open && new URLSearchParams(window.location.search).get('new') === 'true') {
|
||||||
|
window.history.replaceState(null, '', '/tasks');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -20,6 +34,10 @@ export default function TasksPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
|
New task
|
||||||
|
</Button>
|
||||||
<Tabs
|
<Tabs
|
||||||
value={view}
|
value={view}
|
||||||
onValueChange={(v) => setView(v as 'kanban' | 'list')}
|
onValueChange={(v) => setView(v as 'kanban' | 'list')}
|
||||||
@@ -38,7 +56,17 @@ export default function TasksPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{view === 'kanban' ? <TasksKanbanView /> : <TasksListView />}
|
{view === 'kanban' ? (
|
||||||
|
<TasksKanbanView key={refreshKey} />
|
||||||
|
) : (
|
||||||
|
<TasksListView key={refreshKey} />
|
||||||
|
)}
|
||||||
|
<CreateItemDialog
|
||||||
|
type="task"
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={handleCreateOpenChange}
|
||||||
|
onCreated={() => setRefreshKey((key) => key + 1)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,13 +15,19 @@ export const GET = withAuth(async (request: NextRequest, _user) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const byDomain: Record<string, number> = {};
|
const byDomain: Record<string, number> = {};
|
||||||
|
const byDate: Record<string, number> = {};
|
||||||
const byProject: Record<string, number> = {};
|
const byProject: Record<string, number> = {};
|
||||||
const byTag: Record<string, number> = {};
|
const byTag: Record<string, number> = {};
|
||||||
let totalMinutes = 0;
|
let totalMinutes = 0;
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const duration = (entry.duration_minutes as number) || 0;
|
const duration = (entry.duration_minutes as number) || (entry.duration as number) || 0;
|
||||||
totalMinutes += duration;
|
totalMinutes += duration;
|
||||||
|
const startedAt = entry.started_at as string | undefined;
|
||||||
|
if (startedAt) {
|
||||||
|
const date = new Date(startedAt).toISOString().slice(0, 10);
|
||||||
|
byDate[date] = (byDate[date] || 0) + duration;
|
||||||
|
}
|
||||||
|
|
||||||
// Get task for domain/project/tags
|
// Get task for domain/project/tags
|
||||||
const task = await pb.collection('tasks').getOne(entry.task_id as string);
|
const task = await pb.collection('tasks').getOne(entry.task_id as string);
|
||||||
@@ -43,6 +49,7 @@ export const GET = withAuth(async (request: NextRequest, _user) => {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
totalMinutes,
|
totalMinutes,
|
||||||
byDomain,
|
byDomain,
|
||||||
|
byDate,
|
||||||
byProject,
|
byProject,
|
||||||
byTag,
|
byTag,
|
||||||
startDate,
|
startDate,
|
||||||
|
|||||||
@@ -67,6 +67,13 @@ export function AnalyticsCharts({
|
|||||||
habitData,
|
habitData,
|
||||||
activeTab,
|
activeTab,
|
||||||
}: AnalyticsChartsProps) {
|
}: 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') {
|
if (activeTab === 'trends') {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-6">
|
<div className="grid grid-cols-1 gap-6">
|
||||||
@@ -79,43 +86,23 @@ export function AnalyticsCharts({
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
{!hasActivity ? (
|
||||||
<AreaChart data={timeData}>
|
<p className="py-8 text-center text-muted-foreground">No completed tasks or habit logs in the last 30 days.</p>
|
||||||
<CartesianGrid
|
) : (
|
||||||
strokeDasharray="3 3"
|
<div role="img" aria-label={`Productivity trend. ${activitySummary}`}>
|
||||||
stroke="hsl(var(--border))"
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
/>
|
<AreaChart data={timeData} aria-hidden="true">
|
||||||
<XAxis
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
||||||
dataKey="date"
|
<XAxis dataKey="date" stroke="hsl(var(--muted-foreground))" fontSize={12} />
|
||||||
stroke="hsl(var(--muted-foreground))"
|
<YAxis stroke="hsl(var(--muted-foreground))" fontSize={12} />
|
||||||
fontSize={12}
|
<Tooltip contentStyle={chartTooltipStyle} />
|
||||||
/>
|
<Legend />
|
||||||
<YAxis
|
<Area type="monotone" dataKey="tasks" stackId="1" stroke="#3b82f6" fill="#3b82f6" fillOpacity={0.6} name="Tasks Completed" />
|
||||||
stroke="hsl(var(--muted-foreground))"
|
<Area type="monotone" dataKey="habits" stackId="1" stroke="#10b981" fill="#10b981" fillOpacity={0.6} name="Habits Logged" />
|
||||||
fontSize={12}
|
</AreaChart>
|
||||||
/>
|
</ResponsiveContainer>
|
||||||
<Tooltip contentStyle={chartTooltipStyle} />
|
</div>
|
||||||
<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>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -128,37 +115,21 @@ export function AnalyticsCharts({
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
{timeData.some((day) => day.time > 0) ? (
|
||||||
<LineChart data={timeData}>
|
<div role="img" aria-label={`Time tracked over the last 30 days. ${activitySummary}`}>
|
||||||
<CartesianGrid
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
strokeDasharray="3 3"
|
<LineChart data={timeData} aria-hidden="true">
|
||||||
stroke="hsl(var(--border))"
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
||||||
/>
|
<XAxis dataKey="date" stroke="hsl(var(--muted-foreground))" fontSize={12} />
|
||||||
<XAxis
|
<YAxis stroke="hsl(var(--muted-foreground))" fontSize={12} label={{ value: 'Minutes', angle: -90, position: 'insideLeft' }} />
|
||||||
dataKey="date"
|
<Tooltip contentStyle={chartTooltipStyle} />
|
||||||
stroke="hsl(var(--muted-foreground))"
|
<Line type="monotone" dataKey="time" stroke="#8b5cf6" strokeWidth={2} dot={{ fill: '#8b5cf6', r: 3 }} name="Time (minutes)" />
|
||||||
fontSize={12}
|
</LineChart>
|
||||||
/>
|
</ResponsiveContainer>
|
||||||
<YAxis
|
</div>
|
||||||
stroke="hsl(var(--muted-foreground))"
|
) : (
|
||||||
fontSize={12}
|
<p className="py-8 text-center text-muted-foreground">No time tracked in the last 30 days.</p>
|
||||||
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>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -182,8 +153,9 @@ export function AnalyticsCharts({
|
|||||||
No habits tracked
|
No habits tracked
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<div role="img" aria-label={`Habit streaks: ${habitData.map((habit) => `${habit.name}, ${habit.streak} days`).join('; ')}.`}>
|
||||||
<BarChart data={habitData} layout="vertical">
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart data={habitData} layout="vertical">
|
||||||
<CartesianGrid
|
<CartesianGrid
|
||||||
strokeDasharray="3 3"
|
strokeDasharray="3 3"
|
||||||
stroke="hsl(var(--border))"
|
stroke="hsl(var(--border))"
|
||||||
@@ -207,8 +179,9 @@ export function AnalyticsCharts({
|
|||||||
radius={[0, 4, 4, 0]}
|
radius={[0, 4, 4, 0]}
|
||||||
name="Current Streak (days)"
|
name="Current Streak (days)"
|
||||||
/>
|
/>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -239,6 +212,11 @@ export function AnalyticsCharts({
|
|||||||
<div
|
<div
|
||||||
className="h-full rounded-full bg-primary transition-all"
|
className="h-full rounded-full bg-primary transition-all"
|
||||||
style={{ width: `${habit.score}%` }}
|
style={{ width: `${habit.score}%` }}
|
||||||
|
role="progressbar"
|
||||||
|
aria-label={`${habit.name} score`}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-valuenow={habit.score}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -269,6 +247,7 @@ export function AnalyticsCharts({
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-8">
|
<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}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie
|
<Pie
|
||||||
@@ -304,8 +283,12 @@ export function AnalyticsCharts({
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
{!hasRecentActivity ? (
|
||||||
<BarChart data={timeData.slice(-7)}>
|
<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
|
<CartesianGrid
|
||||||
strokeDasharray="3 3"
|
strokeDasharray="3 3"
|
||||||
stroke="hsl(var(--border))"
|
stroke="hsl(var(--border))"
|
||||||
@@ -333,8 +316,10 @@ export function AnalyticsCharts({
|
|||||||
name="Habits"
|
name="Habits"
|
||||||
radius={[4, 4, 0, 0]}
|
radius={[4, 4, 0, 0]}
|
||||||
/>
|
/>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Calendar, dateFnsLocalizer } from 'react-big-calendar';
|
|||||||
import 'react-big-calendar/lib/css/react-big-calendar.css';
|
import 'react-big-calendar/lib/css/react-big-calendar.css';
|
||||||
import { format, parse, startOfWeek, getDay } from 'date-fns';
|
import { format, parse, startOfWeek, getDay } from 'date-fns';
|
||||||
import { enUS } from 'date-fns/locale/en-US';
|
import { enUS } from 'date-fns/locale/en-US';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
const locales = {
|
const locales = {
|
||||||
'en-US': enUS,
|
'en-US': enUS,
|
||||||
@@ -22,9 +23,10 @@ interface CalendarEvent {
|
|||||||
title: string;
|
title: string;
|
||||||
start: Date;
|
start: Date;
|
||||||
end: Date;
|
end: Date;
|
||||||
type: 'task' | 'habit' | 'project' | 'milestone';
|
type: 'task' | 'project' | 'milestone';
|
||||||
domain: string;
|
domain: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
href: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BigCalendarWrapperProps {
|
interface BigCalendarWrapperProps {
|
||||||
@@ -44,11 +46,9 @@ function eventStyleGetter(event: CalendarEvent) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSelectEvent(event: CalendarEvent) {
|
|
||||||
console.log('Selected event:', event);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BigCalendarWrapper({ events }: BigCalendarWrapperProps) {
|
export function BigCalendarWrapper({ events }: BigCalendarWrapperProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Calendar
|
<Calendar
|
||||||
localizer={localizer}
|
localizer={localizer}
|
||||||
@@ -57,7 +57,7 @@ export function BigCalendarWrapper({ events }: BigCalendarWrapperProps) {
|
|||||||
endAccessor="end"
|
endAccessor="end"
|
||||||
style={{ height: 600 }}
|
style={{ height: 600 }}
|
||||||
eventPropGetter={eventStyleGetter}
|
eventPropGetter={eventStyleGetter}
|
||||||
onSelectEvent={handleSelectEvent}
|
onSelectEvent={(event) => router.push(event.href)}
|
||||||
views={['month', 'week', 'day']}
|
views={['month', 'week', 'day']}
|
||||||
defaultView="month"
|
defaultView="month"
|
||||||
popup
|
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';
|
'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-grid-layout/css/styles.css';
|
||||||
import 'react-resizable/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 {
|
interface ResponsiveGridProps {
|
||||||
layout: ReactGridLayout.Layout[];
|
layout: Layout;
|
||||||
onLayoutChange: (newLayout: ReactGridLayout.Layout[]) => void;
|
onLayoutChange: (newLayout: Layout) => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,19 +16,26 @@ export default function ResponsiveGrid({
|
|||||||
onLayoutChange,
|
onLayoutChange,
|
||||||
children,
|
children,
|
||||||
}: ResponsiveGridProps) {
|
}: ResponsiveGridProps) {
|
||||||
|
const { width, containerRef, mounted } = useContainerWidth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveGridLayout
|
<div ref={containerRef}>
|
||||||
className="layout"
|
{mounted && (
|
||||||
layouts={{ lg: layout }}
|
<ResponsiveGridLayout
|
||||||
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
className="layout"
|
||||||
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
|
width={width}
|
||||||
rowHeight={80}
|
layouts={{ lg: layout }}
|
||||||
onLayoutChange={onLayoutChange}
|
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
||||||
draggableHandle=".widget-drag-handle"
|
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
|
||||||
compactType="vertical"
|
rowHeight={80}
|
||||||
isResizable
|
onLayoutChange={(_layout, _layouts) => onLayoutChange(_layout)}
|
||||||
>
|
dragConfig={{ handle: '.widget-drag-handle' }}
|
||||||
{children}
|
compactor={verticalCompactor}
|
||||||
</ResponsiveGridLayout>
|
resizeConfig={{ enabled: true }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ResponsiveGridLayout>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function ProjectProgressWidget() {
|
|||||||
{project.progress}%
|
{project.progress}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Progress value={project.progress} className="h-2" />
|
<Progress value={project.progress} className="h-2" aria-label={`${project.name} progress: ${project.progress}%`} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
export function QuickAddWidget() {
|
export function QuickAddWidget() {
|
||||||
function handleQuickAdd() {
|
const router = useRouter();
|
||||||
document.dispatchEvent(
|
|
||||||
new KeyboardEvent('keydown', { key: 'k', metaKey: true })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="h-full border-0 shadow-none">
|
<Card className="h-full border-0 shadow-none">
|
||||||
@@ -25,7 +22,7 @@ export function QuickAddWidget() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="justify-start"
|
className="justify-start"
|
||||||
onClick={handleQuickAdd}
|
onClick={() => router.push('/tasks?new=true')}
|
||||||
>
|
>
|
||||||
New task
|
New task
|
||||||
</Button>
|
</Button>
|
||||||
@@ -33,7 +30,7 @@ export function QuickAddWidget() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="justify-start"
|
className="justify-start"
|
||||||
onClick={handleQuickAdd}
|
onClick={() => router.push('/habits?new=true')}
|
||||||
>
|
>
|
||||||
New habit
|
New habit
|
||||||
</Button>
|
</Button>
|
||||||
@@ -41,7 +38,7 @@ export function QuickAddWidget() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="justify-start"
|
className="justify-start"
|
||||||
onClick={handleQuickAdd}
|
onClick={() => router.push('/notes?new=true')}
|
||||||
>
|
>
|
||||||
New note
|
New note
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function HabitCard({ habit, onComplete }: HabitCardProps) {
|
|||||||
<span className="text-muted-foreground">Score</span>
|
<span className="text-muted-foreground">Score</span>
|
||||||
<span className="font-semibold">{habit.score}/100</span>
|
<span className="font-semibold">{habit.score}/100</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Frequency badge */}
|
{/* Frequency badge */}
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export function HabitCompletionDialog({
|
|||||||
<Input
|
<Input
|
||||||
id="quantity"
|
id="quantity"
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="e.g., 30 minutes, 10 pages"
|
placeholder="e.g., 30"
|
||||||
value={quantity ?? ''}
|
value={quantity ?? ''}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setQuantity(e.target.value ? Number(e.target.value) : undefined)
|
setQuantity(e.target.value ? Number(e.target.value) : undefined)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import CalendarHeatmap from 'react-calendar-heatmap';
|
import CalendarHeatmap from 'react-calendar-heatmap';
|
||||||
import type { Habit } from '@project-e/shared';
|
import type { Habit } from '@project-e/shared';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
interface HeatmapValue {
|
interface HeatmapValue {
|
||||||
date: Date | string;
|
date: Date | string;
|
||||||
@@ -16,6 +17,7 @@ interface HabitHeatmapProps {
|
|||||||
export function HabitHeatmap({ habits }: HabitHeatmapProps) {
|
export function HabitHeatmap({ habits }: HabitHeatmapProps) {
|
||||||
const [values, setValues] = useState<HeatmapValue[]>([]);
|
const [values, setValues] = useState<HeatmapValue[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchHeatmapData();
|
fetchHeatmapData();
|
||||||
@@ -23,6 +25,8 @@ export function HabitHeatmap({ habits }: HabitHeatmapProps) {
|
|||||||
}, [habits.length]);
|
}, [habits.length]);
|
||||||
|
|
||||||
async function fetchHeatmapData() {
|
async function fetchHeatmapData() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const oneYearAgo = new Date();
|
const oneYearAgo = new Date();
|
||||||
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
|
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
|
||||||
@@ -30,28 +34,30 @@ export function HabitHeatmap({ habits }: HabitHeatmapProps) {
|
|||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/habit-logs?start=${oneYearAgo.toISOString()}`
|
`/api/habit-logs?start=${oneYearAgo.toISOString()}`
|
||||||
);
|
);
|
||||||
if (response.ok) {
|
if (!response.ok) {
|
||||||
const data = await response.json();
|
throw new Error('Habit logs could not be loaded.');
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch heatmap data:', error);
|
console.error('Failed to fetch heatmap data:', error);
|
||||||
|
setError('Habit activity could not be loaded.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -61,12 +67,26 @@ export function HabitHeatmap({ habits }: HabitHeatmapProps) {
|
|||||||
return <p className="text-sm text-muted-foreground">Loading...</p>;
|
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 today = new Date();
|
||||||
const oneYearAgo = new Date();
|
const oneYearAgo = new Date();
|
||||||
oneYearAgo.setFullYear(today.getFullYear() - 1);
|
oneYearAgo.setFullYear(today.getFullYear() - 1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<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
|
<CalendarHeatmap
|
||||||
startDate={oneYearAgo}
|
startDate={oneYearAgo}
|
||||||
endDate={today}
|
endDate={today}
|
||||||
|
|||||||
@@ -9,6 +9,16 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
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 {
|
interface Agent {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -24,20 +34,27 @@ export function SettingsAgents() {
|
|||||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
const [newAgentName, setNewAgentName] = useState('');
|
const [newAgentName, setNewAgentName] = useState('');
|
||||||
const [newAgentTier, setNewAgentTier] = useState('read_only');
|
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(() => {
|
useEffect(() => {
|
||||||
fetchAgents();
|
fetchAgents();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function fetchAgents() {
|
async function fetchAgents() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/agents');
|
const response = await fetch('/api/agents');
|
||||||
if (response.ok) {
|
if (!response.ok) throw new Error('Unable to load agents.');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setAgents(data.items || []);
|
setAgents(data.items || []);
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch agents:', error);
|
console.error('Failed to fetch agents:', error);
|
||||||
|
setError('Unable to load agents. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -46,8 +63,11 @@ export function SettingsAgents() {
|
|||||||
async function createAgent() {
|
async function createAgent() {
|
||||||
if (!newAgentName.trim()) return;
|
if (!newAgentName.trim()) return;
|
||||||
|
|
||||||
|
setCreating(true);
|
||||||
|
setError(null);
|
||||||
|
setStatus(null);
|
||||||
try {
|
try {
|
||||||
await fetch('/api/agents', {
|
const response = await fetch('/api/agents', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -56,22 +76,36 @@ export function SettingsAgents() {
|
|||||||
status: 'active',
|
status: 'active',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
if (!response.ok) throw new Error('Unable to create agent.');
|
||||||
setNewAgentName('');
|
setNewAgentName('');
|
||||||
setCreateDialogOpen(false);
|
setCreateDialogOpen(false);
|
||||||
fetchAgents();
|
setStatus('Agent created successfully.');
|
||||||
|
await fetchAgents();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create agent:', error);
|
console.error('Failed to create agent:', error);
|
||||||
|
setError('Unable to create agent. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteAgent(id: string) {
|
async function deleteAgent() {
|
||||||
if (!confirm('Are you sure? This will revoke the agent\'s access.')) return;
|
if (!agentToDelete) return;
|
||||||
|
|
||||||
|
setDeletingId(agentToDelete.id);
|
||||||
|
setError(null);
|
||||||
|
setStatus(null);
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/agents/${id}`, { method: 'DELETE' });
|
const response = await fetch(`/api/agents/${agentToDelete.id}`, { method: 'DELETE' });
|
||||||
fetchAgents();
|
if (!response.ok) throw new Error('Unable to delete agent.');
|
||||||
|
setAgentToDelete(null);
|
||||||
|
setStatus('Agent deleted successfully.');
|
||||||
|
await fetchAgents();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete agent:', 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>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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 ? (
|
{loading ? (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
Loading agents...
|
Loading agents...
|
||||||
@@ -173,8 +214,9 @@ export function SettingsAgents() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => deleteAgent(agent.id)}
|
onClick={() => setAgentToDelete(agent)}
|
||||||
aria-label={`Delete agent: ${agent.name}`}
|
aria-label={`Delete agent: ${agent.name}`}
|
||||||
|
disabled={deletingId === agent.id}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 text-destructive" aria-hidden="true" />
|
<Trash2 className="h-4 w-4 text-destructive" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -183,6 +225,20 @@ export function SettingsAgents() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,16 @@ import { Plus, Trash2 } from 'lucide-react';
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
|
||||||
interface Domain {
|
interface Domain {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,20 +28,27 @@ export function SettingsDomains() {
|
|||||||
const [domains, setDomains] = useState<Domain[]>([]);
|
const [domains, setDomains] = useState<Domain[]>([]);
|
||||||
const [newDomainName, setNewDomainName] = useState('');
|
const [newDomainName, setNewDomainName] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
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(() => {
|
useEffect(() => {
|
||||||
fetchDomains();
|
fetchDomains();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function fetchDomains() {
|
async function fetchDomains() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/domains?sort=sort_order');
|
const response = await fetch('/api/domains?sort=sort_order');
|
||||||
if (response.ok) {
|
if (!response.ok) throw new Error('Unable to load domains.');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setDomains(data.items || []);
|
setDomains(data.items || []);
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch domains:', error);
|
console.error('Failed to fetch domains:', error);
|
||||||
|
setError('Unable to load domains. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -40,8 +57,11 @@ export function SettingsDomains() {
|
|||||||
async function addDomain() {
|
async function addDomain() {
|
||||||
if (!newDomainName.trim()) return;
|
if (!newDomainName.trim()) return;
|
||||||
|
|
||||||
|
setCreating(true);
|
||||||
|
setError(null);
|
||||||
|
setStatus(null);
|
||||||
try {
|
try {
|
||||||
await fetch('/api/domains', {
|
const response = await fetch('/api/domains', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -51,21 +71,35 @@ export function SettingsDomains() {
|
|||||||
sort_order: domains.length,
|
sort_order: domains.length,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
if (!response.ok) throw new Error('Unable to add domain.');
|
||||||
setNewDomainName('');
|
setNewDomainName('');
|
||||||
fetchDomains();
|
setStatus('Domain added successfully.');
|
||||||
|
await fetchDomains();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to add domain:', error);
|
console.error('Failed to add domain:', error);
|
||||||
|
setError('Unable to add domain. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteDomain(id: string) {
|
async function deleteDomain() {
|
||||||
if (!confirm('Are you sure? This cannot be undone.')) return;
|
if (!domainToDelete) return;
|
||||||
|
|
||||||
|
setDeletingId(domainToDelete.id);
|
||||||
|
setError(null);
|
||||||
|
setStatus(null);
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/domains/${id}`, { method: 'DELETE' });
|
const response = await fetch(`/api/domains/${domainToDelete.id}`, { method: 'DELETE' });
|
||||||
fetchDomains();
|
if (!response.ok) throw new Error('Unable to delete domain.');
|
||||||
|
setDomainToDelete(null);
|
||||||
|
setStatus('Domain deleted successfully.');
|
||||||
|
await fetchDomains();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete domain:', 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>
|
<CardDescription>Manage your workspace domains (e.g., Personal, Work, OTS).</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<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 */}
|
{/* Existing domains */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -93,8 +134,9 @@ export function SettingsDomains() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => deleteDomain(domain.id)}
|
onClick={() => setDomainToDelete(domain)}
|
||||||
aria-label={`Delete domain: ${domain.name}`}
|
aria-label={`Delete domain: ${domain.name}`}
|
||||||
|
disabled={deletingId === domain.id}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 text-destructive" aria-hidden="true" />
|
<Trash2 className="h-4 w-4 text-destructive" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -114,12 +156,27 @@ export function SettingsDomains() {
|
|||||||
value={newDomainName}
|
value={newDomainName}
|
||||||
onChange={(e) => setNewDomainName(e.target.value)}
|
onChange={(e) => setNewDomainName(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && addDomain()}
|
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" />
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
Add
|
{creating ? 'Adding...' : 'Add'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -66,16 +66,20 @@ export function SettingsImportExport() {
|
|||||||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||||
const [confirmImport, setConfirmImport] = useState(false);
|
const [confirmImport, setConfirmImport] = useState(false);
|
||||||
const [pendingImportFile, setPendingImportFile] = useState<File | null>(null);
|
const [pendingImportFile, setPendingImportFile] = useState<File | null>(null);
|
||||||
|
const [exportError, setExportError] = useState<string | null>(null);
|
||||||
|
const [importError, setImportError] = useState<string | null>(null);
|
||||||
|
|
||||||
// ── Export ────────────────────────────────────────────────────────────────
|
// ── Export ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function handleExport() {
|
async function handleExport() {
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
setExportProgress(0);
|
setExportProgress(0);
|
||||||
|
setExportError(null);
|
||||||
|
let progressInterval: ReturnType<typeof setInterval> | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Simulate progress while fetching
|
// Simulate progress while fetching
|
||||||
const progressInterval = setInterval(() => {
|
progressInterval = setInterval(() => {
|
||||||
setExportProgress((prev) => Math.min(prev + 10, 90));
|
setExportProgress((prev) => Math.min(prev + 10, 90));
|
||||||
}, 200);
|
}, 200);
|
||||||
|
|
||||||
@@ -86,6 +90,7 @@ export function SettingsImportExport() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
clearInterval(progressInterval);
|
clearInterval(progressInterval);
|
||||||
|
progressInterval = undefined;
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Export failed');
|
throw new Error('Export failed');
|
||||||
@@ -108,8 +113,10 @@ export function SettingsImportExport() {
|
|||||||
toast.success('Export completed successfully');
|
toast.success('Export completed successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to export:', error);
|
console.error('Failed to export:', error);
|
||||||
|
setExportError('Failed to export data. Please try again.');
|
||||||
toast.error('Failed to export data');
|
toast.error('Failed to export data');
|
||||||
} finally {
|
} finally {
|
||||||
|
if (progressInterval) clearInterval(progressInterval);
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
setExportProgress(0);
|
setExportProgress(0);
|
||||||
}
|
}
|
||||||
@@ -131,6 +138,7 @@ export function SettingsImportExport() {
|
|||||||
|
|
||||||
setPendingImportFile(file);
|
setPendingImportFile(file);
|
||||||
setImportResult(null);
|
setImportResult(null);
|
||||||
|
setImportError(null);
|
||||||
setConfirmImport(true);
|
setConfirmImport(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,18 +148,21 @@ export function SettingsImportExport() {
|
|||||||
setConfirmImport(false);
|
setConfirmImport(false);
|
||||||
setImporting(true);
|
setImporting(true);
|
||||||
setImportProgress(0);
|
setImportProgress(0);
|
||||||
|
setImportError(null);
|
||||||
|
let progressInterval: ReturnType<typeof setInterval> | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const text = await pendingImportFile.text();
|
const text = await pendingImportFile.text();
|
||||||
const data = JSON.parse(text);
|
const data = JSON.parse(text);
|
||||||
|
|
||||||
if (!data.version) {
|
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?');
|
toast.error('Invalid file — missing version field. Is this a valid Project E export?');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simulate progress
|
// Simulate progress
|
||||||
const progressInterval = setInterval(() => {
|
progressInterval = setInterval(() => {
|
||||||
setImportProgress((prev) => Math.min(prev + 5, 90));
|
setImportProgress((prev) => Math.min(prev + 5, 90));
|
||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
@@ -162,16 +173,25 @@ export function SettingsImportExport() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
clearInterval(progressInterval);
|
clearInterval(progressInterval);
|
||||||
|
progressInterval = undefined;
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json();
|
let message = 'Import failed. Please try again.';
|
||||||
toast.error(errorData.error?.message || 'Import failed');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: ImportResult = await response.json();
|
const result: ImportResult = await response.json();
|
||||||
setImportProgress(100);
|
setImportProgress(100);
|
||||||
setImportResult(result);
|
setImportResult(result);
|
||||||
|
setPendingImportFile(null);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(`Import complete: ${result.imported} records imported`);
|
toast.success(`Import complete: ${result.imported} records imported`);
|
||||||
@@ -182,10 +202,11 @@ export function SettingsImportExport() {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to import:', 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.');
|
toast.error('Failed to parse import file. Please check the format.');
|
||||||
} finally {
|
} finally {
|
||||||
|
if (progressInterval) clearInterval(progressInterval);
|
||||||
setImporting(false);
|
setImporting(false);
|
||||||
setPendingImportFile(null);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +276,7 @@ export function SettingsImportExport() {
|
|||||||
{/* Progress */}
|
{/* Progress */}
|
||||||
{exporting && (
|
{exporting && (
|
||||||
<div className="mt-4 space-y-2">
|
<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>
|
<p className="text-xs text-muted-foreground">Exporting... {exportProgress}%</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -272,6 +293,7 @@ export function SettingsImportExport() {
|
|||||||
)}
|
)}
|
||||||
{exporting ? 'Exporting...' : 'Export to JSON'}
|
{exporting ? 'Exporting...' : 'Export to JSON'}
|
||||||
</Button>
|
</Button>
|
||||||
|
{exportError && <p className="mt-2 text-sm text-destructive" role="alert">{exportError}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Import Section ─────────────────────────────────────────────────── */}
|
{/* ── Import Section ─────────────────────────────────────────────────── */}
|
||||||
@@ -284,7 +306,7 @@ export function SettingsImportExport() {
|
|||||||
{/* Progress */}
|
{/* Progress */}
|
||||||
{importing && (
|
{importing && (
|
||||||
<div className="mt-4 space-y-2">
|
<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>
|
<p className="text-xs text-muted-foreground">Importing... {importProgress}%</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -356,6 +378,12 @@ export function SettingsImportExport() {
|
|||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* ── Import Confirmation Dialog ─────────────────────────────────────── */}
|
{/* ── Import Confirmation Dialog ─────────────────────────────────────── */}
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ export function ShortcutsHelp() {
|
|||||||
if (
|
if (
|
||||||
target.tagName === 'INPUT' ||
|
target.tagName === 'INPUT' ||
|
||||||
target.tagName === 'TEXTAREA' ||
|
target.tagName === 'TEXTAREA' ||
|
||||||
target.isContentEditable
|
target.tagName === 'SELECT' ||
|
||||||
|
target.tagName === 'BUTTON' ||
|
||||||
|
target.isContentEditable ||
|
||||||
|
target.closest('[role="dialog"], [role="menu"], [role="listbox"], [role="combobox"]')
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip';
|
} from '@/components/ui/tooltip';
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from '@/components/ui/sheet';
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
@@ -46,13 +53,88 @@ const workspaceItems = [
|
|||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const pathname = usePathname();
|
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 (
|
return (
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
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'
|
collapsed ? 'w-16' : 'w-60'
|
||||||
)}
|
)}
|
||||||
aria-label="Main navigation"
|
aria-label="Main navigation"
|
||||||
@@ -80,82 +162,17 @@ export function Sidebar() {
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Navigation */}
|
{navigation(collapsed)}
|
||||||
<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>
|
|
||||||
</aside>
|
</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>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle
|
||||||
} from '@/components/ui/sheet';
|
} from '@/components/ui/sheet';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -16,8 +17,18 @@ import {
|
|||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
|
||||||
interface Task {
|
interface Task {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -42,7 +53,7 @@ export function TaskDetailPanel({
|
|||||||
task,
|
task,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onUpdate,
|
onUpdate
|
||||||
}: TaskDetailPanelProps) {
|
}: TaskDetailPanelProps) {
|
||||||
const [title, setTitle] = useState(task.title);
|
const [title, setTitle] = useState(task.title);
|
||||||
const [description, setDescription] = useState(task.description || '');
|
const [description, setDescription] = useState(task.description || '');
|
||||||
@@ -51,11 +62,13 @@ export function TaskDetailPanel({
|
|||||||
const [domain, setDomain] = useState(task.domain);
|
const [domain, setDomain] = useState(task.domain);
|
||||||
const [dueDate, setDueDate] = useState(task.due_date || '');
|
const [dueDate, setDueDate] = useState(task.due_date || '');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/tasks/${task.id}`, {
|
const response = await fetch(`/api/tasks/${task.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -64,27 +77,37 @@ export function TaskDetailPanel({
|
|||||||
status,
|
status,
|
||||||
priority,
|
priority,
|
||||||
domain,
|
domain,
|
||||||
due_date: dueDate || null,
|
due_date: dueDate || null
|
||||||
}),
|
})
|
||||||
});
|
});
|
||||||
|
if (!response.ok) throw new Error('Unable to save task');
|
||||||
onUpdate();
|
onUpdate();
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
|
toast.success('Task saved');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update task:', error);
|
console.error('Failed to update task:', error);
|
||||||
|
toast.error('Unable to save task');
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
if (!confirm('Are you sure you want to delete this task?')) return;
|
setDeleting(true);
|
||||||
|
|
||||||
try {
|
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();
|
onUpdate();
|
||||||
|
setDeleteOpen(false);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
|
toast.success('Task deleted');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete task:', 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>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={handleDelete}
|
onClick={() => setDeleteOpen(true)}
|
||||||
className="ml-auto"
|
className="ml-auto"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,17 +2,30 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
KeyboardSensor,
|
||||||
DndContext,
|
DndContext,
|
||||||
DragEndEvent,
|
DragEndEvent,
|
||||||
DragOverlay,
|
DragOverlay,
|
||||||
DragStartEvent,
|
DragStartEvent,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
useDraggable,
|
useDraggable,
|
||||||
useDroppable,
|
useDroppable
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
|
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
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';
|
import { TaskDetailPanel } from './task-detail-panel';
|
||||||
|
|
||||||
interface Task {
|
interface Task {
|
||||||
@@ -30,25 +43,27 @@ interface Task {
|
|||||||
const columns = [
|
const columns = [
|
||||||
{ id: 'todo', title: 'To Do', color: 'bg-slate-500' },
|
{ id: 'todo', title: 'To Do', color: 'bg-slate-500' },
|
||||||
{ id: 'in_progress', title: 'In Progress', color: 'bg-blue-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({
|
function DraggableTask({
|
||||||
task,
|
task,
|
||||||
onClick,
|
onClick,
|
||||||
|
onStatusChange
|
||||||
}: {
|
}: {
|
||||||
task: Task;
|
task: Task;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
onStatusChange: (task: Task, status: Task['status']) => void;
|
||||||
}) {
|
}) {
|
||||||
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
||||||
useDraggable({
|
useDraggable({
|
||||||
id: task.id,
|
id: task.id,
|
||||||
data: { task },
|
data: { task }
|
||||||
});
|
});
|
||||||
|
|
||||||
const style = transform
|
const style = transform
|
||||||
? {
|
? {
|
||||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
@@ -56,11 +71,7 @@ function DraggableTask({
|
|||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
style={style}
|
||||||
{...listeners}
|
className={isDragging ? 'opacity-50' : ''}
|
||||||
{...attributes}
|
|
||||||
className={`cursor-grab active:cursor-grabbing ${
|
|
||||||
isDragging ? 'opacity-50' : ''
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Card className="mb-2 hover:shadow-md transition-shadow">
|
<Card className="mb-2 hover:shadow-md transition-shadow">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
@@ -77,12 +88,32 @@ function DraggableTask({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-11 w-11 shrink-0"
|
className="h-11 w-11 shrink-0 cursor-grab active:cursor-grabbing"
|
||||||
aria-label={`More options for ${task.title}`}
|
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>
|
</Button>
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
@@ -118,12 +149,14 @@ function DroppableColumn({
|
|||||||
color,
|
color,
|
||||||
tasks,
|
tasks,
|
||||||
onTaskClick,
|
onTaskClick,
|
||||||
|
onStatusChange
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
color: string;
|
color: string;
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
onTaskClick: (task: Task) => void;
|
onTaskClick: (task: Task) => void;
|
||||||
|
onStatusChange: (task: Task, status: Task['status']) => void;
|
||||||
}) {
|
}) {
|
||||||
const { setNodeRef, isOver } = useDroppable({ id });
|
const { setNodeRef, isOver } = useDroppable({ id });
|
||||||
|
|
||||||
@@ -132,9 +165,7 @@ function DroppableColumn({
|
|||||||
<div className="mb-3 flex items-center gap-2">
|
<div className="mb-3 flex items-center gap-2">
|
||||||
<div className={`h-2 w-2 rounded-full ${color}`} aria-hidden="true" />
|
<div className={`h-2 w-2 rounded-full ${color}`} aria-hidden="true" />
|
||||||
<h3 className="font-semibold">{title}</h3>
|
<h3 className="font-semibold">{title}</h3>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">({tasks.length})</span>
|
||||||
({tasks.length})
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
@@ -149,6 +180,7 @@ function DroppableColumn({
|
|||||||
key={task.id}
|
key={task.id}
|
||||||
task={task}
|
task={task}
|
||||||
onClick={() => onTaskClick(task)}
|
onClick={() => onTaskClick(task)}
|
||||||
|
onStatusChange={onStatusChange}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -161,6 +193,12 @@ export function TasksKanbanView() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [activeTask, setActiveTask] = useState<Task | null>(null);
|
const [activeTask, setActiveTask] = useState<Task | null>(null);
|
||||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTasks();
|
fetchTasks();
|
||||||
@@ -169,12 +207,14 @@ export function TasksKanbanView() {
|
|||||||
async function fetchTasks() {
|
async function fetchTasks() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/tasks?sort=-created');
|
const response = await fetch('/api/tasks?sort=-created');
|
||||||
if (response.ok) {
|
if (!response.ok) {
|
||||||
const data = await response.json();
|
throw new Error('Unable to load tasks');
|
||||||
setTasks(data.items || []);
|
|
||||||
}
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
setTasks(data.items || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch tasks:', error);
|
console.error('Failed to fetch tasks:', error);
|
||||||
|
toast.error('Unable to load tasks');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -182,23 +222,18 @@ export function TasksKanbanView() {
|
|||||||
|
|
||||||
async function handleDragEnd(event: DragEndEvent) {
|
async function handleDragEnd(event: DragEndEvent) {
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
if (!over) return;
|
if (!over) {
|
||||||
|
setActiveTask(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const task = active.data.current?.task as Task;
|
const task = active.data.current?.task as Task;
|
||||||
const newStatus = over.id as Task['status'];
|
const newStatus = over.id as Task['status'];
|
||||||
|
|
||||||
if (task.status !== newStatus) {
|
if (task.status !== newStatus) {
|
||||||
try {
|
await updateTaskStatus(task, newStatus);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
setActiveTask(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDragStart(event: DragStartEvent) {
|
function handleDragStart(event: DragStartEvent) {
|
||||||
@@ -206,13 +241,39 @@ export function TasksKanbanView() {
|
|||||||
setActiveTask(task);
|
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) {
|
if (loading) {
|
||||||
return <p className="text-muted-foreground">Loading tasks...</p>;
|
return <p className="text-muted-foreground">Loading tasks...</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||||
{columns.map((column) => (
|
{columns.map((column) => (
|
||||||
<DroppableColumn
|
<DroppableColumn
|
||||||
@@ -222,6 +283,7 @@ export function TasksKanbanView() {
|
|||||||
color={column.color}
|
color={column.color}
|
||||||
tasks={tasks.filter((t) => t.status === column.id)}
|
tasks={tasks.filter((t) => t.status === column.id)}
|
||||||
onTaskClick={setSelectedTask}
|
onTaskClick={setSelectedTask}
|
||||||
|
onStatusChange={updateTaskStatus}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import {
|
|||||||
TableCell,
|
TableCell,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
|
||||||
import { Calendar, MoreHorizontal } from 'lucide-react';
|
import { Calendar, MoreHorizontal } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { TaskDetailPanel } from './task-detail-panel';
|
import { TaskDetailPanel } from './task-detail-panel';
|
||||||
|
|
||||||
interface Task {
|
interface Task {
|
||||||
@@ -39,12 +41,14 @@ export function TasksListView() {
|
|||||||
async function fetchTasks() {
|
async function fetchTasks() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/tasks?sort=-created');
|
const response = await fetch('/api/tasks?sort=-created');
|
||||||
if (response.ok) {
|
if (!response.ok) {
|
||||||
const data = await response.json();
|
throw new Error('Unable to load tasks');
|
||||||
setTasks(data.items || []);
|
|
||||||
}
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
setTasks(data.items || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch tasks:', error);
|
console.error('Failed to fetch tasks:', error);
|
||||||
|
toast.error('Unable to load tasks');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -53,14 +57,19 @@ export function TasksListView() {
|
|||||||
async function toggleTaskComplete(task: Task) {
|
async function toggleTaskComplete(task: Task) {
|
||||||
const newStatus = task.status === 'done' ? 'todo' : 'done';
|
const newStatus = task.status === 'done' ? 'todo' : 'done';
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/tasks/${task.id}`, {
|
const response = await fetch(`/api/tasks/${task.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to toggle task:', error);
|
console.error('Failed to toggle task:', error);
|
||||||
|
toast.error(`Unable to update ${task.title}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,77 +79,82 @@ export function TasksListView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Table>
|
<ScrollArea className="w-full">
|
||||||
<TableHeader>
|
<div className="min-w-[700px]">
|
||||||
<TableRow>
|
<Table>
|
||||||
<TableHead className="w-[50px]"></TableHead>
|
<TableHeader>
|
||||||
<TableHead>Task</TableHead>
|
<TableRow>
|
||||||
<TableHead>Priority</TableHead>
|
<TableHead className="w-[50px]"></TableHead>
|
||||||
<TableHead>Domain</TableHead>
|
<TableHead>Task</TableHead>
|
||||||
<TableHead>Due Date</TableHead>
|
<TableHead>Priority</TableHead>
|
||||||
<TableHead className="w-[50px]"></TableHead>
|
<TableHead>Domain</TableHead>
|
||||||
</TableRow>
|
<TableHead>Due Date</TableHead>
|
||||||
</TableHeader>
|
<TableHead className="w-[50px]"></TableHead>
|
||||||
<TableBody>
|
</TableRow>
|
||||||
{tasks.map((task) => (
|
</TableHeader>
|
||||||
<TableRow key={task.id}>
|
<TableBody>
|
||||||
<TableCell>
|
{tasks.map((task) => (
|
||||||
<Checkbox
|
<TableRow key={task.id}>
|
||||||
checked={task.status === 'done'}
|
<TableCell>
|
||||||
onCheckedChange={() => toggleTaskComplete(task)}
|
<Checkbox
|
||||||
aria-label={`Mark "${task.title}" as ${task.status === 'done' ? 'incomplete' : 'complete'}`}
|
checked={task.status === 'done'}
|
||||||
/>
|
onCheckedChange={() => toggleTaskComplete(task)}
|
||||||
</TableCell>
|
aria-label={`Mark "${task.title}" as ${task.status === 'done' ? 'incomplete' : 'complete'}`}
|
||||||
<TableCell>
|
/>
|
||||||
<button
|
</TableCell>
|
||||||
onClick={() => setSelectedTask(task)}
|
<TableCell>
|
||||||
className={`text-left font-medium hover:underline ${
|
<button
|
||||||
task.status === 'done'
|
onClick={() => setSelectedTask(task)}
|
||||||
? 'line-through text-muted-foreground'
|
className={`text-left font-medium hover:underline ${
|
||||||
: ''
|
task.status === 'done'
|
||||||
}`}
|
? 'line-through text-muted-foreground'
|
||||||
>
|
: ''
|
||||||
{task.title}
|
}`}
|
||||||
</button>
|
>
|
||||||
</TableCell>
|
{task.title}
|
||||||
<TableCell>
|
</button>
|
||||||
<Badge
|
</TableCell>
|
||||||
variant={
|
<TableCell>
|
||||||
task.priority === 'urgent'
|
<Badge
|
||||||
? 'destructive'
|
variant={
|
||||||
: task.priority === 'high'
|
task.priority === 'urgent'
|
||||||
? 'default'
|
? 'destructive'
|
||||||
: 'secondary'
|
: task.priority === 'high'
|
||||||
}
|
? 'default'
|
||||||
>
|
: 'secondary'
|
||||||
{task.priority}
|
}
|
||||||
</Badge>
|
>
|
||||||
</TableCell>
|
{task.priority}
|
||||||
<TableCell>
|
</Badge>
|
||||||
<Badge variant="outline">{task.domain}</Badge>
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell>
|
||||||
<TableCell>
|
<Badge variant="outline">{task.domain}</Badge>
|
||||||
{task.due_date && (
|
</TableCell>
|
||||||
<span className="flex items-center gap-1 text-sm text-muted-foreground">
|
<TableCell>
|
||||||
<Calendar className="h-3 w-3" />
|
{task.due_date && (
|
||||||
{new Date(task.due_date).toLocaleDateString()}
|
<span className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
</span>
|
<Calendar className="h-3 w-3" />
|
||||||
)}
|
{new Date(task.due_date).toLocaleDateString()}
|
||||||
</TableCell>
|
</span>
|
||||||
<TableCell>
|
)}
|
||||||
<Button
|
</TableCell>
|
||||||
variant="ghost"
|
<TableCell>
|
||||||
size="icon"
|
<Button
|
||||||
className="h-11 w-11"
|
variant="ghost"
|
||||||
aria-label={`More options for ${task.title}`}
|
size="icon"
|
||||||
>
|
className="h-11 w-11"
|
||||||
<MoreHorizontal className="h-4 w-4" aria-hidden="true" />
|
aria-label={`More options for ${task.title}`}
|
||||||
</Button>
|
>
|
||||||
</TableCell>
|
<MoreHorizontal className="h-4 w-4" aria-hidden="true" />
|
||||||
</TableRow>
|
</Button>
|
||||||
))}
|
</TableCell>
|
||||||
</TableBody>
|
</TableRow>
|
||||||
</Table>
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<ScrollBar orientation="horizontal" />
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
{selectedTask && (
|
{selectedTask && (
|
||||||
<TaskDetailPanel
|
<TaskDetailPanel
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Search, Bell, Plus, Menu } from 'lucide-react';
|
import { Search, Bell, Plus, Menu } from 'lucide-react';
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useSidebarStore } from '@/lib/stores/use-sidebar-store';
|
import { useSidebarStore } from '@/lib/stores/use-sidebar-store';
|
||||||
import { CommandPalette } from '@/components/command-palette';
|
import { CommandPalette } from '@/components/command-palette';
|
||||||
|
|
||||||
export function TopBar() {
|
export function TopBar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
const { setMobileOpen } = useSidebarStore();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -46,14 +56,16 @@ export function TopBar() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
document.dispatchEvent(
|
if (creation) {
|
||||||
new KeyboardEvent('keydown', { key: 'k', metaKey: true })
|
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" />
|
<Plus className="mr-1 h-4 w-4" aria-hidden="true" />
|
||||||
Quick add
|
{creation?.label ?? 'Quick add'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button variant="ghost" size="icon" aria-label="Notifications">
|
<Button variant="ghost" size="icon" aria-label="Notifications">
|
||||||
|
|||||||
@@ -15,12 +15,15 @@ export function useKeyboardShortcuts() {
|
|||||||
let pendingTimeout: ReturnType<typeof setTimeout>;
|
let pendingTimeout: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
// Ignore if user is typing in an input/textarea
|
// Never override native behavior inside controls or modal UI.
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (
|
if (
|
||||||
target.tagName === 'INPUT' ||
|
target.tagName === 'INPUT' ||
|
||||||
target.tagName === 'TEXTAREA' ||
|
target.tagName === 'TEXTAREA' ||
|
||||||
target.isContentEditable
|
target.tagName === 'SELECT' ||
|
||||||
|
target.tagName === 'BUTTON' ||
|
||||||
|
target.isContentEditable ||
|
||||||
|
target.closest('[role="dialog"], [role="menu"], [role="listbox"], [role="combobox"]')
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -63,11 +66,6 @@ export function useKeyboardShortcuts() {
|
|||||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }));
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }));
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
break;
|
break;
|
||||||
case '?':
|
|
||||||
// Show shortcuts help
|
|
||||||
console.log('Show shortcuts help');
|
|
||||||
e.preventDefault();
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ export function middleware(request: NextRequest) {
|
|||||||
const token = request.cookies.get('pb_auth')?.value;
|
const token = request.cookies.get('pb_auth')?.value;
|
||||||
|
|
||||||
// If no token and trying to access protected routes, redirect to login
|
// If no token and trying to access protected routes, redirect to login
|
||||||
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
|
const protectedRoutes = [
|
||||||
|
'/dashboard', '/tasks', '/habits', '/projects', '/notes', '/reports',
|
||||||
|
'/calendar', '/analytics', '/agents', '/settings',
|
||||||
|
];
|
||||||
|
if (!token && protectedRoutes.some((route) => request.nextUrl.pathname === route || request.nextUrl.pathname.startsWith(`${route}/`))) {
|
||||||
const loginUrl = new URL('/login', request.url);
|
const loginUrl = new URL('/login', request.url);
|
||||||
return NextResponse.redirect(loginUrl);
|
return NextResponse.redirect(loginUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Generated
-1
@@ -5304,7 +5304,6 @@
|
|||||||
"version": "2.3.2",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
exports = {
|
||||||
up: async (app) => {
|
up: async (app) => {
|
||||||
const collection = new app.models.Collection({
|
const collection = new app.models.Collection({
|
||||||
name: 'domains',
|
name: 'domains',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
exports = {
|
||||||
up: async (app) => {
|
up: async (app) => {
|
||||||
const collection = new app.models.Collection({
|
const collection = new app.models.Collection({
|
||||||
name: 'tags',
|
name: 'tags',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
exports = {
|
||||||
up: async (app) => {
|
up: async (app) => {
|
||||||
const collection = new app.models.Collection({
|
const collection = new app.models.Collection({
|
||||||
name: 'projects',
|
name: 'projects',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
exports = {
|
||||||
up: async (app) => {
|
up: async (app) => {
|
||||||
const collection = new app.models.Collection({
|
const collection = new app.models.Collection({
|
||||||
name: 'project_settings',
|
name: 'project_settings',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
exports = {
|
||||||
up: async (app) => {
|
up: async (app) => {
|
||||||
const collection = new app.models.Collection({
|
const collection = new app.models.Collection({
|
||||||
name: 'milestones',
|
name: 'milestones',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
exports = {
|
||||||
up: async (app) => {
|
up: async (app) => {
|
||||||
const collection = new app.models.Collection({
|
const collection = new app.models.Collection({
|
||||||
name: 'milestone_dependencies',
|
name: 'milestone_dependencies',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
exports = {
|
||||||
up: async (app) => {
|
up: async (app) => {
|
||||||
const collection = new app.models.Collection({
|
const collection = new app.models.Collection({
|
||||||
name: 'milestone_templates',
|
name: 'milestone_templates',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
exports = {
|
||||||
up: async (app) => {
|
up: async (app) => {
|
||||||
const collection = new app.models.Collection({
|
const collection = new app.models.Collection({
|
||||||
name: 'milestone_history',
|
name: 'milestone_history',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
exports = {
|
||||||
up: async (app) => {
|
up: async (app) => {
|
||||||
const collection = new app.models.Collection({
|
const collection = new app.models.Collection({
|
||||||
name: 'tasks',
|
name: 'tasks',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
exports = {
|
||||||
up: async (app) => {
|
up: async (app) => {
|
||||||
const collection = new app.models.Collection({
|
const collection = new app.models.Collection({
|
||||||
name: 'task_subtasks',
|
name: 'task_subtasks',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
exports = {
|
||||||
up: async (app) => {
|
up: async (app) => {
|
||||||
const collection = new app.models.Collection({
|
const collection = new app.models.Collection({
|
||||||
name: 'task_dependencies',
|
name: 'task_dependencies',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
exports = {
|
||||||
up: async (app) => {
|
up: async (app) => {
|
||||||
const collection = new app.models.Collection({
|
const collection = new app.models.Collection({
|
||||||
name: 'task_attachments',
|
name: 'task_attachments',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
exports = {
|
||||||
up: async (app) => {
|
up: async (app) => {
|
||||||
const collection = new app.models.Collection({
|
const collection = new app.models.Collection({
|
||||||
name: 'task_time_entries',
|
name: 'task_time_entries',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
exports = {
|
||||||
up: async (app) => {
|
up: async (app) => {
|
||||||
const collection = new app.models.Collection({
|
const collection = new app.models.Collection({
|
||||||
name: 'habits',
|
name: 'habits',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
exports = {
|
||||||
up: async (app) => {
|
up: async (app) => {
|
||||||
const collection = new app.models.Collection({
|
const collection = new app.models.Collection({
|
||||||
name: 'habit_logs',
|
name: 'habit_logs',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
exports = {
|
||||||
up: async (app) => {
|
up: async (app) => {
|
||||||
const collection = new app.models.Collection({
|
const collection = new app.models.Collection({
|
||||||
name: 'habit_skip_days',
|
name: 'habit_skip_days',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
exports = {
|
||||||
up: async (app) => {
|
up: async (app) => {
|
||||||
const collection = new app.models.Collection({
|
const collection = new app.models.Collection({
|
||||||
name: 'notes',
|
name: 'notes',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
exports = {
|
||||||
up: async (app) => {
|
up: async (app) => {
|
||||||
const collection = new app.models.Collection({
|
const collection = new app.models.Collection({
|
||||||
name: 'note_links',
|
name: 'note_links',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
exports = {
|
||||||
up: async (app) => {
|
up: async (app) => {
|
||||||
const collection = new app.models.Collection({
|
const collection = new app.models.Collection({
|
||||||
name: 'note_task_links',
|
name: 'note_task_links',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
exports = {
|
||||||
up: async (app) => {
|
up: async (app) => {
|
||||||
const collection = new app.models.Collection({
|
const collection = new app.models.Collection({
|
||||||
name: 'report_templates',
|
name: 'report_templates',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
exports = {
|
||||||
up: async (app) => {
|
up: async (app) => {
|
||||||
const collection = new app.models.Collection({
|
const collection = new app.models.Collection({
|
||||||
name: 'reports',
|
name: 'reports',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
exports = {
|
||||||
up: async (app) => {
|
up: async (app) => {
|
||||||
const collection = new app.models.Collection({
|
const collection = new app.models.Collection({
|
||||||
name: 'canvases',
|
name: 'canvases',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
exports = {
|
||||||
up: async (app) => {
|
up: async (app) => {
|
||||||
const collection = new app.models.Collection({
|
const collection = new app.models.Collection({
|
||||||
name: 'canvas_cards',
|
name: 'canvas_cards',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
exports = {
|
||||||
up: async (app) => {
|
up: async (app) => {
|
||||||
const collection = new app.models.Collection({
|
const collection = new app.models.Collection({
|
||||||
name: 'agents',
|
name: 'agents',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
exports = {
|
||||||
up: async (app) => {
|
up: async (app) => {
|
||||||
const collection = new app.models.Collection({
|
const collection = new app.models.Collection({
|
||||||
name: 'agent_activity',
|
name: 'agent_activity',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
exports = {
|
||||||
up: async (app) => {
|
up: async (app) => {
|
||||||
const collection = new app.models.Collection({
|
const collection = new app.models.Collection({
|
||||||
name: 'webhooks',
|
name: 'webhooks',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
exports = {
|
||||||
up: async (app) => {
|
up: async (app) => {
|
||||||
const collection = new app.models.Collection({
|
const collection = new app.models.Collection({
|
||||||
name: 'webhook_deliveries',
|
name: 'webhook_deliveries',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
exports = {
|
||||||
up: async (app) => {
|
up: async (app) => {
|
||||||
const collection = new app.models.Collection({
|
const collection = new app.models.Collection({
|
||||||
name: 'agent_tasks',
|
name: 'agent_tasks',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
exports = {
|
||||||
up: async (app) => {
|
up: async (app) => {
|
||||||
const collection = new app.models.Collection({
|
const collection = new app.models.Collection({
|
||||||
name: 'notifications',
|
name: 'notifications',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
exports = {
|
||||||
up: async (app) => {
|
up: async (app) => {
|
||||||
const collection = new app.models.Collection({
|
const collection = new app.models.Collection({
|
||||||
name: 'error_logs',
|
name: 'error_logs',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
exports = {
|
||||||
up: async (app) => {
|
up: async (app) => {
|
||||||
const collection = new app.models.Collection({
|
const collection = new app.models.Collection({
|
||||||
name: 'queue_jobs',
|
name: 'queue_jobs',
|
||||||
|
|||||||
Reference in New Issue
Block a user