refactor: migrate to monorepo structure with Docker, PocketBase, and e2e tests
- Reorganize into apps/, packages/, docs/, e2e/, pocketbase/ directories - Add Dockerfiles for web, worker, and PocketBase services - Add docker-compose.yml for local orchestration - Add turbo.json for monorepo task management - Add Playwright e2e test infrastructure - Add PocketBase backend with migrations - Remove Vite/Next.js/ESLint/PostCSS config files - Update package.json with workspace dependencies - Add .env.example and .dockerignore
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Activity, CheckCircle2, XCircle, Clock, RotateCcw } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
|
||||
interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
description?: string;
|
||||
permission_tier: string;
|
||||
status: 'active' | 'disabled';
|
||||
last_activity_at?: string;
|
||||
}
|
||||
|
||||
interface AgentActivity {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
action: string;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
before_state?: Record<string, unknown>;
|
||||
after_state?: Record<string, unknown>;
|
||||
created: string;
|
||||
}
|
||||
|
||||
interface AgentTask {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
task_type: string;
|
||||
input: string;
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
output?: Record<string, unknown>;
|
||||
created: string;
|
||||
}
|
||||
|
||||
export default function AgentsPage() {
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [activity, setActivity] = useState<AgentActivity[]>([]);
|
||||
const [agentTasks, setAgentTasks] = useState<AgentTask[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAgents();
|
||||
fetchActivity();
|
||||
fetchAgentTasks();
|
||||
}, []);
|
||||
|
||||
async function fetchAgents() {
|
||||
try {
|
||||
const response = await fetch('/api/agents');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setAgents(data.items || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch agents:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchActivity() {
|
||||
try {
|
||||
const response = await fetch('/api/agent-activity?sort=-created&perPage=50');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setActivity(data.items || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch activity:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAgentTasks() {
|
||||
try {
|
||||
const response = await fetch('/api/agent-tasks?sort=-created&perPage=50');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setAgentTasks(data.items || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch agent tasks:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function undoActivity(activityId: string) {
|
||||
try {
|
||||
await fetch(`/api/agent-activity/${activityId}/undo`, {
|
||||
method: 'POST',
|
||||
});
|
||||
fetchActivity();
|
||||
} catch (error) {
|
||||
console.error('Failed to undo activity:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function getAgentName(agentId: string): string {
|
||||
const agent = agents.find((a) => a.id === agentId);
|
||||
return agent?.name || 'Unknown Agent';
|
||||
}
|
||||
|
||||
function getStatusIcon(status: string) {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircle2 className="h-4 w-4 text-green-600" />;
|
||||
case 'failed':
|
||||
return <XCircle className="h-4 w-4 text-red-600" />;
|
||||
case 'in_progress':
|
||||
return <Clock className="h-4 w-4 text-blue-600 animate-pulse" />;
|
||||
default:
|
||||
return <Clock className="h-4 w-4 text-muted-foreground" />;
|
||||
}
|
||||
}
|
||||
|
||||
function getActionLabel(action: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
create: 'Created',
|
||||
update: 'Updated',
|
||||
delete: 'Deleted',
|
||||
complete: 'Completed',
|
||||
assign: 'Assigned',
|
||||
};
|
||||
return labels[action] || action;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-muted-foreground">Loading agent activity...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold">Agent Activity</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Every agent action, visible and reversible.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[300px_1fr]">
|
||||
{/* Agents list */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Agents</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{agents.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No agents configured
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{agents.map((agent) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
onClick={() => setSelectedAgent(agent)}
|
||||
aria-label={`View activity for agent: ${agent.name}`}
|
||||
aria-current={selectedAgent?.id === agent.id ? 'true' : undefined}
|
||||
className={`w-full rounded-lg p-3 text-left transition-colors ${
|
||||
selectedAgent?.id === agent.id
|
||||
? 'bg-accent'
|
||||
: 'hover:bg-accent/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback>
|
||||
{agent.name.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate text-sm font-medium">{agent.name}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={agent.status === 'active' ? 'default' : 'secondary'}
|
||||
className="text-xs"
|
||||
>
|
||||
{agent.status}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{agent.permission_tier}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{agent.last_activity_at && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(agent.last_activity_at).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Activity feed */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5" aria-hidden="true" />
|
||||
Activity Feed
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="activity">
|
||||
<TabsList>
|
||||
<TabsTrigger value="activity">Activity</TabsTrigger>
|
||||
<TabsTrigger value="tasks">Tasks</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="activity" className="mt-4">
|
||||
{activity.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
No agent activity yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{activity.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded-lg border p-4 transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback>
|
||||
{getAgentName(item.agent_id).charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{getAgentName(item.agent_id)}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{getActionLabel(item.action)}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{item.entity_type}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{new Date(item.created).toLocaleString()}
|
||||
</p>
|
||||
{item.before_state && item.after_state && (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground" role="button">
|
||||
View changes
|
||||
</summary>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<p className="font-semibold text-red-600">Before</p>
|
||||
<pre className="mt-1 rounded bg-muted p-2 overflow-x-auto">
|
||||
{JSON.stringify(item.before_state, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-green-600">After</p>
|
||||
<pre className="mt-1 rounded bg-muted p-2 overflow-x-auto">
|
||||
{JSON.stringify(item.after_state, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => undoActivity(item.id)}
|
||||
className="shrink-0"
|
||||
aria-label={`Undo activity: ${getActionLabel(item.action)} ${item.entity_type}`}
|
||||
>
|
||||
<RotateCcw className="mr-1 h-3 w-3" aria-hidden="true" />
|
||||
Undo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tasks" className="mt-4">
|
||||
{agentTasks.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
No agent tasks yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{agentTasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{getStatusIcon(task.status)}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{getAgentName(task.agent_id)}
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
task.status === 'completed'
|
||||
? 'default'
|
||||
: task.status === 'failed'
|
||||
? 'destructive'
|
||||
: 'secondary'
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{task.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm">{task.input}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{new Date(task.created).toLocaleString()}
|
||||
</p>
|
||||
{task.output && (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground" role="button">
|
||||
View output
|
||||
</summary>
|
||||
<pre className="mt-2 rounded bg-muted p-2 text-xs overflow-x-auto">
|
||||
{JSON.stringify(task.output, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, Suspense } from 'react';
|
||||
import {
|
||||
TrendingUp,
|
||||
Target,
|
||||
Clock,
|
||||
Flame,
|
||||
BarChart3,
|
||||
PieChart as PieChartIcon,
|
||||
} from 'lucide-react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@/components/ui/tabs';
|
||||
|
||||
// Lazy load recharts (~180KB)
|
||||
const AnalyticsCharts = dynamic(
|
||||
() => import('@/components/analytics/analytics-charts').then((m) => m.AnalyticsCharts),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="h-[350px] animate-pulse rounded-lg border bg-muted/30 p-6">
|
||||
<div className="mb-4 h-5 w-40 rounded bg-muted/50" />
|
||||
<div className="h-full rounded bg-muted/30" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
interface AnalyticsData {
|
||||
taskCompletionRate: number;
|
||||
habitConsistency: number;
|
||||
totalTimeMinutes: number;
|
||||
activeStreaks: number;
|
||||
bestStreak: number;
|
||||
period: number;
|
||||
}
|
||||
|
||||
interface TimeData {
|
||||
date: string;
|
||||
tasks: number;
|
||||
habits: number;
|
||||
time: number;
|
||||
}
|
||||
|
||||
interface DomainData {
|
||||
name: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface HabitData {
|
||||
name: string;
|
||||
streak: number;
|
||||
score: number;
|
||||
consistency: number;
|
||||
}
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const [analytics, setAnalytics] = useState<AnalyticsData | null>(null);
|
||||
const [timeData, setTimeData] = useState<TimeData[]>([]);
|
||||
const [domainData, setDomainData] = useState<DomainData[]>([]);
|
||||
const [habitData, setHabitData] = useState<HabitData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAnalytics();
|
||||
}, []);
|
||||
|
||||
async function fetchAnalytics() {
|
||||
try {
|
||||
// Fetch overall analytics
|
||||
const analyticsResponse = await fetch('/api/analytics?period=30');
|
||||
if (analyticsResponse.ok) {
|
||||
const analyticsData = await analyticsResponse.json();
|
||||
setAnalytics(analyticsData);
|
||||
}
|
||||
|
||||
// Fetch time summary
|
||||
const timeResponse = await fetch('/api/time-summary?period=30');
|
||||
if (timeResponse.ok) {
|
||||
const timeSummary = await timeResponse.json();
|
||||
|
||||
// Transform to domain data for pie chart
|
||||
const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#06b6d4'];
|
||||
const domains: DomainData[] = Object.entries(
|
||||
timeSummary.byDomain || {}
|
||||
).map(([name, value], index) => ({
|
||||
name,
|
||||
value: value as number,
|
||||
color: COLORS[index % COLORS.length],
|
||||
}));
|
||||
setDomainData(domains);
|
||||
}
|
||||
|
||||
// Fetch habit streaks
|
||||
const habitsResponse = await fetch('/api/habits/streaks');
|
||||
if (habitsResponse.ok) {
|
||||
const habitsData = await habitsResponse.json();
|
||||
const habits: HabitData[] = (habitsData.streaks || []).map(
|
||||
(s: {
|
||||
habit: { name: string; score?: number };
|
||||
current_streak: number;
|
||||
best_streak: number;
|
||||
}) => ({
|
||||
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 {
|
||||
date: date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
tasks: Math.floor(Math.random() * 10) + 2,
|
||||
habits: Math.floor(Math.random() * 5) + 1,
|
||||
time: Math.floor(Math.random() * 180) + 30,
|
||||
};
|
||||
});
|
||||
setTimeData(sampleTimeData);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch analytics:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading || !analytics) {
|
||||
return <p className="text-muted-foreground">Loading analytics...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold">Analytics</h1>
|
||||
<p className="mt-1 text-muted-foreground">Patterns behind your progress.</p>
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="mb-6 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Task Completion
|
||||
</CardTitle>
|
||||
<Target className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{analytics.taskCompletionRate}%
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Last {analytics.period} days
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Habit Consistency
|
||||
</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{analytics.habitConsistency}%
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Last {analytics.period} days
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">Time Tracked</CardTitle>
|
||||
<Clock className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{Math.round(analytics.totalTimeMinutes / 60)}h
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Last {analytics.period} days
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Active Streaks
|
||||
</CardTitle>
|
||||
<Flame className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{analytics.activeStreaks}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Best: {analytics.bestStreak} days
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<Tabs defaultValue="trends">
|
||||
<TabsList>
|
||||
<TabsTrigger value="trends">Trends</TabsTrigger>
|
||||
<TabsTrigger value="habits">Habits</TabsTrigger>
|
||||
<TabsTrigger value="time">Time</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="trends" className="mt-6">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="h-[350px] animate-pulse rounded-lg border bg-muted/30 p-6" />
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AnalyticsCharts
|
||||
timeData={timeData}
|
||||
domainData={domainData}
|
||||
habitData={habitData}
|
||||
activeTab="trends"
|
||||
/>
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="habits" className="mt-6">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-[350px] animate-pulse rounded-lg border bg-muted/30 p-6" />
|
||||
}
|
||||
>
|
||||
<AnalyticsCharts
|
||||
timeData={timeData}
|
||||
domainData={domainData}
|
||||
habitData={habitData}
|
||||
activeTab="habits"
|
||||
/>
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="time" className="mt-6">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-[350px] animate-pulse rounded-lg border bg-muted/30 p-6" />
|
||||
}
|
||||
>
|
||||
<AnalyticsCharts
|
||||
timeData={timeData}
|
||||
domainData={domainData}
|
||||
habitData={habitData}
|
||||
activeTab="time"
|
||||
/>
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useMemo, Suspense } from 'react';
|
||||
import { Filter } from 'lucide-react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
// Lazy load react-big-calendar (~60KB + date-fns)
|
||||
const BigCalendar = dynamic(
|
||||
() => import('@/components/calendar/big-calendar-wrapper').then((m) => m.BigCalendarWrapper),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-[600px] items-center justify-center rounded-lg border bg-muted/30">
|
||||
<div className="animate-pulse text-sm text-muted-foreground">Loading calendar...</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
interface CalendarEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
type: 'task' | 'habit' | 'project' | 'milestone';
|
||||
domain: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export default function CalendarPage() {
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showTasks, setShowTasks] = useState(true);
|
||||
const [showHabits, setShowHabits] = useState(true);
|
||||
const [showProjects, setShowProjects] = useState(true);
|
||||
const [showMilestones, setShowMilestones] = useState(true);
|
||||
const [selectedDomains, setSelectedDomains] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEvents();
|
||||
}, []);
|
||||
|
||||
async function fetchEvents() {
|
||||
try {
|
||||
// Fetch tasks with due dates
|
||||
const tasksResponse = await fetch('/api/tasks?filter=due_date!%3D%22%22&perPage=500');
|
||||
const tasksData = tasksResponse.ok ? await tasksResponse.json() : { items: [] };
|
||||
|
||||
// Fetch projects with target dates
|
||||
const projectsResponse = await fetch('/api/projects?filter=target_date!%3D%22%22&perPage=500');
|
||||
const projectsData = projectsResponse.ok ? await projectsResponse.json() : { items: [] };
|
||||
|
||||
// Fetch milestones with target dates
|
||||
const milestonesResponse = await fetch('/api/milestones?filter=target_date!%3D%22%22&perPage=500');
|
||||
const milestonesData = milestonesResponse.ok ? await milestonesResponse.json() : { items: [] };
|
||||
|
||||
const calendarEvents: CalendarEvent[] = [];
|
||||
|
||||
// Add tasks
|
||||
if (tasksData.items) {
|
||||
for (const task of tasksData.items) {
|
||||
if (task.due_date) {
|
||||
const date = new Date(task.due_date);
|
||||
calendarEvents.push({
|
||||
id: `task-${task.id}`,
|
||||
title: task.title,
|
||||
start: date,
|
||||
end: date,
|
||||
type: 'task',
|
||||
domain: task.domain ?? 'personal',
|
||||
color: '#3b82f6', // blue
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add projects
|
||||
if (projectsData.items) {
|
||||
for (const project of projectsData.items) {
|
||||
if (project.target_date) {
|
||||
const date = new Date(project.target_date);
|
||||
calendarEvents.push({
|
||||
id: `project-${project.id}`,
|
||||
title: `📁 ${project.name}`,
|
||||
start: date,
|
||||
end: date,
|
||||
type: 'project',
|
||||
domain: project.domain ?? 'personal',
|
||||
color: '#8b5cf6', // purple
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add milestones
|
||||
if (milestonesData.items) {
|
||||
for (const milestone of milestonesData.items) {
|
||||
if (milestone.target_date) {
|
||||
const date = new Date(milestone.target_date);
|
||||
calendarEvents.push({
|
||||
id: `milestone-${milestone.id}`,
|
||||
title: `🎯 ${milestone.name}`,
|
||||
start: date,
|
||||
end: date,
|
||||
type: 'milestone',
|
||||
domain: milestone.domain ?? 'work',
|
||||
color: '#f59e0b', // amber
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setEvents(calendarEvents);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch calendar events:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const filteredEvents = useMemo(() => {
|
||||
return events.filter((event) => {
|
||||
// Filter by type
|
||||
if (event.type === 'task' && !showTasks) return false;
|
||||
if (event.type === 'habit' && !showHabits) return false;
|
||||
if (event.type === 'project' && !showProjects) return false;
|
||||
if (event.type === 'milestone' && !showMilestones) return false;
|
||||
|
||||
// Filter by domain
|
||||
if (selectedDomains.length > 0 && !selectedDomains.includes(event.domain)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [events, showTasks, showHabits, showProjects, showMilestones, selectedDomains]);
|
||||
|
||||
function toggleDomain(domain: string) {
|
||||
setSelectedDomains((prev) =>
|
||||
prev.includes(domain) ? prev.filter((d) => d !== domain) : [...prev, domain]
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<p className="text-muted-foreground">Loading calendar...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Calendar</h1>
|
||||
<p className="mt-1 text-muted-foreground">Your commitments, in time.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[250px_1fr]">
|
||||
{/* Filters sidebar */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Filter className="h-4 w-4" aria-hidden="true" />
|
||||
Filters
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Entity types */}
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-semibold">Show</h2>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="tasks"
|
||||
checked={showTasks}
|
||||
onCheckedChange={(checked) => setShowTasks(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="tasks" className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded" style={{ backgroundColor: '#3b82f6' }} />
|
||||
Tasks
|
||||
</Label>
|
||||
</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">
|
||||
<Checkbox
|
||||
id="projects"
|
||||
checked={showProjects}
|
||||
onCheckedChange={(checked) => setShowProjects(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="projects" className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded" style={{ backgroundColor: '#8b5cf6' }} />
|
||||
Projects
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="milestones"
|
||||
checked={showMilestones}
|
||||
onCheckedChange={(checked) => setShowMilestones(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="milestones" className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded" style={{ backgroundColor: '#f59e0b' }} />
|
||||
Milestones
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Domains */}
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-semibold">Domains</h2>
|
||||
<div className="space-y-2">
|
||||
{['personal', 'work', 'ots'].map((domain) => (
|
||||
<div key={domain} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={domain}
|
||||
checked={selectedDomains.includes(domain)}
|
||||
onCheckedChange={() => toggleDomain(domain)}
|
||||
/>
|
||||
<Label htmlFor={domain}>
|
||||
<Badge variant="outline">{domain}</Badge>
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{selectedDomains.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedDomains([])}
|
||||
className="text-xs"
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<h2 className="text-sm font-semibold">Legend</h2>
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
<p>• Tasks show on due date</p>
|
||||
<p>• Projects show on deadline</p>
|
||||
<p>• Milestones show on due date</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Calendar */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-[600px] items-center justify-center rounded-lg border bg-muted/30">
|
||||
<div className="animate-pulse text-sm text-muted-foreground">Loading calendar...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<BigCalendar events={filteredEvents} />
|
||||
</Suspense>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
'use client';
|
||||
|
||||
import React, { Suspense } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useDashboardStore } from '@/lib/stores/use-dashboard-store';
|
||||
import { WidgetErrorBoundary } from '@/components/widget-error-boundary';
|
||||
|
||||
// Lazy load react-grid-layout (client-only, ~45KB)
|
||||
const ResponsiveGridLayout = dynamic(
|
||||
() => import('@/components/dashboard/responsive-grid-layout'),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="h-[200px] animate-pulse rounded-lg border bg-muted/30" />
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
// Lazy load individual widgets — each is code-split into its own chunk
|
||||
const TodayTasksWidget = dynamic(
|
||||
() =>
|
||||
import('@/components/dashboard/widgets/today-tasks-widget').then((m) => m.TodayTasksWidget),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <WidgetSkeleton />,
|
||||
}
|
||||
);
|
||||
|
||||
const HabitChecklistWidget = dynamic(
|
||||
() =>
|
||||
import('@/components/dashboard/widgets/habit-checklist-widget').then(
|
||||
(m) => m.HabitChecklistWidget
|
||||
),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <WidgetSkeleton />,
|
||||
}
|
||||
);
|
||||
|
||||
const WeeklyStatsWidget = dynamic(
|
||||
() =>
|
||||
import('@/components/dashboard/widgets/weekly-stats-widget').then((m) => m.WeeklyStatsWidget),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <WidgetSkeleton />,
|
||||
}
|
||||
);
|
||||
|
||||
const ProjectProgressWidget = dynamic(
|
||||
() =>
|
||||
import('@/components/dashboard/widgets/project-progress-widget').then(
|
||||
(m) => m.ProjectProgressWidget
|
||||
),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <WidgetSkeleton />,
|
||||
}
|
||||
);
|
||||
|
||||
const HabitStreaksWidget = dynamic(
|
||||
() =>
|
||||
import('@/components/dashboard/widgets/habit-streaks-widget').then((m) => m.HabitStreaksWidget),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <WidgetSkeleton />,
|
||||
}
|
||||
);
|
||||
|
||||
const CalendarMiniWidget = dynamic(
|
||||
() =>
|
||||
import('@/components/dashboard/widgets/calendar-mini-widget').then((m) => m.CalendarMiniWidget),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <WidgetSkeleton />,
|
||||
}
|
||||
);
|
||||
|
||||
const QuickAddWidget = dynamic(
|
||||
() =>
|
||||
import('@/components/dashboard/widgets/quick-add-widget').then((m) => m.QuickAddWidget),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <WidgetSkeleton />,
|
||||
}
|
||||
);
|
||||
|
||||
const RecentActivityWidget = dynamic(
|
||||
() =>
|
||||
import('@/components/dashboard/widgets/recent-activity-widget').then(
|
||||
(m) => m.RecentActivityWidget
|
||||
),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <WidgetSkeleton />,
|
||||
}
|
||||
);
|
||||
|
||||
function WidgetSkeleton() {
|
||||
return (
|
||||
<div className="h-full animate-pulse rounded-lg border bg-muted/30 p-4">
|
||||
<div className="mb-3 h-4 w-24 rounded bg-muted/50" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-3 w-full rounded bg-muted/50" />
|
||||
<div className="h-3 w-3/4 rounded bg-muted/50" />
|
||||
<div className="h-3 w-1/2 rounded bg-muted/50" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const widgetComponents: Record<string, React.ComponentType> = {
|
||||
'today-tasks': TodayTasksWidget,
|
||||
'habit-checklist': HabitChecklistWidget,
|
||||
'weekly-stats': WeeklyStatsWidget,
|
||||
'project-progress': ProjectProgressWidget,
|
||||
'habit-streaks': HabitStreaksWidget,
|
||||
'calendar-mini': CalendarMiniWidget,
|
||||
'quick-add': QuickAddWidget,
|
||||
'recent-activity': RecentActivityWidget,
|
||||
};
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { widgets, setWidgets } = useDashboardStore();
|
||||
|
||||
const layout = widgets.map((w) => ({
|
||||
i: w.id,
|
||||
x: w.x,
|
||||
y: w.y,
|
||||
w: w.w,
|
||||
h: w.h,
|
||||
}));
|
||||
|
||||
function handleLayoutChange(newLayout: { i: string; x: number; y: number; w: number; h: number }[]) {
|
||||
const updated = widgets.map((w) => {
|
||||
const layoutItem = newLayout.find((l) => l.i === w.id);
|
||||
if (layoutItem) {
|
||||
return {
|
||||
...w,
|
||||
x: layoutItem.x,
|
||||
y: layoutItem.y,
|
||||
w: layoutItem.w,
|
||||
h: layoutItem.h,
|
||||
};
|
||||
}
|
||||
return w;
|
||||
});
|
||||
setWidgets(updated);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||
<p className="mt-1 text-muted-foreground">Your day, at a glance.</p>
|
||||
</div>
|
||||
|
||||
<ResponsiveGridLayout layout={layout} onLayoutChange={handleLayoutChange}>
|
||||
{widgets.map((widget) => {
|
||||
const WidgetComponent = widgetComponents[widget.id];
|
||||
if (!WidgetComponent) return null;
|
||||
|
||||
return (
|
||||
<div key={widget.id}>
|
||||
<WidgetErrorBoundary widgetName={widget.type}>
|
||||
<div className="h-full rounded-lg border bg-card p-4 shadow-sm">
|
||||
<div className="widget-drag-handle">
|
||||
<Suspense fallback={<WidgetSkeleton />}>
|
||||
<WidgetComponent />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</WidgetErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ResponsiveGridLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, Suspense } from 'react';
|
||||
import { Flame, Plus } from 'lucide-react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { HabitCard } from '@/components/habits/habit-card';
|
||||
import { HabitCompletionDialog } from '@/components/habits/habit-completion-dialog';
|
||||
import type { Habit } from '@project-e/shared';
|
||||
|
||||
// Lazy load react-calendar-heatmap (~15KB)
|
||||
const HabitHeatmap = dynamic(
|
||||
() => import('@/components/habits/habit-heatmap').then((m) => m.HabitHeatmap),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="h-[150px] animate-pulse rounded-lg bg-muted/30" />
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
/** Extended habit with server-computed fields */
|
||||
interface HabitWithMeta extends Habit {
|
||||
logged_today: boolean;
|
||||
}
|
||||
|
||||
export default function HabitsPage() {
|
||||
const [habits, setHabits] = useState<HabitWithMeta[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedHabit, setSelectedHabit] = useState<HabitWithMeta | null>(null);
|
||||
const [completionDialogOpen, setCompletionDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHabits();
|
||||
}, []);
|
||||
|
||||
async function fetchHabits() {
|
||||
try {
|
||||
const response = await fetch('/api/habits');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setHabits(data.items || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch habits:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleComplete(habit: HabitWithMeta) {
|
||||
if (habit.completion_mode === 'quick') {
|
||||
logHabitCompletion(habit.id, {});
|
||||
} else {
|
||||
setSelectedHabit(habit);
|
||||
setCompletionDialogOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
async function logHabitCompletion(
|
||||
habitId: string,
|
||||
data: { mood?: number; value?: number; notes?: string }
|
||||
) {
|
||||
try {
|
||||
await fetch(`/api/habits/${habitId}/logs`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
fetchHabits();
|
||||
setCompletionDialogOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to log habit:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const completedCount = habits.filter((h) => h.logged_today).length;
|
||||
const completionRate =
|
||||
habits.length > 0 ? Math.round((completedCount / habits.length) * 100) : 0;
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-muted-foreground">Loading habits...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Habits</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Small actions, visible momentum.
|
||||
</p>
|
||||
</div>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New habit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Summary banner */}
|
||||
<Card className="mb-6">
|
||||
<CardContent className="flex items-center justify-between p-6">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Today's progress</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{completedCount} / {habits.length} habits
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-muted-foreground">Completion rate</p>
|
||||
<p className="text-2xl font-bold">{completionRate}%</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Habit cards grid */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{habits.map((habit) => (
|
||||
<HabitCard
|
||||
key={habit.id}
|
||||
habit={habit}
|
||||
onComplete={() => handleComplete(habit)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Heatmap section */}
|
||||
<Card className="mt-8">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Flame className="h-5 w-5 text-orange-500" aria-hidden="true" />
|
||||
Consistency Overview
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-[150px] animate-pulse rounded-lg bg-muted/30" />
|
||||
}
|
||||
>
|
||||
<HabitHeatmap habits={habits} />
|
||||
</Suspense>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Completion dialog */}
|
||||
{selectedHabit && (
|
||||
<HabitCompletionDialog
|
||||
habit={selectedHabit}
|
||||
open={completionDialogOpen}
|
||||
onOpenChange={setCompletionDialogOpen}
|
||||
onSubmit={(data) => logHabitCompletion(selectedHabit.id, data)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Sidebar } from '@/components/sidebar';
|
||||
import { TopBar } from '@/components/topbar';
|
||||
import { NetworkErrorBanner } from '@/components/network-error-banner';
|
||||
import { KeyboardShortcutsProvider } from '@/components/keyboard-shortcuts-provider';
|
||||
import { WebVitalsTracker } from '@/components/web-vitals-tracker';
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<KeyboardShortcutsProvider>
|
||||
<WebVitalsTracker />
|
||||
<a href="#main-content" className="skip-link">
|
||||
Skip to main content
|
||||
</a>
|
||||
<div className="flex min-h-screen">
|
||||
<NetworkErrorBanner />
|
||||
<Sidebar />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<TopBar />
|
||||
<main id="main-content" className="flex-1 overflow-auto p-6" tabIndex={-1}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sr-only" aria-live="polite" aria-atomic="true" id="a11y-announcer" />
|
||||
</KeyboardShortcutsProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, Suspense } from 'react';
|
||||
import { Plus, FileText, Link2, GitBranch } from 'lucide-react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { DailyNoteButton } from '@/components/notes/daily-note-button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
// Lazy load TipTap editor (~80KB TipTap + extensions)
|
||||
const NoteEditor = dynamic(
|
||||
() => import('@/components/notes/note-editor').then((m) => m.NoteEditor),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="animate-pulse text-sm text-muted-foreground">Loading editor...</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
// Lazy load react-force-graph-2d (~120KB + three.js)
|
||||
const NoteGraph = dynamic(
|
||||
() => import('@/components/notes/note-graph').then((m) => m.NoteGraph),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="animate-pulse text-sm text-muted-foreground">Loading graph...</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
interface Note {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
domain: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
interface Backlink {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function NotesPage() {
|
||||
const [notes, setNotes] = useState<Note[]>([]);
|
||||
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
|
||||
const [backlinks, setBacklinks] = useState<Backlink[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotes();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedNote) {
|
||||
fetchBacklinks(selectedNote.id);
|
||||
}
|
||||
}, [selectedNote]);
|
||||
|
||||
async function fetchNotes() {
|
||||
try {
|
||||
const response = await fetch('/api/notes?sort=-updated');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const notesList = data.items || [];
|
||||
setNotes(notesList);
|
||||
if (notesList.length > 0 && !selectedNote) {
|
||||
setSelectedNote(notesList[0]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch notes:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBacklinks(noteId: string) {
|
||||
try {
|
||||
const response = await fetch(`/api/notes/${noteId}/backlinks`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setBacklinks(data.items || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch backlinks:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function createNote() {
|
||||
try {
|
||||
const response = await fetch('/api/notes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: 'Untitled note',
|
||||
content: '',
|
||||
domain: 'personal',
|
||||
}),
|
||||
});
|
||||
if (response.ok) {
|
||||
const newNote = await response.json();
|
||||
setNotes([newNote, ...notes]);
|
||||
setSelectedNote(newNote);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create note:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDailyNoteReady(raw: Record<string, unknown>) {
|
||||
const note = raw as unknown as Note;
|
||||
// If the note already appears in the list, just select it
|
||||
const exists = notes.find((n) => n.id === note.id);
|
||||
if (exists) {
|
||||
setSelectedNote(exists);
|
||||
return;
|
||||
}
|
||||
// Otherwise prepend it and select
|
||||
setNotes([note, ...notes]);
|
||||
setSelectedNote(note);
|
||||
}
|
||||
|
||||
async function updateNote(noteId: string, updates: Partial<Note>) {
|
||||
try {
|
||||
await fetch(`/api/notes/${noteId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
fetchNotes();
|
||||
} catch (error) {
|
||||
console.error('Failed to update note:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNote(noteId: string) {
|
||||
if (!confirm('Are you sure you want to delete this note?')) return;
|
||||
|
||||
try {
|
||||
await fetch(`/api/notes/${noteId}`, { method: 'DELETE' });
|
||||
const updatedNotes = notes.filter((n) => n.id !== noteId);
|
||||
setNotes(updatedNotes);
|
||||
if (selectedNote?.id === noteId) {
|
||||
setSelectedNote(updatedNotes[0] || null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete note:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-muted-foreground">Loading notes...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Notes</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Connect ideas to the work they shape.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<DailyNoteButton onNoteReady={handleDailyNoteReady} />
|
||||
<Button onClick={createNote}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New note
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[250px_1fr_300px]">
|
||||
{/* Notes list */}
|
||||
<Card className="h-[calc(100vh-200px)]">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-2">
|
||||
{notes.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No notes yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{notes.map((note) => (
|
||||
<button
|
||||
key={note.id}
|
||||
onClick={() => setSelectedNote(note)}
|
||||
aria-label={`Open note: ${note.title}`}
|
||||
aria-current={selectedNote?.id === note.id ? 'true' : undefined}
|
||||
className={`w-full rounded-lg p-3 text-left transition-colors ${
|
||||
selectedNote?.id === note.id
|
||||
? 'bg-accent'
|
||||
: 'hover:bg-accent/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{note.title}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{new Date(note.updated).toLocaleDateString()}
|
||||
</p>
|
||||
<Badge variant="outline" className="mt-1 text-xs">
|
||||
{note.domain}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Card>
|
||||
|
||||
{/* Note editor */}
|
||||
<Card className="h-[calc(100vh-200px)]">
|
||||
{selectedNote ? (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b p-4">
|
||||
<label htmlFor="note-title" className="sr-only">
|
||||
Note title
|
||||
</label>
|
||||
<input
|
||||
id="note-title"
|
||||
type="text"
|
||||
value={selectedNote.title}
|
||||
onChange={(e) =>
|
||||
setSelectedNote({
|
||||
...selectedNote,
|
||||
title: e.target.value,
|
||||
})
|
||||
}
|
||||
onBlur={() =>
|
||||
updateNote(selectedNote.id, {
|
||||
title: selectedNote.title,
|
||||
})
|
||||
}
|
||||
className="w-full text-xl font-semibold outline-none"
|
||||
placeholder="Note title"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="animate-pulse text-sm text-muted-foreground">
|
||||
Loading editor...
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<NoteEditor
|
||||
content={selectedNote.content}
|
||||
onChange={(content) =>
|
||||
setSelectedNote({ ...selectedNote, content })
|
||||
}
|
||||
onBlur={() =>
|
||||
updateNote(selectedNote.id, {
|
||||
content: selectedNote.content,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground">
|
||||
Select a note or create a new one
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Backlinks and graph */}
|
||||
<Card className="h-[calc(100vh-200px)]">
|
||||
<Tabs defaultValue="backlinks" className="h-full">
|
||||
<div className="border-b p-2">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="backlinks" className="flex-1 gap-2">
|
||||
<Link2 className="h-3 w-3" aria-hidden="true" />
|
||||
Backlinks
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="graph" className="flex-1 gap-2">
|
||||
<GitBranch className="h-3 w-3" aria-hidden="true" />
|
||||
Graph
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="backlinks" className="h-full overflow-auto p-4">
|
||||
{backlinks.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No backlinks
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{backlinks.map((link) => (
|
||||
<button
|
||||
key={link.id}
|
||||
onClick={() => {
|
||||
const note = notes.find((n) => n.id === link.id);
|
||||
if (note) setSelectedNote(note);
|
||||
}}
|
||||
aria-label={`Open linked note: ${link.title}`}
|
||||
className="w-full rounded-lg border p-3 text-left transition-colors hover:bg-accent"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="text-sm font-medium">
|
||||
{link.title}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="graph" className="h-full p-4">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="animate-pulse text-sm text-muted-foreground">
|
||||
Loading graph...
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<NoteGraph notes={notes} />
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { ArrowLeft, Calendar, CheckCircle2, Circle, Flag } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
status: 'active' | 'paused' | 'archived';
|
||||
domain: string;
|
||||
progress: number;
|
||||
task_count: number;
|
||||
completed_count: number;
|
||||
due_date?: string;
|
||||
}
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
status: 'todo' | 'in_progress' | 'done';
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||
due_date?: string;
|
||||
}
|
||||
|
||||
interface Milestone {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
due_date?: string;
|
||||
status: 'planned' | 'in_progress' | 'completed';
|
||||
completed_tasks: number;
|
||||
total_tasks: number;
|
||||
}
|
||||
|
||||
export default function ProjectDetailPage() {
|
||||
const params = useParams();
|
||||
const projectId = params.id as string;
|
||||
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [milestones, setMilestones] = useState<Milestone[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
fetchProject();
|
||||
fetchTasks();
|
||||
fetchMilestones();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [projectId]);
|
||||
|
||||
async function fetchProject() {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setProject(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch project:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTasks() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/tasks?filter=project_id%3D%22${projectId}%22&sort=-created`
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setTasks(data.items || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tasks:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMilestones() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/milestones?filter=project_id%3D%22${projectId}%22&sort=due_date`
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setMilestones(data.items || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch milestones:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleTaskComplete(taskId: string, currentStatus: string) {
|
||||
const newStatus = currentStatus === 'done' ? 'todo' : 'done';
|
||||
try {
|
||||
await fetch(`/api/tasks/${taskId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
});
|
||||
fetchTasks();
|
||||
fetchProject();
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle task:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading || !project) {
|
||||
return <p className="text-muted-foreground">Loading project...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Back button */}
|
||||
<Link href="/projects">
|
||||
<Button variant="ghost" size="sm" className="mb-4">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
Back to projects
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{/* Project header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{project.name}</h1>
|
||||
{project.description && (
|
||||
<p className="mt-1 text-muted-foreground">{project.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
project.status === 'active'
|
||||
? 'default'
|
||||
: project.status === 'paused'
|
||||
? 'secondary'
|
||||
: 'outline'
|
||||
}
|
||||
>
|
||||
{project.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Project stats */}
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Progress</p>
|
||||
<p className="text-2xl font-bold">{project.progress}%</p>
|
||||
</div>
|
||||
<CheckCircle2 className="h-8 w-8 text-green-600" aria-hidden="true" />
|
||||
</div>
|
||||
<Progress value={project.progress} className="mt-2 h-2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Tasks</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{project.completed_count} / {project.task_count}
|
||||
</p>
|
||||
</div>
|
||||
<Circle className="h-8 w-8 text-blue-600" aria-hidden="true" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Due Date</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{project.due_date
|
||||
? new Date(project.due_date).toLocaleDateString()
|
||||
: 'No date'}
|
||||
</p>
|
||||
</div>
|
||||
<Calendar className="h-8 w-8 text-orange-600" aria-hidden="true" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="tasks">
|
||||
<TabsList>
|
||||
<TabsTrigger value="tasks">Tasks ({tasks.length})</TabsTrigger>
|
||||
<TabsTrigger value="milestones">
|
||||
Milestones ({milestones.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="habits">Habits</TabsTrigger>
|
||||
<TabsTrigger value="notes">Notes</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="tasks" className="mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Project Tasks</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{tasks.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
No tasks yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-11 w-11 shrink-0"
|
||||
onClick={() =>
|
||||
toggleTaskComplete(task.id, task.status)
|
||||
}
|
||||
aria-label={`Mark "${task.title}" as ${task.status === 'done' ? 'incomplete' : 'complete'}`}
|
||||
>
|
||||
{task.status === 'done' ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||
) : (
|
||||
<Circle className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<p
|
||||
className={`text-sm font-medium ${
|
||||
task.status === 'done'
|
||||
? 'text-muted-foreground line-through'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{task.title}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
task.priority === 'urgent'
|
||||
? 'destructive'
|
||||
: task.priority === 'high'
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{task.priority}
|
||||
</Badge>
|
||||
{task.due_date && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(task.due_date).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="milestones" className="mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Milestones</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{milestones.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
No milestones yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{milestones.map((milestone, index) => (
|
||||
<div key={milestone.id} className="relative flex gap-4">
|
||||
{/* Timeline line */}
|
||||
{index < milestones.length - 1 && (
|
||||
<div className="absolute left-5 top-12 h-full w-0.5 bg-border" />
|
||||
)}
|
||||
|
||||
{/* Milestone marker */}
|
||||
<div className="relative z-10 flex h-10 w-10 shrink-0 items-center justify-center rounded-full border-2 bg-background">
|
||||
<Flag
|
||||
className={`h-5 w-5 ${
|
||||
milestone.status === 'completed'
|
||||
? 'text-green-600'
|
||||
: milestone.status === 'in_progress'
|
||||
? 'text-blue-600'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Milestone content */}
|
||||
<div className="flex-1 pb-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold">
|
||||
{milestone.name}
|
||||
</h2>
|
||||
{milestone.description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{milestone.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
milestone.status === 'completed'
|
||||
? 'default'
|
||||
: milestone.status === 'in_progress'
|
||||
? 'secondary'
|
||||
: 'outline'
|
||||
}
|
||||
>
|
||||
{milestone.status}
|
||||
</Badge>
|
||||
</div>
|
||||
{milestone.due_date && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Due:{' '}
|
||||
{new Date(
|
||||
milestone.due_date
|
||||
).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
<div className="mb-1 flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
Tasks
|
||||
</span>
|
||||
<span>
|
||||
{milestone.completed_tasks} /{' '}
|
||||
{milestone.total_tasks}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={
|
||||
milestone.total_tasks > 0
|
||||
? (milestone.completed_tasks /
|
||||
milestone.total_tasks) *
|
||||
100
|
||||
: 0
|
||||
}
|
||||
className="h-1.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="habits" className="mt-6">
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
Habits linked to this project will appear here
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="notes" className="mt-6">
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
Notes linked to this project will appear here
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus, FolderKanban } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
status: 'active' | 'paused' | 'archived';
|
||||
domain: string;
|
||||
progress: number;
|
||||
task_count: number;
|
||||
completed_count: number;
|
||||
due_date?: string;
|
||||
}
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjects();
|
||||
}, []);
|
||||
|
||||
async function fetchProjects() {
|
||||
try {
|
||||
const response = await fetch('/api/projects?sort=-created');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setProjects(data.items || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch projects:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-muted-foreground">Loading projects...</p>;
|
||||
}
|
||||
|
||||
const activeProjects = projects.filter((p) => p.status === 'active');
|
||||
const pausedProjects = projects.filter((p) => p.status === 'paused');
|
||||
const archivedProjects = projects.filter((p) => p.status === 'archived');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Projects</h1>
|
||||
<p className="mt-1 text-muted-foreground">Every outcome has a home.</p>
|
||||
</div>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New project
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Active projects */}
|
||||
{activeProjects.length > 0 && (
|
||||
<section className="mb-8">
|
||||
<h2 className="mb-4 text-lg font-semibold">Active Projects</h2>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{activeProjects.map((project) => (
|
||||
<ProjectCard key={project.id} project={project} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Paused projects */}
|
||||
{pausedProjects.length > 0 && (
|
||||
<section className="mb-8">
|
||||
<h2 className="mb-4 text-lg font-semibold">Paused Projects</h2>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{pausedProjects.map((project) => (
|
||||
<ProjectCard key={project.id} project={project} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Archived projects */}
|
||||
{archivedProjects.length > 0 && (
|
||||
<section>
|
||||
<h2 className="mb-4 text-lg font-semibold">Archived Projects</h2>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{archivedProjects.map((project) => (
|
||||
<ProjectCard key={project.id} project={project} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{projects.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<FolderKanban className="mb-4 h-12 w-12 text-muted-foreground" aria-hidden="true" />
|
||||
<p className="text-lg font-semibold">No projects yet</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Create your first project to get started
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectCard({ project }: { project: Project }) {
|
||||
return (
|
||||
<Link href={`/projects/${project.id}`}>
|
||||
<Card className="h-full transition-shadow hover:shadow-md">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-base">{project.name}</CardTitle>
|
||||
{project.description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">
|
||||
{project.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
project.status === 'active'
|
||||
? 'default'
|
||||
: project.status === 'paused'
|
||||
? 'secondary'
|
||||
: 'outline'
|
||||
}
|
||||
className="ml-2 shrink-0"
|
||||
>
|
||||
{project.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* Progress */}
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">Progress</span>
|
||||
<span className="font-semibold">{project.progress}%</span>
|
||||
</div>
|
||||
<Progress value={project.progress} className="h-2" />
|
||||
</div>
|
||||
|
||||
{/* Task count */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Tasks</span>
|
||||
<span className="font-semibold">
|
||||
{project.completed_count} / {project.task_count}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Domain and due date */}
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<Badge variant="outline">{project.domain}</Badge>
|
||||
{project.due_date && (
|
||||
<span className="text-muted-foreground">
|
||||
Due: {new Date(project.due_date).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, Suspense } from 'react';
|
||||
import { Plus, FileBarChart, Calendar, Target, TrendingUp, Clock } from 'lucide-react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
// Lazy load TipTap report editor (~80KB)
|
||||
const ReportEditor = dynamic(
|
||||
() => import('@/components/reports/report-editor').then((m) => m.ReportEditor),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="animate-pulse text-sm text-muted-foreground">Loading editor...</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
// Lazy load report templates
|
||||
const ReportTemplates = dynamic(
|
||||
() => import('@/components/reports/report-templates').then((m) => m.ReportTemplates),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="animate-pulse text-sm text-muted-foreground">Loading templates...</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
interface Report {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
report_type: 'weekly' | 'monthly' | 'project' | 'habit' | 'custom';
|
||||
date_range_start?: string;
|
||||
date_range_end?: string;
|
||||
domain: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export default function ReportsPage() {
|
||||
const [reports, setReports] = useState<Report[]>([]);
|
||||
const [selectedReport, setSelectedReport] = useState<Report | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showTemplates, setShowTemplates] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReports();
|
||||
}, []);
|
||||
|
||||
async function fetchReports() {
|
||||
try {
|
||||
const response = await fetch('/api/reports?sort=-created');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const reportsList = data.items || [];
|
||||
setReports(reportsList);
|
||||
if (reportsList.length > 0 && !selectedReport) {
|
||||
setSelectedReport(reportsList[0]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch reports:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function createReport(overrides?: Partial<Report>) {
|
||||
try {
|
||||
const response = await fetch('/api/reports', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: 'Untitled report',
|
||||
content: '',
|
||||
report_type: 'custom',
|
||||
domain: 'personal',
|
||||
...overrides,
|
||||
}),
|
||||
});
|
||||
if (response.ok) {
|
||||
const newReport = await response.json();
|
||||
setReports([newReport, ...reports]);
|
||||
setSelectedReport(newReport);
|
||||
setShowTemplates(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create report:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateReport(reportId: string, updates: Partial<Report>) {
|
||||
try {
|
||||
await fetch(`/api/reports/${reportId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
fetchReports();
|
||||
} catch (error) {
|
||||
console.error('Failed to update report:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteReport(reportId: string) {
|
||||
if (!confirm('Are you sure you want to delete this report?')) return;
|
||||
|
||||
try {
|
||||
await fetch(`/api/reports/${reportId}`, { method: 'DELETE' });
|
||||
const updatedReports = reports.filter((r) => r.id !== reportId);
|
||||
setReports(updatedReports);
|
||||
if (selectedReport?.id === reportId) {
|
||||
setSelectedReport(updatedReports[0] || null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete report:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function getReportTypeIcon(type: string) {
|
||||
switch (type) {
|
||||
case 'weekly':
|
||||
return <Calendar className="h-4 w-4" />;
|
||||
case 'monthly':
|
||||
return <Calendar className="h-4 w-4" />;
|
||||
case 'project':
|
||||
return <Target className="h-4 w-4" />;
|
||||
case 'habit':
|
||||
return <TrendingUp className="h-4 w-4" />;
|
||||
case 'custom':
|
||||
return <FileBarChart className="h-4 w-4" />;
|
||||
default:
|
||||
return <FileBarChart className="h-4 w-4" />;
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-muted-foreground">Loading reports...</p>;
|
||||
}
|
||||
|
||||
if (showTemplates) {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="animate-pulse text-sm text-muted-foreground">Loading templates...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ReportTemplates
|
||||
onSelect={(template) => {
|
||||
createReport({
|
||||
title: template.name,
|
||||
report_type: template.type,
|
||||
content: template.content,
|
||||
});
|
||||
}}
|
||||
onCancel={() => setShowTemplates(false)}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Reports</h1>
|
||||
<p className="mt-1 text-muted-foreground">Step back and see what changed.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setShowTemplates(true)}>
|
||||
From template
|
||||
</Button>
|
||||
<Button onClick={() => createReport()}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New report
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[300px_1fr]">
|
||||
{/* Reports list */}
|
||||
<Card className="h-[calc(100vh-200px)] overflow-auto">
|
||||
<div className="p-2">
|
||||
{reports.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No reports yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{reports.map((report) => (
|
||||
<button
|
||||
key={report.id}
|
||||
onClick={() => setSelectedReport(report)}
|
||||
aria-label={`Open report: ${report.title}`}
|
||||
aria-current={selectedReport?.id === report.id ? 'true' : undefined}
|
||||
className={`w-full rounded-lg p-3 text-left transition-colors ${
|
||||
selectedReport?.id === report.id
|
||||
? 'bg-accent'
|
||||
: 'hover:bg-accent/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="mt-0.5 text-muted-foreground" aria-hidden="true">
|
||||
{getReportTypeIcon(report.report_type)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate text-sm font-medium">{report.title}</p>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{new Date(report.updated).toLocaleDateString()}
|
||||
</p>
|
||||
<div className="mt-1 flex gap-1">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{report.report_type}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{report.domain}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Report editor */}
|
||||
<Card className="h-[calc(100vh-200px)]">
|
||||
{selectedReport ? (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b p-4">
|
||||
<label htmlFor="report-title" className="sr-only">
|
||||
Report title
|
||||
</label>
|
||||
<input
|
||||
id="report-title"
|
||||
type="text"
|
||||
value={selectedReport.title}
|
||||
onChange={(e) =>
|
||||
setSelectedReport({ ...selectedReport, title: e.target.value })
|
||||
}
|
||||
onBlur={() =>
|
||||
updateReport(selectedReport.id, { title: selectedReport.title })
|
||||
}
|
||||
className="w-full text-xl font-semibold outline-none"
|
||||
placeholder="Report title"
|
||||
/>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Badge variant="outline">{selectedReport.report_type}</Badge>
|
||||
<Badge variant="outline">{selectedReport.domain}</Badge>
|
||||
{selectedReport.date_range_start && selectedReport.date_range_end && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{new Date(selectedReport.date_range_start).toLocaleDateString()} -{' '}
|
||||
{new Date(selectedReport.date_range_end).toLocaleDateString()}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="animate-pulse text-sm text-muted-foreground">
|
||||
Loading editor...
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ReportEditor
|
||||
content={selectedReport.content}
|
||||
onChange={(content) =>
|
||||
setSelectedReport({ ...selectedReport, content })
|
||||
}
|
||||
onBlur={() =>
|
||||
updateReport(selectedReport.id, { content: selectedReport.content })
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground">Select a report or create a new one</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertTriangle, Trash2 } from 'lucide-react';
|
||||
|
||||
interface ErrorLog {
|
||||
id: string;
|
||||
level: string;
|
||||
source: string;
|
||||
message: string;
|
||||
metadata: Record<string, unknown>;
|
||||
created: string;
|
||||
}
|
||||
|
||||
export default function ErrorLogPage() {
|
||||
const [errors, setErrors] = useState<ErrorLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchErrors();
|
||||
}, []);
|
||||
|
||||
async function fetchErrors() {
|
||||
try {
|
||||
const response = await fetch('/api/error-logs?limit=50');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setErrors(data.items || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch error logs:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearErrors() {
|
||||
try {
|
||||
const response = await fetch('/api/error-logs', { method: 'DELETE' });
|
||||
if (response.ok) {
|
||||
setErrors([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to clear error logs:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Error Log</CardTitle>
|
||||
<CardDescription>Loading...</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Error Log</CardTitle>
|
||||
<CardDescription>
|
||||
Recent errors from the application (auto-purged after 30 days)
|
||||
</CardDescription>
|
||||
</div>
|
||||
{errors.length > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={clearErrors} aria-label="Clear all error logs">
|
||||
<Trash2 className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
Clear all
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{errors.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground py-8">
|
||||
No errors logged
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{errors.map((error) => (
|
||||
<div
|
||||
key={error.id}
|
||||
className="rounded-lg border border-border bg-card p-4 space-y-2"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-destructive" aria-hidden="true" />
|
||||
<span className="text-sm font-medium">{error.level}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{error.source}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(error.created).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm">{error.message}</p>
|
||||
{error.metadata &&
|
||||
Object.keys(error.metadata).length > 0 && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-muted-foreground hover:text-foreground" role="button">
|
||||
Details
|
||||
</summary>
|
||||
<pre className="mt-2 rounded bg-muted p-2 overflow-x-auto">
|
||||
{JSON.stringify(error.metadata, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Palette,
|
||||
Globe,
|
||||
Keyboard,
|
||||
Bot,
|
||||
Webhook,
|
||||
Download,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { SettingsAppearance } from '@/components/settings/settings-appearance';
|
||||
import { SettingsDomains } from '@/components/settings/settings-domains';
|
||||
import { SettingsShortcuts } from '@/components/settings/settings-shortcuts';
|
||||
import { SettingsAgents } from '@/components/settings/settings-agents';
|
||||
import { SettingsWebhooks } from '@/components/settings/settings-webhooks';
|
||||
import { SettingsImportExport } from '@/components/settings/settings-import-export';
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold">Settings</h1>
|
||||
<p className="mt-1 text-muted-foreground">Tune Project E to fit your work.</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="appearance" orientation="vertical" className="flex gap-6">
|
||||
<TabsList className="flex w-[200px] flex-col gap-1 bg-transparent h-auto">
|
||||
<TabsTrigger value="appearance" className="justify-start gap-2">
|
||||
<Palette className="h-4 w-4" aria-hidden="true" />
|
||||
Appearance
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="domains" className="justify-start gap-2">
|
||||
<Globe className="h-4 w-4" aria-hidden="true" />
|
||||
Domains
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="shortcuts" className="justify-start gap-2">
|
||||
<Keyboard className="h-4 w-4" aria-hidden="true" />
|
||||
Keyboard Shortcuts
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="agents" className="justify-start gap-2">
|
||||
<Bot className="h-4 w-4" aria-hidden="true" />
|
||||
Agents & Permissions
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="webhooks" className="justify-start gap-2">
|
||||
<Webhook className="h-4 w-4" aria-hidden="true" />
|
||||
Webhooks
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="import-export" className="justify-start gap-2">
|
||||
<Download className="h-4 w-4" aria-hidden="true" />
|
||||
Import & Export
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="error-log" className="justify-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
|
||||
Error Log
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex-1">
|
||||
<TabsContent value="appearance">
|
||||
<SettingsAppearance />
|
||||
</TabsContent>
|
||||
<TabsContent value="domains">
|
||||
<SettingsDomains />
|
||||
</TabsContent>
|
||||
<TabsContent value="shortcuts">
|
||||
<SettingsShortcuts />
|
||||
</TabsContent>
|
||||
<TabsContent value="agents">
|
||||
<SettingsAgents />
|
||||
</TabsContent>
|
||||
<TabsContent value="webhooks">
|
||||
<SettingsWebhooks />
|
||||
</TabsContent>
|
||||
<TabsContent value="import-export">
|
||||
<SettingsImportExport />
|
||||
</TabsContent>
|
||||
<TabsContent value="error-log">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Error Log</CardTitle>
|
||||
<CardDescription>View recent application errors</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
See the{' '}
|
||||
<a href="/settings/error-log" className="text-primary underline">
|
||||
detailed error log
|
||||
</a>{' '}
|
||||
for more information.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { LayoutGrid, List } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { TasksKanbanView } from '@/components/tasks/tasks-kanban-view';
|
||||
import { TasksListView } from '@/components/tasks/tasks-list-view';
|
||||
|
||||
export default function TasksPage() {
|
||||
const [view, setView] = useState<'kanban' | 'list'>('kanban');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Tasks</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Move work forward without losing the thread.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tabs
|
||||
value={view}
|
||||
onValueChange={(v) => setView(v as 'kanban' | 'list')}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="kanban" className="gap-2">
|
||||
<LayoutGrid className="h-4 w-4" aria-hidden="true" />
|
||||
Board
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="list" className="gap-2">
|
||||
<List className="h-4 w-4" aria-hidden="true" />
|
||||
List
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view === 'kanban' ? <TasksKanbanView /> : <TasksListView />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user