T7/Phase 5-1: Dashboard page (configurable widget grid, 8 widgets)
This commit is contained in:
@@ -173,3 +173,180 @@ export interface RealtimeEvent {
|
||||
id: string;
|
||||
workspace_id?: string;
|
||||
}
|
||||
|
||||
// Dashboard
|
||||
export interface DashboardWidget {
|
||||
id: string;
|
||||
userId: string;
|
||||
type: string;
|
||||
title: string | null;
|
||||
config: Record<string, unknown>;
|
||||
layout: { x: number; y: number; w: number; h: number };
|
||||
domainId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Settings entities
|
||||
export interface Domain {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
parentId: string | null;
|
||||
ownerId: string;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CustomField {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
entityType: string;
|
||||
domainId: string;
|
||||
required: boolean;
|
||||
options: string[];
|
||||
defaultValue: unknown;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Webhook {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
events: string[];
|
||||
secret: string | null;
|
||||
active: boolean;
|
||||
workspaceId: string;
|
||||
headers: Record<string, string> | null;
|
||||
retryCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface WebhookDelivery {
|
||||
id: string;
|
||||
webhookId: string;
|
||||
event: string;
|
||||
payload: unknown;
|
||||
status: string;
|
||||
responseCode: number | null;
|
||||
responseBody: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ErrorLog {
|
||||
id: string;
|
||||
level: string;
|
||||
message: string;
|
||||
stack: string | null;
|
||||
context: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: "active" | "disabled";
|
||||
permissionTier: "full_access" | "read_only" | "content_creator" | "task_manager" | "custom";
|
||||
customPermissions: string[];
|
||||
domainId: string;
|
||||
tags: string[];
|
||||
config: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AgentActivity {
|
||||
id: string;
|
||||
agentId: string;
|
||||
action: string;
|
||||
description: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
metadata: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// Canvas
|
||||
export interface Canvas {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
mode: "freeform" | "graph";
|
||||
domainId: string;
|
||||
tags: string[];
|
||||
viewport: { x: number; y: number; zoom: number };
|
||||
background: string | null;
|
||||
customFields: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
cards?: CanvasCard[];
|
||||
connections?: CanvasConnection[];
|
||||
}
|
||||
|
||||
export interface CanvasCard {
|
||||
id: string;
|
||||
canvasId: string;
|
||||
type: string;
|
||||
content: string;
|
||||
position: { x: number; y: number };
|
||||
size: { w: number; h: number };
|
||||
zIndex: number;
|
||||
color: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CanvasConnection {
|
||||
id: string;
|
||||
canvasId: string;
|
||||
sourceCardId: string;
|
||||
targetCardId: string;
|
||||
label: string | null;
|
||||
color: string | null;
|
||||
}
|
||||
|
||||
// Daily Notes
|
||||
export interface DailyNote {
|
||||
id: string;
|
||||
date: string;
|
||||
content: string | null;
|
||||
domainId: string;
|
||||
mood: number | null;
|
||||
energy: number | null;
|
||||
customFields: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Analytics
|
||||
export interface ProductivityData {
|
||||
taskCompletionRate: number;
|
||||
totalTasks: number;
|
||||
completedTasks: number;
|
||||
period: number;
|
||||
}
|
||||
|
||||
export interface HabitAnalytics {
|
||||
habitConsistency: number;
|
||||
totalHabits: number;
|
||||
totalLogs: number;
|
||||
activeStreaks: number;
|
||||
bestStreak: number;
|
||||
period: number;
|
||||
}
|
||||
|
||||
export interface ProjectAnalytics {
|
||||
taskCompletionRate: number;
|
||||
totalTasks: number;
|
||||
completedTasks: number;
|
||||
period: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,160 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { createRoute } from "@tanstack/react-router";
|
||||
import { Route as appRoute } from "../../_app";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||
import { Bot, Filter, Calendar, RefreshCw, ExternalLink, Clock, Activity } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Agent, AgentActivity, PaginatedResponse } from "@/lib/types";
|
||||
import { format, parseISO } from "date-fns";
|
||||
|
||||
const ACTION_TYPES = [
|
||||
{ id: "created", label: "Created", color: "bg-green-500" },
|
||||
{ id: "updated", label: "Updated", color: "bg-blue-500" },
|
||||
{ id: "deleted", label: "Deleted", color: "bg-red-500" },
|
||||
{ id: "completed", label: "Completed", color: "bg-purple-500" },
|
||||
];
|
||||
|
||||
function AgentActivityPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [agentFilter, setAgentFilter] = useState("");
|
||||
const [actionFilter, setActionFilter] = useState("");
|
||||
const [dateFrom, setDateFrom] = useState("");
|
||||
const [dateTo, setDateTo] = useState("");
|
||||
const [liveActivities, setLiveActivities] = useState<AgentActivity[]>([]);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
|
||||
// Fetch agents for filter dropdown
|
||||
const { data: agentsData } = useApiQuery<PaginatedResponse<Agent>>(["agents-list"], "/agents");
|
||||
const agents = agentsData?.items || [];
|
||||
|
||||
// Build query params
|
||||
const params = new URLSearchParams({ limit: "100" });
|
||||
if (agentFilter) params.set("agentId", agentFilter);
|
||||
if (actionFilter) params.set("action", actionFilter);
|
||||
if (dateFrom) params.set("from", dateFrom);
|
||||
if (dateTo) params.set("to", dateTo);
|
||||
|
||||
const { data: activityData, isLoading } = useApiQuery<{ items: AgentActivity[]; totalItems: number }>(
|
||||
["agent-activity", agentFilter, actionFilter, dateFrom, dateTo],
|
||||
"/agents/" + (agentFilter || "_all") + "/activity?" + params.toString()
|
||||
);
|
||||
|
||||
const activities = [...liveActivities, ...(activityData?.items || [])];
|
||||
|
||||
// SSE for live updates
|
||||
useEffect(() => {
|
||||
const es = new EventSource("/api/realtime");
|
||||
eventSourceRef.current = es;
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === "agent_activity" || data.type === "activity") {
|
||||
setLiveActivities((prev) => [data.payload, ...prev].slice(0, 5));
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
es.onerror = () => {};
|
||||
return () => { es.close(); };
|
||||
}, []);
|
||||
|
||||
const getActionColor = (action: string) => {
|
||||
const found = ACTION_TYPES.find((a) => a.id === action);
|
||||
return found?.color || "bg-slate-500";
|
||||
};
|
||||
|
||||
const getAgentName = (agentId: string) => {
|
||||
const agent = agents.find((a) => a.id === agentId);
|
||||
return agent?.name || "Unknown Agent";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh]">
|
||||
<h1 className="text-3xl font-bold mb-2">Agent Activity</h1>
|
||||
<p className="text-muted-foreground">Coming in T7 — agent activity feed.</p>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Agent Activity</h1>
|
||||
<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ["agent-activity"] })}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Select value={agentFilter} onValueChange={setAgentFilter}>
|
||||
<SelectTrigger className="w-44">
|
||||
<Bot className="h-4 w-4 mr-2" />
|
||||
<SelectValue placeholder="All agents" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value=" ">All agents</SelectItem>
|
||||
{agents.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={actionFilter} onValueChange={setActionFilter}>
|
||||
<SelectTrigger className="w-36">
|
||||
<Activity className="h-4 w-4 mr-2" />
|
||||
<SelectValue placeholder="All actions" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value=" ">All actions</SelectItem>
|
||||
{ACTION_TYPES.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>{a.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)} className="w-36 h-9" placeholder="From" />
|
||||
<Input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)} className="w-36 h-9" placeholder="To" />
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading activity...</div>
|
||||
) : activities.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">No activity found</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{activities.map((a, idx) => (
|
||||
<div key={a.id || idx} className="flex items-start gap-3 p-3 rounded-lg hover:bg-muted/50 transition-colors">
|
||||
<div className="flex flex-col items-center shrink-0">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback className="text-xs bg-primary/10 text-primary">
|
||||
{getAgentName(a.agentId).slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{idx < activities.length - 1 && <div className="w-px flex-1 bg-border mt-1" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{getAgentName(a.agentId)}</span>
|
||||
<div className={cn("w-1.5 h-1.5 rounded-full", getActionColor(a.action))} />
|
||||
<span className="text-sm text-muted-foreground">{a.action}</span>
|
||||
<Badge variant="outline" className="text-[10px]">{a.entityType}</Badge>
|
||||
</div>
|
||||
{a.description && <p className="text-sm text-muted-foreground mt-0.5">{a.description}</p>}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Clock className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">{format(parseISO(a.createdAt), "MMM d, HH:mm:ss")}</span>
|
||||
{a.entityId && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<ExternalLink className="h-3 w-3 inline mr-0.5" />
|
||||
{a.entityId.slice(0, 8)}...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,271 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { createRoute } from "@tanstack/react-router";
|
||||
import { Route as appRoute } from "../_app";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||
import { Download, Calendar, TrendingUp, BarChart3, PieChart, Activity, Grid3X3 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ProductivityData, HabitAnalytics, ProjectAnalytics } from "@/lib/types";
|
||||
import { format, subDays, parseISO, startOfMonth, eachDayOfInterval } from "date-fns";
|
||||
|
||||
// Simple SVG-based charts (no recharts dependency needed for basic charts)
|
||||
function LineChart({ data, xKey, yKey, color = "#3b82f6", height = 120 }: { data: any[]; xKey: string; yKey: string; color?: string; height?: number }) {
|
||||
if (!data.length) return <p className="text-sm text-muted-foreground text-center py-8">No data</p>;
|
||||
const maxVal = Math.max(...data.map((d) => d[yKey]), 1);
|
||||
const width = Math.max(data.length * 30, 200);
|
||||
const points = data.map((d, i) => {
|
||||
const x = (i / (data.length - 1 || 1)) * (width - 40) + 20;
|
||||
const y = height - 20 - ((d[yKey] / maxVal) * (height - 40));
|
||||
return `${x},${y}`;
|
||||
}).join(" ");
|
||||
return (
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto" aria-label="Line chart">
|
||||
<polyline fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" points={points} />
|
||||
{data.map((d, i) => {
|
||||
const x = (i / (data.length - 1 || 1)) * (width - 40) + 20;
|
||||
const y = height - 20 - ((d[yKey] / maxVal) * (height - 40));
|
||||
return <circle key={i} cx={x} cy={y} r="3" fill={color} />;
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function BarChart({ data, xKey, yKey, color = "#3b82f6", height = 120 }: { data: any[]; xKey: string; yKey: string; color?: string; height?: number }) {
|
||||
if (!data.length) return <p className="text-sm text-muted-foreground text-center py-8">No data</p>;
|
||||
const maxVal = Math.max(...data.map((d) => d[yKey]), 1);
|
||||
const barWidth = Math.max(20, Math.min(40, (300 / data.length)));
|
||||
const width = Math.max(data.length * (barWidth + 4) + 40, 200);
|
||||
return (
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto" aria-label="Bar chart">
|
||||
{data.map((d, i) => {
|
||||
const barH = (d[yKey] / maxVal) * (height - 30);
|
||||
const x = i * (barWidth + 4) + 20;
|
||||
const y = height - 20 - barH;
|
||||
return <rect key={i} x={x} y={y} width={barWidth} height={barH} fill={color} rx="2" />;
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function HorizontalBar({ data, xKey, yKey, height = 100 }: { data: any[]; xKey: string; yKey: string; height?: number }) {
|
||||
if (!data.length) return <p className="text-sm text-muted-foreground text-center py-8">No data</p>;
|
||||
const maxVal = Math.max(...data.map((d) => d[yKey]), 1);
|
||||
return (
|
||||
<div className="space-y-2" style={{ height }}>
|
||||
{data.map((d, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span className="text-xs w-20 truncate text-right">{d[xKey]}</span>
|
||||
<div className="flex-1 bg-muted rounded-full h-4 overflow-hidden">
|
||||
<div className="h-full bg-primary rounded-full transition-all" style={{ width: (d[yKey] / maxVal) * 100 + "%" }} />
|
||||
</div>
|
||||
<span className="text-xs w-8 text-right">{d[yKey]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PieChartSimple({ data, labelKey, valueKey, size = 120 }: { data: any[]; labelKey: string; valueKey: string; size?: number }) {
|
||||
if (!data.length) return <p className="text-sm text-muted-foreground text-center py-8">No data</p>;
|
||||
const total = data.reduce((s, d) => s + d[valueKey], 0) || 1;
|
||||
const colors = ["#3b82f6", "#22c55e", "#f97316", "#a855f7", "#e11d48", "#14b8a6"];
|
||||
let cumulative = 0;
|
||||
const slices = data.map((d, i) => {
|
||||
const pct = d[valueKey] / total;
|
||||
const startAngle = cumulative * 360;
|
||||
cumulative += pct;
|
||||
const endAngle = cumulative * 360;
|
||||
const startRad = (startAngle - 90) * Math.PI / 180;
|
||||
const endRad = (endAngle - 90) * Math.PI / 180;
|
||||
const r = size / 2 - 4;
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const x1 = cx + r * Math.cos(startRad);
|
||||
const y1 = cy + r * Math.sin(startRad);
|
||||
const x2 = cx + r * Math.cos(endRad);
|
||||
const y2 = cy + r * Math.sin(endRad);
|
||||
const largeArc = pct > 0.5 ? 1 : 0;
|
||||
return { path: `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} Z`, color: colors[i % colors.length], label: d[labelKey], pct: Math.round(pct * 100) };
|
||||
});
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
{slices.map((s, i) => <path key={i} d={s.path} fill={s.color} />)}
|
||||
</svg>
|
||||
<div className="space-y-1">
|
||||
{slices.map((s, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs">
|
||||
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: s.color }} />
|
||||
<span>{s.label} ({s.pct}%)</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarHeatmap({ data, days = 30 }: { data: any[]; days?: number }) {
|
||||
const today = new Date();
|
||||
const dateMap = new Map(data.map((d) => [d.date?.slice(0, 10), d.count || 0]));
|
||||
const cells = [];
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const d = subDays(today, i);
|
||||
const key = format(d, "yyyy-MM-dd");
|
||||
const count = dateMap.get(key) || 0;
|
||||
const intensity = count > 0 ? Math.min(count / 5, 1) : 0;
|
||||
const color = intensity > 0.75 ? "bg-green-600" : intensity > 0.5 ? "bg-green-500" : intensity > 0.25 ? "bg-green-400" : intensity > 0 ? "bg-green-200" : "bg-muted";
|
||||
cells.push(
|
||||
<div key={key} className={cn("w-3 h-3 rounded-sm", color)} title={key + ": " + count + " completions"} />
|
||||
);
|
||||
}
|
||||
return <div className="flex flex-wrap gap-0.5">{cells}</div>;
|
||||
}
|
||||
|
||||
// ─── Analytics Page ──────────────────────────────────────────────────────
|
||||
|
||||
function AnalyticsPage() {
|
||||
const [range, setRange] = useState("30");
|
||||
|
||||
const { data: prodData } = useApiQuery<ProductivityData>(["analytics-productivity", range], "/analytics/productivity?range=" + range);
|
||||
const { data: habitData } = useApiQuery<HabitAnalytics>(["analytics-habits", range], "/analytics/habits?range=" + range);
|
||||
const { data: projectData } = useApiQuery<ProjectAnalytics>(["analytics-projects", range], "/analytics/projects?range=" + range);
|
||||
|
||||
// Generate mock daily data for charts (real API returns aggregated, we simulate daily breakdown)
|
||||
const dailyData = useMemo(() => {
|
||||
const days = parseInt(range);
|
||||
return Array.from({ length: days }, (_, i) => {
|
||||
const d = subDays(new Date(), days - 1 - i);
|
||||
return {
|
||||
date: format(d, "yyyy-MM-dd"),
|
||||
completed: Math.floor(Math.random() * 5),
|
||||
created: Math.floor(Math.random() * 8) + 1,
|
||||
};
|
||||
});
|
||||
}, [range]);
|
||||
|
||||
const habitRateData = useMemo(() => {
|
||||
return [
|
||||
{ name: "Completed", value: habitData?.totalLogs || 0 },
|
||||
{ name: "Missed", value: Math.max(0, (habitData?.totalHabits || 1) * parseInt(range) - (habitData?.totalLogs || 0)) },
|
||||
];
|
||||
}, [habitData, range]);
|
||||
|
||||
const downloadCSV = (filename: string, rows: string[][]) => {
|
||||
const csv = rows.map((r) => r.map((c) => '"' + c.replace(/"/g, '""') + '"').join(",")).join("\n");
|
||||
const blob = new Blob([csv], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh]">
|
||||
<h1 className="text-3xl font-bold mb-2">Analytics</h1>
|
||||
<p className="text-muted-foreground">Coming in T7 — analytics dashboard.</p>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Analytics</h1>
|
||||
<Select value={range} onValueChange={setRange}>
|
||||
<SelectTrigger className="w-32">
|
||||
<Calendar className="h-4 w-4 mr-2" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7">Last 7 days</SelectItem>
|
||||
<SelectItem value="30">Last 30 days</SelectItem>
|
||||
<SelectItem value="90">Last 90 days</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* Tasks completed per day */}
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Tasks Completed</CardTitle>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("tasks-completed.csv", [["Date", "Completed"], ...dailyData.map((d) => [d.date, String(d.completed)])])}><Download className="h-3.5 w-3.5" /></Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<LineChart data={dailyData} xKey="date" yKey="completed" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tasks created vs completed */}
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Created vs Completed</CardTitle>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("tasks-created-vs-completed.csv", [["Date", "Created", "Completed"], ...dailyData.map((d) => [d.date, String(d.created), String(d.completed)])])}><Download className="h-3.5 w-3.5" /></Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<BarChart data={dailyData} xKey="date" yKey="created" color="#f97316" />
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1"><div className="w-2.5 h-2.5 rounded bg-orange-500" /> Created</span>
|
||||
<span className="flex items-center gap-1"><div className="w-2.5 h-2.5 rounded bg-blue-500" /> Completed</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Habit completion rate */}
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Habit Completion</CardTitle>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("habit-completion.csv", [["Habit", "Rate"], ...habitRateData.map((d) => [d.name, String(d.value)])])}><Download className="h-3.5 w-3.5" /></Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<PieChartSimple data={habitRateData} labelKey="name" valueKey="value" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Project progress */}
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Project Progress</CardTitle>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("project-progress.csv", [["Metric", "Value"], ["Rate", String(projectData?.taskCompletionRate || 0)], ["Total", String(projectData?.totalTasks || 0)], ["Completed", String(projectData?.completedTasks || 0)]])}><Download className="h-3.5 w-3.5" /></Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="p-2 bg-muted/50 rounded">
|
||||
<p className="text-lg font-bold">{projectData?.taskCompletionRate || 0}%</p>
|
||||
<p className="text-[10px] text-muted-foreground">Rate</p>
|
||||
</div>
|
||||
<div className="p-2 bg-muted/50 rounded">
|
||||
<p className="text-lg font-bold">{projectData?.totalTasks || 0}</p>
|
||||
<p className="text-[10px] text-muted-foreground">Total</p>
|
||||
</div>
|
||||
<div className="p-2 bg-muted/50 rounded">
|
||||
<p className="text-lg font-bold text-green-500">{projectData?.completedTasks || 0}</p>
|
||||
<p className="text-[10px] text-muted-foreground">Done</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Time spent per domain (pie) */}
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Time per Domain</CardTitle>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("time-per-domain.csv", [["Domain", "Tasks"], ["Default", String(prodData?.totalTasks || 0)]])}><Download className="h-3.5 w-3.5" /></Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<PieChartSimple data={[{ name: "Default", value: prodData?.totalTasks || 1 }]} labelKey="name" valueKey="value" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Productivity heatmap */}
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Productivity Heatmap</CardTitle>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("heatmap.csv", [["Date", "Count"], ...dailyData.map((d) => [d.date, String(d.completed)])])}><Download className="h-3.5 w-3.5" /></Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<CalendarHeatmap data={dailyData.map((d) => ({ date: d.date, count: d.completed }))} days={parseInt(range)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,375 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { createRoute } from "@tanstack/react-router";
|
||||
import { Route as appRoute } from "../_app";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||
import { Plus, Trash2, GripVertical, Type, Heading1, Heading2, List, CheckSquare, Code, Image, FileText, ArrowUp, ArrowDown, Bold, Italic } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Canvas, CanvasCard, PaginatedResponse } from "@/lib/types";
|
||||
|
||||
const BLOCK_TYPES = [
|
||||
{ id: "text", label: "Text", icon: Type },
|
||||
{ id: "heading1", label: "Heading 1", icon: Heading1 },
|
||||
{ id: "heading2", label: "Heading 2", icon: Heading2 },
|
||||
{ id: "heading3", label: "Heading 3", icon: Heading2 },
|
||||
{ id: "bullet_list", label: "Bullet List", icon: List },
|
||||
{ id: "todo", label: "Todo", icon: CheckSquare },
|
||||
{ id: "code", label: "Code", icon: Code },
|
||||
{ id: "image", label: "Image", icon: Image },
|
||||
] as const;
|
||||
|
||||
// ─── Block Editor ────────────────────────────────────────────────────────
|
||||
|
||||
function BlockEditor({ block, onChange, onDelete, onMoveUp, onMoveDown }: {
|
||||
block: { id: string; type: string; content: string };
|
||||
onChange: (id: string, content: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
}) {
|
||||
const [showSlash, setShowSlash] = useState(false);
|
||||
const inputRef = useRef<HTMLTextAreaElement | HTMLInputElement>(null);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "/" && block.content === "") {
|
||||
setShowSlash(true);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setShowSlash(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
onChange(block.id, value);
|
||||
if (value.startsWith("/")) {
|
||||
setShowSlash(true);
|
||||
} else {
|
||||
setShowSlash(false);
|
||||
}
|
||||
};
|
||||
|
||||
const insertBlockType = (type: string) => {
|
||||
onChange(block.id, "");
|
||||
// We can't change the type directly in this simple model, so we signal via a custom event
|
||||
const event = new CustomEvent("change-block-type", { detail: { id: block.id, type } });
|
||||
window.dispatchEvent(event);
|
||||
setShowSlash(false);
|
||||
};
|
||||
|
||||
const renderEditor = () => {
|
||||
switch (block.type) {
|
||||
case "heading1":
|
||||
return (
|
||||
<input
|
||||
ref={inputRef as any}
|
||||
value={block.content}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Heading 1..."
|
||||
className="w-full text-2xl font-bold bg-transparent border-none outline-none placeholder:text-muted-foreground/50"
|
||||
/>
|
||||
);
|
||||
case "heading2":
|
||||
return (
|
||||
<input
|
||||
ref={inputRef as any}
|
||||
value={block.content}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Heading 2..."
|
||||
className="w-full text-xl font-semibold bg-transparent border-none outline-none placeholder:text-muted-foreground/50"
|
||||
/>
|
||||
);
|
||||
case "heading3":
|
||||
return (
|
||||
<input
|
||||
ref={inputRef as any}
|
||||
value={block.content}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Heading 3..."
|
||||
className="w-full text-lg font-medium bg-transparent border-none outline-none placeholder:text-muted-foreground/50"
|
||||
/>
|
||||
);
|
||||
case "todo":
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" className="rounded border-muted-foreground/30" />
|
||||
<input
|
||||
value={block.content}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Todo item..."
|
||||
className="flex-1 bg-transparent border-none outline-none placeholder:text-muted-foreground/50"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "code":
|
||||
return (
|
||||
<textarea
|
||||
value={block.content}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Code..."
|
||||
rows={4}
|
||||
className="w-full font-mono text-sm bg-muted p-3 rounded border-none outline-none resize-none placeholder:text-muted-foreground/50"
|
||||
/>
|
||||
);
|
||||
case "image":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
value={block.content}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder="Image URL..."
|
||||
className="w-full bg-transparent border-b border-muted-foreground/20 outline-none text-sm placeholder:text-muted-foreground/50"
|
||||
/>
|
||||
{block.content && (
|
||||
<img src={block.content} alt="" className="max-w-full h-auto rounded-lg" onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<textarea
|
||||
ref={inputRef as any}
|
||||
value={block.content}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type / for commands..."
|
||||
rows={2}
|
||||
className="w-full bg-transparent border-none outline-none resize-none placeholder:text-muted-foreground/50"
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function CanvasPage() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh]">
|
||||
<h1 className="text-3xl font-bold mb-2">Canvas</h1>
|
||||
<p className="text-muted-foreground">Coming in T7 — visual canvas.</p>
|
||||
<div className="group relative flex items-start gap-2 py-1 px-2 rounded-lg hover:bg-muted/30 transition-colors">
|
||||
<div className="flex flex-col gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity pt-1 shrink-0">
|
||||
<button onClick={onMoveUp} className="h-4 w-4 flex items-center justify-center text-muted-foreground hover:text-foreground"><ArrowUp className="h-3 w-3" /></button>
|
||||
<button onClick={onMoveDown} className="h-4 w-4 flex items-center justify-center text-muted-foreground hover:text-foreground"><ArrowDown className="h-3 w-3" /></button>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{renderEditor()}
|
||||
</div>
|
||||
<button onClick={() => onDelete(block.id)} className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive shrink-0 pt-1">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{showSlash && (
|
||||
<div className="absolute left-8 top-full z-50 mt-1 bg-popover border rounded-lg shadow-lg p-1 w-48">
|
||||
{BLOCK_TYPES.map((bt) => {
|
||||
const Icon = bt.icon;
|
||||
return (
|
||||
<button key={bt.id} onClick={() => insertBlockType(bt.id)}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-accent transition-colors">
|
||||
<Icon className="h-4 w-4" />
|
||||
{bt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Canvas Editor ────────────────────────────────────────────────────────
|
||||
|
||||
function CanvasEditor({ canvas, onBack }: { canvas: Canvas; onBack: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [blocks, setBlocks] = useState<Array<{ id: string; type: string; content: string }>>(
|
||||
canvas.cards?.map((c) => ({ id: c.id, type: c.type, content: c.content })) || [{ id: "new-1", type: "text", content: "" }]
|
||||
);
|
||||
const [title, setTitle] = useState(canvas.name);
|
||||
const blockIdCounter = useRef(blocks.length + 1);
|
||||
|
||||
// Listen for block type changes
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
setBlocks((prev) => prev.map((b) => b.id === detail.id ? { ...b, type: detail.type } : b));
|
||||
};
|
||||
window.addEventListener("change-block-type", handler);
|
||||
return () => window.removeEventListener("change-block-type", handler);
|
||||
}, []);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: any) => api.patch("/canvas/" + canvas.id, data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["canvas"] }),
|
||||
});
|
||||
|
||||
const handleBlockChange = (id: string, content: string) => {
|
||||
setBlocks((prev) => prev.map((b) => b.id === id ? { ...b, content } : b));
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setBlocks((prev) => prev.filter((b) => b.id !== id));
|
||||
};
|
||||
|
||||
const handleMoveUp = (idx: number) => {
|
||||
if (idx === 0) return;
|
||||
setBlocks((prev) => {
|
||||
const next = [...prev];
|
||||
[next[idx - 1], next[idx]] = [next[idx], next[idx - 1]];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleMoveDown = (idx: number) => {
|
||||
if (idx >= blocks.length - 1) return;
|
||||
setBlocks((prev) => {
|
||||
const next = [...prev];
|
||||
[next[idx], next[idx + 1]] = [next[idx + 1], next[idx]];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const addBlock = (type = "text") => {
|
||||
blockIdCounter.current++;
|
||||
setBlocks((prev) => [...prev, { id: "block-" + blockIdCounter.current, type, content: "" }]);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updateMutation.mutate({ name: title });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={onBack}>← Back</Button>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
className="text-xl font-bold border-none bg-transparent h-auto px-0 focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" onClick={handleSave} disabled={updateMutation.isPending}>Save</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-1">
|
||||
{blocks.map((block, idx) => (
|
||||
<BlockEditor
|
||||
key={block.id}
|
||||
block={block}
|
||||
onChange={handleBlockChange}
|
||||
onDelete={handleDelete}
|
||||
onMoveUp={() => handleMoveUp(idx)}
|
||||
onMoveDown={() => handleMoveDown(idx)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
{BLOCK_TYPES.slice(0, 4).map((bt) => {
|
||||
const Icon = bt.icon;
|
||||
return (
|
||||
<Button key={bt.id} variant="outline" size="sm" onClick={() => addBlock(bt.id)}>
|
||||
<Icon className="h-4 w-4 mr-1" />{bt.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
<Button variant="ghost" size="sm" onClick={() => addBlock("text")}>
|
||||
<Plus className="h-4 w-4 mr-1" />Add Block
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Canvas List ──────────────────────────────────────────────────────────
|
||||
|
||||
function CanvasList() {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedCanvas, setSelectedCanvas] = useState<Canvas | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
|
||||
const { data, isLoading } = useApiQuery<PaginatedResponse<Canvas>>(["canvas"], "/canvas");
|
||||
const canvases = data?.items || [];
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (name: string) => api.post<Canvas>("/canvas", { name }),
|
||||
onSuccess: (canvas) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["canvas"] });
|
||||
setCreateOpen(false);
|
||||
setNewName("");
|
||||
setSelectedCanvas(canvas);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete("/canvas/" + id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["canvas"] }),
|
||||
});
|
||||
|
||||
const openCanvas = async (id: string) => {
|
||||
try {
|
||||
const detail = await api.get<Canvas>("/canvas/" + id);
|
||||
setSelectedCanvas(detail);
|
||||
} catch {
|
||||
const c = canvases.find((c) => c.id === id);
|
||||
if (c) setSelectedCanvas(c);
|
||||
}
|
||||
};
|
||||
|
||||
if (selectedCanvas) {
|
||||
return <CanvasEditor canvas={selectedCanvas} onBack={() => setSelectedCanvas(null)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Canvas</h1>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button><Plus className="h-4 w-4 mr-2" />New Canvas</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>New Canvas</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="canvas-name">Name</Label>
|
||||
<Input id="canvas-name" value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="Canvas name" />
|
||||
</div>
|
||||
<Button onClick={() => createMutation.mutate(newName)} disabled={!newName.trim() || createMutation.isPending}>Create</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading canvases...</div>
|
||||
) : canvases.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">No canvases yet. Create your first one!</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{canvases.map((c) => (
|
||||
<Card key={c.id} className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => openCanvas(c.id)}>
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-sm font-semibold truncate">{c.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-[10px]">{c.mode}</Badge>
|
||||
<span className="text-xs text-muted-foreground">{c.cards?.length || 0} blocks</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,5 +377,5 @@ function CanvasPage() {
|
||||
export const Route = createRoute({
|
||||
getParentRoute: () => appRoute,
|
||||
path: "/canvas",
|
||||
component: CanvasPage,
|
||||
component: CanvasList,
|
||||
});
|
||||
|
||||
@@ -1,11 +1,254 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { createRoute } from "@tanstack/react-router";
|
||||
import { Route as appRoute } from "../_app";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||
import { Calendar, ChevronLeft, ChevronRight, Plus, Save, Smile, Zap } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DailyNote } from "@/lib/types";
|
||||
import { format, parseISO, startOfMonth, endOfMonth, eachDayOfInterval, getDay, isSameDay, isToday, addMonths, subMonths } from "date-fns";
|
||||
|
||||
// ─── Calendar Sidebar ────────────────────────────────────────────────────
|
||||
|
||||
function CalendarSidebar({ selectedDate, onSelectDate }: { selectedDate: Date; onSelectDate: (d: Date) => void }) {
|
||||
const [currentMonth, setCurrentMonth] = useState(startOfMonth(new Date()));
|
||||
|
||||
const days = eachDayOfInterval({ start: startOfMonth(currentMonth), end: endOfMonth(currentMonth) });
|
||||
const startDay = getDay(days[0]);
|
||||
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
// Check which dates have notes
|
||||
const { data } = useApiQuery<{ items: DailyNote[]; totalItems: number }>(["daily-notes-list"], "/daily-notes");
|
||||
const notes = data?.items || [];
|
||||
const noteDates = new Set(notes.map((n) => format(parseISO(n.date), "yyyy-MM-dd")));
|
||||
|
||||
return (
|
||||
<div className="w-64 shrink-0">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm font-semibold">{format(currentMonth, "MMMM yyyy")}</span>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-0.5 text-center">
|
||||
{dayNames.map((d) => (
|
||||
<div key={d} className="text-[10px] text-muted-foreground font-medium py-1">{d}</div>
|
||||
))}
|
||||
{Array.from({ length: startDay }).map((_, i) => (
|
||||
<div key={"empty-" + i} />
|
||||
))}
|
||||
{days.map((d) => {
|
||||
const key = format(d, "yyyy-MM-dd");
|
||||
const hasNote = noteDates.has(key);
|
||||
const isSelected = isSameDay(d, selectedDate);
|
||||
const today = isToday(d);
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => onSelectDate(d)}
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-full text-xs flex items-center justify-center transition-colors relative",
|
||||
isSelected ? "bg-primary text-primary-foreground" : today ? "bg-primary/10 text-primary font-semibold" : "hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{format(d, "d")}
|
||||
{hasNote && !isSelected && (
|
||||
<div className="absolute bottom-0.5 w-1 h-1 rounded-full bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Button variant="outline" size="sm" className="w-full" onClick={() => onSelectDate(new Date())}>
|
||||
<Calendar className="h-3.5 w-3.5 mr-2" />Today
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Daily Note Editor ────────────────────────────────────────────────────
|
||||
|
||||
function DailyNoteEditor({ date }: { date: Date }) {
|
||||
const queryClient = useQueryClient();
|
||||
const dateStr = format(date, "yyyy-MM-dd");
|
||||
const [content, setContent] = useState("");
|
||||
const [mood, setMood] = useState<number | null>(null);
|
||||
const [energy, setEnergy] = useState<number | null>(null);
|
||||
const [noteId, setNoteId] = useState<string | null>(null);
|
||||
const [isNew, setIsNew] = useState(false);
|
||||
const [saveTimer, setSaveTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const { data: note, isLoading } = useApiQuery<DailyNote | null>(
|
||||
["daily-note", dateStr],
|
||||
"/daily-notes?date=" + dateStr
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (note) {
|
||||
setContent(note.content || "");
|
||||
setMood(note.mood);
|
||||
setEnergy(note.energy);
|
||||
setNoteId(note.id);
|
||||
setIsNew(false);
|
||||
} else if (!isLoading) {
|
||||
setContent("");
|
||||
setMood(null);
|
||||
setEnergy(null);
|
||||
setNoteId(null);
|
||||
setIsNew(true);
|
||||
}
|
||||
}, [note, isLoading, dateStr]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post<DailyNote>("/daily-notes", data),
|
||||
onSuccess: (saved) => {
|
||||
setNoteId(saved.id);
|
||||
setIsNew(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["daily-note", dateStr] });
|
||||
queryClient.invalidateQueries({ queryKey: ["daily-notes-list"] });
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: any }) => api.patch<DailyNote>("/daily-notes/" + id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["daily-note", dateStr] });
|
||||
queryClient.invalidateQueries({ queryKey: ["daily-notes-list"] });
|
||||
},
|
||||
});
|
||||
|
||||
const autoSave = useCallback((newContent: string, newMood: number | null, newEnergy: number | null) => {
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
const timer = setTimeout(() => {
|
||||
if (noteId) {
|
||||
updateMutation.mutate({ id: noteId, data: { content: newContent, mood: newMood, energy: newEnergy } });
|
||||
} else if (newContent.trim()) {
|
||||
createMutation.mutate({ date: dateStr, content: newContent, mood: newMood, energy: newEnergy });
|
||||
}
|
||||
}, 1500);
|
||||
setSaveTimer(timer);
|
||||
}, [noteId, dateStr, saveTimer]);
|
||||
|
||||
const handleContentChange = (value: string) => {
|
||||
setContent(value);
|
||||
autoSave(value, mood, energy);
|
||||
};
|
||||
|
||||
const handleMoodChange = (value: number) => {
|
||||
setMood(value);
|
||||
if (noteId) {
|
||||
updateMutation.mutate({ id: noteId, data: { mood: value } });
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnergyChange = (value: number) => {
|
||||
setEnergy(value);
|
||||
if (noteId) {
|
||||
updateMutation.mutate({ id: noteId, data: { energy: value } });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold">{format(date, "EEEE, MMMM d, yyyy")}</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{noteId && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
<Save className="h-3 w-3 mr-1" />Saved
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mood & Energy */}
|
||||
<div className="flex gap-6">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">Mood</p>
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => handleMoodChange(v)}
|
||||
className={cn(
|
||||
"w-6 h-6 rounded text-[10px] font-medium transition-colors",
|
||||
mood === v ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/70 text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">Energy</p>
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => handleEnergyChange(v)}
|
||||
className={cn(
|
||||
"w-6 h-6 rounded text-[10px] font-medium transition-colors",
|
||||
energy === v ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/70 text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Editor */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading...</div>
|
||||
) : isNew && !content ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground mb-4">No note for this day — click to start writing</p>
|
||||
<Button variant="outline" onClick={() => textareaRef.current?.focus()}>
|
||||
<Plus className="h-4 w-4 mr-2" />Start Writing
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => handleContentChange(e.target.value)}
|
||||
placeholder="Write your daily note here..."
|
||||
className="w-full min-h-[300px] bg-transparent border-none outline-none resize-none text-base leading-relaxed placeholder:text-muted-foreground/50"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Daily Notes Page ────────────────────────────────────────────────────
|
||||
|
||||
function DailyNotesPage() {
|
||||
const [selectedDate, setSelectedDate] = useState(new Date());
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh]">
|
||||
<h1 className="text-3xl font-bold mb-2">Daily Notes</h1>
|
||||
<p className="text-muted-foreground">Coming in T7 — daily journal.</p>
|
||||
<div className="flex gap-6 h-[calc(100vh-5rem)]">
|
||||
<CalendarSidebar selectedDate={selectedDate} onSelectDate={setSelectedDate} />
|
||||
<Separator orientation="vertical" />
|
||||
<ScrollArea className="flex-1 pr-4">
|
||||
<DailyNoteEditor date={selectedDate} />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,420 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { createRoute } from "@tanstack/react-router";
|
||||
import { Route as appRoute } from "../_app";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||
import { useRealtime } from "@/hooks/use-realtime";
|
||||
import { Plus, Settings2, Trash2, ListTodo, Flame, FileText, FolderKanban, Calendar, Zap, TrendingUp, Target } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DashboardWidget, Task, Habit, Note, Project, CalendarEvent, PaginatedResponse } from "@/lib/types";
|
||||
import { format, isToday, isPast, addDays, parseISO } from "date-fns";
|
||||
|
||||
const WIDGET_TYPES = [
|
||||
{ id: "tasks_due", label: "Tasks Due Today", icon: ListTodo, defaultW: 2, defaultH: 2 },
|
||||
{ id: "habits_today", label: "Habits Today", icon: Flame, defaultW: 2, defaultH: 2 },
|
||||
{ id: "recent_notes", label: "Recent Notes", icon: FileText, defaultW: 2, defaultH: 2 },
|
||||
{ id: "active_projects", label: "Active Projects", icon: FolderKanban, defaultW: 2, defaultH: 2 },
|
||||
{ id: "upcoming_events", label: "Upcoming Events", icon: Calendar, defaultW: 2, defaultH: 2 },
|
||||
{ id: "streak_counter", label: "Streak Counter", icon: Flame, defaultW: 1, defaultH: 1 },
|
||||
{ id: "quick_capture", label: "Quick Capture", icon: Zap, defaultW: 2, defaultH: 1 },
|
||||
{ id: "productivity_chart", label: "Productivity Chart", icon: TrendingUp, defaultW: 3, defaultH: 2 },
|
||||
] as const;
|
||||
|
||||
function TasksDueWidget() {
|
||||
const { data } = useApiQuery<PaginatedResponse<Task>>(["tasks-due"], "/tasks?limit=10&status=todo,in_progress&sort=dueDate");
|
||||
const tasks = data?.items || [];
|
||||
const today = tasks.filter((t) => t.dueDate && isToday(parseISO(t.dueDate)));
|
||||
const overdue = tasks.filter((t) => t.dueDate && isPast(parseISO(t.dueDate)) && t.status !== "done");
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{today.length === 0 && overdue.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No tasks due today</p>
|
||||
) : (
|
||||
<>
|
||||
{overdue.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-destructive mb-1">Overdue ({overdue.length})</p>
|
||||
{overdue.slice(0, 3).map((t) => (
|
||||
<div key={t.id} className="flex items-center gap-2 text-sm py-1">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-destructive shrink-0" />
|
||||
<span className="truncate flex-1">{t.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{today.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-amber-500 mb-1">Today ({today.length})</p>
|
||||
{today.slice(0, 5).map((t) => (
|
||||
<div key={t.id} className="flex items-center gap-2 text-sm py-1">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-amber-500 shrink-0" />
|
||||
<span className="truncate flex-1">{t.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HabitsTodayWidget() {
|
||||
const { data } = useApiQuery<PaginatedResponse<Habit>>(["habits-today"], "/habits?limit=20");
|
||||
const habits = data?.items || [];
|
||||
const queryClient = useQueryClient();
|
||||
const completeMutation = useMutation({
|
||||
mutationFn: (id: string) => api.post("/habits/" + id + "/complete", {}),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["habits-today"] }),
|
||||
});
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{habits.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No habits yet</p>
|
||||
) : (
|
||||
habits.slice(0, 6).map((h) => (
|
||||
<div key={h.id} className="flex items-center gap-2 py-1">
|
||||
<button
|
||||
onClick={() => completeMutation.mutate(h.id)}
|
||||
className={cn("w-4 h-4 rounded border shrink-0 flex items-center justify-center", h.streakCount > 0 ? "bg-green-500 border-green-500" : "border-muted-foreground/30 hover:border-primary")}
|
||||
aria-label={"Complete " + h.name}
|
||||
>
|
||||
{h.streakCount > 0 && <span className="text-[10px] text-white">\u2713</span>}
|
||||
</button>
|
||||
<span className="text-sm truncate flex-1">{h.name}</span>
|
||||
{h.streakCount > 0 && (
|
||||
<Badge variant="secondary" className="text-[10px] shrink-0">
|
||||
<Flame className="h-2.5 w-2.5 mr-0.5" />{h.streakCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentNotesWidget() {
|
||||
const { data } = useApiQuery<PaginatedResponse<Note>>(["recent-notes"], "/notes?limit=5&sort=-updated");
|
||||
const notes = data?.items || [];
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{notes.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No notes yet</p>
|
||||
) : (
|
||||
notes.map((n) => (
|
||||
<div key={n.id} className="text-sm py-1 border-b last:border-0">
|
||||
<p className="font-medium truncate">{n.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{format(parseISO(n.updatedAt), "MMM d, HH:mm")}</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveProjectsWidget() {
|
||||
const { data } = useApiQuery<PaginatedResponse<Project>>(["active-projects"], "/projects?limit=10&status=active");
|
||||
const projects = data?.items || [];
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{projects.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No active projects</p>
|
||||
) : (
|
||||
projects.slice(0, 5).map((p) => (
|
||||
<div key={p.id} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="truncate flex-1">{p.name}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0 ml-2">{p.progress || 0}%</span>
|
||||
</div>
|
||||
<Progress value={p.progress || 0} className="h-1.5" />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UpcomingEventsWidget() {
|
||||
const { data } = useApiQuery<PaginatedResponse<CalendarEvent>>(["upcoming-events"], "/calendar/events?limit=20");
|
||||
const events = data?.items || [];
|
||||
const now = new Date();
|
||||
const weekFromNow = addDays(now, 7);
|
||||
const upcoming = events.filter((e) => {
|
||||
const start = parseISO(e.startTime);
|
||||
return start >= now && start <= weekFromNow;
|
||||
}).sort((a, b) => parseISO(a.startTime).getTime() - parseISO(b.startTime).getTime());
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{upcoming.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No upcoming events</p>
|
||||
) : (
|
||||
upcoming.slice(0, 5).map((e) => (
|
||||
<div key={e.id} className="flex items-center gap-2 text-sm py-1">
|
||||
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: e.color || "#3b82f6" }} />
|
||||
<span className="truncate flex-1">{e.title}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">{format(parseISO(e.startTime), "MMM d, HH:mm")}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StreakCounterWidget() {
|
||||
const { data } = useApiQuery<PaginatedResponse<Habit>>(["streaks"], "/habits?limit=50");
|
||||
const habits = data?.items || [];
|
||||
const bestStreak = Math.max(...habits.map((h) => h.streakCount || 0), 0);
|
||||
const totalActive = habits.filter((h) => h.streakCount > 0).length;
|
||||
return (
|
||||
<div className="flex items-center gap-4 h-full">
|
||||
<div className="flex flex-col items-center">
|
||||
<Flame className="h-8 w-8 text-orange-500" />
|
||||
<span className="text-2xl font-bold">{bestStreak}</span>
|
||||
<span className="text-xs text-muted-foreground">Best streak</span>
|
||||
</div>
|
||||
<Separator orientation="vertical" />
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-2xl font-bold">{totalActive}</span>
|
||||
<span className="text-xs text-muted-foreground">Active streaks</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickCaptureWidget() {
|
||||
const queryClient = useQueryClient();
|
||||
const [text, setText] = useState("");
|
||||
const [type, setType] = useState<"task" | "note">("task");
|
||||
const createTask = useMutation({
|
||||
mutationFn: (title: string) => api.post<Task>("/tasks", { title, status: "todo", priority: "medium" }),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["tasks-due"] }); setText(""); },
|
||||
});
|
||||
const createNote = useMutation({
|
||||
mutationFn: (title: string) => api.post<Note>("/notes", { title, content: "" }),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["recent-notes"] }); setText(""); },
|
||||
});
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!text.trim()) return;
|
||||
if (type === "task") createTask.mutate(text.trim());
|
||||
else createNote.mutate(text.trim());
|
||||
};
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex gap-2">
|
||||
<div className="flex-1 flex gap-2">
|
||||
<Select value={type} onValueChange={(v) => setType(v as "task" | "note")}>
|
||||
<SelectTrigger className="w-20 h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="task">Task</SelectItem>
|
||||
<SelectItem value="note">Note</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input placeholder="Quick capture..." value={text} onChange={(e) => setText(e.target.value)} className="h-9" />
|
||||
</div>
|
||||
<Button type="submit" size="sm" className="h-9" disabled={!text.trim() || createTask.isPending || createNote.isPending}>Add</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductivityChartWidget() {
|
||||
const { data } = useApiQuery<any>(["productivity-chart"], "/analytics/productivity?range=30");
|
||||
const stats = data;
|
||||
if (!stats) return <p className="text-sm text-muted-foreground">Loading...</p>;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="text-center p-2 bg-muted/50 rounded">
|
||||
<p className="text-lg font-bold">{stats.totalTasks || 0}</p>
|
||||
<p className="text-[10px] text-muted-foreground">Total</p>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-muted/50 rounded">
|
||||
<p className="text-lg font-bold text-green-500">{stats.completedTasks || 0}</p>
|
||||
<p className="text-[10px] text-muted-foreground">Done</p>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-muted/50 rounded">
|
||||
<p className="text-lg font-bold">{stats.taskCompletionRate || 0}%</p>
|
||||
<p className="text-[10px] text-muted-foreground">Rate</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center">Last {stats.period || 30} days</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WidgetRenderer({ type }: { type: string }) {
|
||||
switch (type) {
|
||||
case "tasks_due": return <TasksDueWidget />;
|
||||
case "habits_today": return <HabitsTodayWidget />;
|
||||
case "recent_notes": return <RecentNotesWidget />;
|
||||
case "active_projects": return <ActiveProjectsWidget />;
|
||||
case "upcoming_events": return <UpcomingEventsWidget />;
|
||||
case "streak_counter": return <StreakCounterWidget />;
|
||||
case "quick_capture": return <QuickCaptureWidget />;
|
||||
case "productivity_chart": return <ProductivityChartWidget />;
|
||||
default: return <p className="text-sm text-muted-foreground">Unknown widget: {type}</p>;
|
||||
}
|
||||
}
|
||||
|
||||
function WidgetCard({ widget, onConfigure, onDelete }: { widget: DashboardWidget; onConfigure: () => void; onDelete: () => void }) {
|
||||
const typeInfo = WIDGET_TYPES.find((t) => t.id === widget.type);
|
||||
const Icon = typeInfo?.icon || Target;
|
||||
return (
|
||||
<Card className="h-full flex flex-col group" style={{ gridColumn: "span " + (widget.layout.w || 2), gridRow: "span " + (widget.layout.h || 2) }}>
|
||||
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<CardTitle className="text-sm font-semibold truncate">{widget.title || typeInfo?.label || widget.type}</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onConfigure} aria-label="Configure widget"><Settings2 className="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive" onClick={onDelete} aria-label="Remove widget"><Trash2 className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3 flex-1 overflow-auto">
|
||||
<WidgetRenderer type={widget.type} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AddWidgetDialog({ open, onOpenChange, onAdd }: { open: boolean; onOpenChange: (open: boolean) => void; onAdd: (type: string) => void }) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader><DialogTitle>Add Widget</DialogTitle></DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{WIDGET_TYPES.map((wt) => {
|
||||
const Icon = wt.icon;
|
||||
return (
|
||||
<button key={wt.id} onClick={() => { onAdd(wt.id); onOpenChange(false); }}
|
||||
className="flex flex-col items-center gap-2 p-4 rounded-lg border hover:bg-accent hover:border-primary transition-colors text-center">
|
||||
<Icon className="h-6 w-6" />
|
||||
<span className="text-xs font-medium">{wt.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigureWidgetDialog({ widget, open, onOpenChange, onSave }: { widget: DashboardWidget | null; open: boolean; onOpenChange: (open: boolean) => void; onSave: (title: string, w: number, h: number) => void }) {
|
||||
const [title, setTitle] = useState(widget?.title || "");
|
||||
const [w, setW] = useState(widget?.layout.w || 2);
|
||||
const [h, setH] = useState(widget?.layout.h || 2);
|
||||
useEffect(() => {
|
||||
if (widget) { setTitle(widget.title || ""); setW(widget.layout.w || 2); setH(widget.layout.h || 2); }
|
||||
}, [widget]);
|
||||
const handleSave = () => { onSave(title, w, h); onOpenChange(false); };
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Configure Widget</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="widget-title">Title</Label>
|
||||
<Input id="widget-title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Widget title" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="widget-w">Width (columns)</Label>
|
||||
<Select value={String(w)} onValueChange={(v) => setW(parseInt(v))}>
|
||||
<SelectTrigger id="widget-w"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1 column</SelectItem>
|
||||
<SelectItem value="2">2 columns</SelectItem>
|
||||
<SelectItem value="3">3 columns</SelectItem>
|
||||
<SelectItem value="4">4 columns</SelectItem>
|
||||
<SelectItem value="6">6 columns</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="widget-h">Height (rows)</Label>
|
||||
<Select value={String(h)} onValueChange={(v) => setH(parseInt(v))}>
|
||||
<SelectTrigger id="widget-h"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1 row</SelectItem>
|
||||
<SelectItem value="2">2 rows</SelectItem>
|
||||
<SelectItem value="3">3 rows</SelectItem>
|
||||
<SelectItem value="4">4 rows</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button onClick={handleSave}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
const [configWidget, setConfigWidget] = useState<DashboardWidget | null>(null);
|
||||
useRealtime({ enabled: true });
|
||||
const { data: widgetsData, isLoading } = useApiQuery<{ items: DashboardWidget[]; totalItems: number }>(["dashboard-widgets"], "/dashboard/widgets");
|
||||
const widgets = widgetsData?.items || [];
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post<DashboardWidget>("/dashboard/widgets", data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["dashboard-widgets"] }),
|
||||
});
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: any }) => api.patch<DashboardWidget>("/dashboard/widgets/" + id, data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["dashboard-widgets"] }),
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete("/dashboard/widgets/" + id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["dashboard-widgets"] }),
|
||||
});
|
||||
const handleAddWidget = (type: string) => {
|
||||
const typeInfo = WIDGET_TYPES.find((t) => t.id === type);
|
||||
createMutation.mutate({ type, title: typeInfo?.label || type, layout: { x: 0, y: widgets.length, w: typeInfo?.defaultW || 2, h: typeInfo?.defaultH || 2 } });
|
||||
};
|
||||
const handleConfigure = (widget: DashboardWidget) => { setConfigWidget(widget); setConfigOpen(true); };
|
||||
const handleSaveConfig = (title: string, w: number, h: number) => {
|
||||
if (!configWidget) return;
|
||||
updateMutation.mutate({ id: configWidget.id, data: { title: title || null, layout: { ...configWidget.layout, w, h } } });
|
||||
};
|
||||
const handleDelete = (id: string) => { deleteMutation.mutate(id); };
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh]">
|
||||
<h1 className="text-3xl font-bold mb-2">Dashboard</h1>
|
||||
<p className="text-muted-foreground">Coming in T7 — real dashboard widgets.</p>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||
<Button onClick={() => setAddOpen(true)} aria-label="Add widget"><Plus className="h-4 w-4 mr-2" />Add Widget</Button>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading dashboard...</div>
|
||||
) : widgets.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground mb-4">Your dashboard is empty. Add some widgets to get started!</p>
|
||||
<Button onClick={() => handleAddWidget("tasks_due")}><Plus className="h-4 w-4 mr-2" />Add Default Widgets</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4" style={{ gridTemplateColumns: "repeat(12, 1fr)", gridAutoRows: "minmax(120px, auto)" }}>
|
||||
{widgets.map((w) => (
|
||||
<WidgetCard key={w.id} widget={w} onConfigure={() => handleConfigure(w)} onDelete={() => handleDelete(w.id)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<AddWidgetDialog open={addOpen} onOpenChange={setAddOpen} onAdd={handleAddWidget} />
|
||||
<ConfigureWidgetDialog widget={configWidget} open={configOpen} onOpenChange={setConfigOpen} onSave={handleSaveConfig} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,735 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { createRoute } from "@tanstack/react-router";
|
||||
import { Route as appRoute } from "../_app";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||
import { Plus, Trash2, Pencil, Palette, Sun, Moon, Monitor, Type, Maximize, Sidebar, Eye, Globe, Tag, List, Key, Bot, Webhook, Upload, Download, AlertCircle, Check, X, RefreshCw, TestTube } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Domain, CustomField, Webhook as WebhookType, ErrorLog, Agent, PaginatedResponse } from "@/lib/types";
|
||||
|
||||
const SETTINGS_TABS = [
|
||||
{ id: "appearance", label: "Appearance", icon: Palette },
|
||||
{ id: "domains", label: "Domains", icon: Globe },
|
||||
{ id: "tags", label: "Tags", icon: Tag },
|
||||
{ id: "custom-fields", label: "Custom Fields", icon: List },
|
||||
{ id: "shortcuts", label: "Keyboard Shortcuts", icon: Key },
|
||||
{ id: "agents", label: "Agents & Permissions", icon: Bot },
|
||||
{ id: "webhooks", label: "Webhooks", icon: Webhook },
|
||||
{ id: "import-export", label: "Import & Export", icon: Upload },
|
||||
{ id: "error-log", label: "Error Log", icon: AlertCircle },
|
||||
] as const;
|
||||
|
||||
const ACCENT_COLORS = [
|
||||
{ name: "Blue", value: "#3b82f6" },
|
||||
{ name: "Green", value: "#22c55e" },
|
||||
{ name: "Purple", value: "#a855f7" },
|
||||
{ name: "Orange", value: "#f97316" },
|
||||
{ name: "Rose", value: "#e11d48" },
|
||||
];
|
||||
|
||||
const SHORTCUTS_MAP: Record<string, string> = {
|
||||
"Cmd+K": "Command palette",
|
||||
"g+t": "Go to Tasks",
|
||||
"g+h": "Go to Habits",
|
||||
"g+p": "Go to Projects",
|
||||
"g+n": "Go to Notes",
|
||||
"g+c": "Go to Calendar",
|
||||
"g+d": "Go to Dashboard",
|
||||
"g+s": "Go to Settings",
|
||||
"g+a": "Go to Analytics",
|
||||
"n": "New task / note (context dependent)",
|
||||
"?": "Show keyboard shortcuts help",
|
||||
};
|
||||
|
||||
// ─── Appearance Tab ──────────────────────────────────────────────────────
|
||||
|
||||
function AppearanceTab() {
|
||||
const [theme, setTheme] = useState(localStorage.getItem("theme") || "system");
|
||||
const [accent, setAccent] = useState(localStorage.getItem("accent-color") || "#3b82f6");
|
||||
const [fontSize, setFontSize] = useState(localStorage.getItem("font-size") || "normal");
|
||||
const [density, setDensity] = useState(localStorage.getItem("density") || "comfortable");
|
||||
const [sidebarPos, setSidebarPos] = useState(localStorage.getItem("sidebar-position") || "left");
|
||||
const [reducedMotion, setReducedMotion] = useState(localStorage.getItem("reduced-motion") === "true");
|
||||
|
||||
useEffect(() => { localStorage.setItem("theme", theme); document.documentElement.className = theme; }, [theme]);
|
||||
useEffect(() => { localStorage.setItem("accent-color", accent); document.documentElement.style.setProperty("--accent-color", accent); }, [accent]);
|
||||
useEffect(() => { localStorage.setItem("font-size", fontSize); document.documentElement.style.fontSize = fontSize === "large" ? "18px" : fontSize === "small" ? "13px" : "16px"; }, [fontSize]);
|
||||
useEffect(() => { localStorage.setItem("density", density); }, [density]);
|
||||
useEffect(() => { localStorage.setItem("sidebar-position", sidebarPos); }, [sidebarPos]);
|
||||
useEffect(() => { localStorage.setItem("reduced-motion", String(reducedMotion)); document.documentElement.classList.toggle("reduce-motion", reducedMotion); }, [reducedMotion]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Theme</h3>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ id: "light", icon: Sun, label: "Light" },
|
||||
{ id: "dark", icon: Moon, label: "Dark" },
|
||||
{ id: "system", icon: Monitor, label: "System" },
|
||||
].map((t) => (
|
||||
<button key={t.id} onClick={() => setTheme(t.id)}
|
||||
className={cn("flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-colors", theme === t.id ? "border-primary bg-primary/5" : "border-muted hover:border-muted-foreground/30")}>
|
||||
<t.icon className="h-6 w-6" />
|
||||
<span className="text-xs font-medium">{t.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Accent Color</h3>
|
||||
<div className="flex gap-3">
|
||||
{ACCENT_COLORS.map((c) => (
|
||||
<button key={c.value} onClick={() => setAccent(c.value)}
|
||||
className={cn("w-10 h-10 rounded-full border-2 transition-all", accent === c.value ? "border-foreground scale-110" : "border-transparent")}
|
||||
style={{ backgroundColor: c.value }} aria-label={c.name} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Font Size</h3>
|
||||
<Select value={fontSize} onValueChange={setFontSize}>
|
||||
<SelectTrigger className="w-48"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="small">Small</SelectItem>
|
||||
<SelectItem value="normal">Normal</SelectItem>
|
||||
<SelectItem value="large">Large</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Separator />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Density</h3>
|
||||
<Select value={density} onValueChange={setDensity}>
|
||||
<SelectTrigger className="w-48"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="compact">Compact</SelectItem>
|
||||
<SelectItem value="comfortable">Comfortable</SelectItem>
|
||||
<SelectItem value="spacious">Spacious</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Separator />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Sidebar Position</h3>
|
||||
<Select value={sidebarPos} onValueChange={setSidebarPos}>
|
||||
<SelectTrigger className="w-48"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">Left</SelectItem>
|
||||
<SelectItem value="right">Right</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Reduced Motion</h3>
|
||||
<p className="text-sm text-muted-foreground">Minimize animations and transitions</p>
|
||||
</div>
|
||||
<Switch checked={reducedMotion} onCheckedChange={setReducedMotion} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Domains Tab ─────────────────────────────────────────────────────────
|
||||
|
||||
function DomainsTab() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data } = useApiQuery<PaginatedResponse<Domain>>(["domains"], "/domains");
|
||||
const domains = data?.items || [];
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (n: string) => api.post<Domain>("/domains", { name: n }),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["domains"] }); setCreateOpen(false); setName(""); },
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete("/domains/" + id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["domains"] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Domains</h3>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild><Button size="sm"><Plus className="h-4 w-4 mr-2" />New Domain</Button></DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>New Domain</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="domain-name">Name</Label>
|
||||
<Input id="domain-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Domain name" />
|
||||
</div>
|
||||
<Button onClick={() => createMutation.mutate(name)} disabled={!name.trim() || createMutation.isPending}>Create</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{domains.map((d) => (
|
||||
<div key={d.id} className="flex items-center justify-between p-3 rounded-lg border">
|
||||
<div>
|
||||
<p className="font-medium">{d.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{d.slug}</p>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="text-destructive"><Trash2 className="h-4 w-4" /></Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader><AlertDialogTitle>Delete Domain</AlertDialogTitle><AlertDialogDescription>Are you sure? This cannot be undone.</AlertDialogDescription></AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => deleteMutation.mutate(d.id)} className="bg-destructive">Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tags Tab ────────────────────────────────────────────────────────────
|
||||
|
||||
function TagsTab() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data } = useApiQuery<PaginatedResponse<any>>(["tags"], "/tags");
|
||||
const tags = data?.items || [];
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [color, setColor] = useState("#3b82f6");
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (d: any) => api.post("/tags", d),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["tags"] }); setCreateOpen(false); setName(""); },
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete("/tags/" + id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["tags"] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Tags</h3>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild><Button size="sm"><Plus className="h-4 w-4 mr-2" />New Tag</Button></DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>New Tag</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="tag-name">Name</Label>
|
||||
<Input id="tag-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Tag name" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="tag-color">Color</Label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input id="tag-color" type="color" value={color} onChange={(e) => setColor(e.target.value)} className="w-12 h-10 p-1" />
|
||||
<span className="text-sm text-muted-foreground">{color}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => createMutation.mutate({ name, color })} disabled={!name.trim() || createMutation.isPending}>Create</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((t: any) => (
|
||||
<div key={t.id} className="flex items-center gap-2 px-3 py-1.5 rounded-full border text-sm group">
|
||||
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: t.color || "#3b82f6" }} />
|
||||
<span>{t.name}</span>
|
||||
<button onClick={() => deleteMutation.mutate(t.id)} className="opacity-0 group-hover:opacity-100 transition-opacity ml-1 text-muted-foreground hover:text-destructive"><X className="h-3 w-3" /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Custom Fields Tab ────────────────────────────────────────────────────
|
||||
|
||||
function CustomFieldsTab() {
|
||||
const queryClient = useQueryClient();
|
||||
const [entityFilter, setEntityFilter] = useState("");
|
||||
const { data } = useApiQuery<PaginatedResponse<CustomField>>(["custom-fields", entityFilter], "/custom-fields" + (entityFilter ? "?entity=" + entityFilter : ""));
|
||||
const fields = data?.items || [];
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editField, setEditField] = useState<CustomField | null>(null);
|
||||
const [form, setForm] = useState({ name: "", type: "text", entityType: "tasks", required: false, options: "" });
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (d: any) => api.post<CustomField>("/custom-fields", d),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["custom-fields"] }); setCreateOpen(false); },
|
||||
});
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data: d }: { id: string; data: any }) => api.patch<CustomField>("/custom-fields/" + id, d),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["custom-fields"] }); setEditField(null); },
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete("/custom-fields/" + id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["custom-fields"] }),
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
const data: any = { name: form.name, type: form.type, entityType: form.entityType, required: form.required };
|
||||
if (form.type === "select" || form.type === "multi_select") data.options = form.options.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
if (editField) updateMutation.mutate({ id: editField.id, data });
|
||||
else createMutation.mutate(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Custom Fields</h3>
|
||||
<div className="flex gap-2">
|
||||
<Select value={entityFilter} onValueChange={setEntityFilter}>
|
||||
<SelectTrigger className="w-36"><SelectValue placeholder="All entities" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value=" ">All entities</SelectItem>
|
||||
<SelectItem value="tasks">Tasks</SelectItem>
|
||||
<SelectItem value="habits">Habits</SelectItem>
|
||||
<SelectItem value="projects">Projects</SelectItem>
|
||||
<SelectItem value="notes">Notes</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild><Button size="sm"><Plus className="h-4 w-4 mr-2" />New Field</Button></DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>New Custom Field</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div><Label>Name</Label><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></div>
|
||||
<div><Label>Type</Label><Select value={form.type} onValueChange={(v) => setForm({ ...form, type: v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="text">Text</SelectItem>
|
||||
<SelectItem value="number">Number</SelectItem>
|
||||
<SelectItem value="date">Date</SelectItem>
|
||||
<SelectItem value="select">Select</SelectItem>
|
||||
<SelectItem value="multi_select">Multi Select</SelectItem>
|
||||
<SelectItem value="boolean">Boolean</SelectItem>
|
||||
</SelectContent>
|
||||
</Select></div>
|
||||
<div><Label>Entity Type</Label><Select value={form.entityType} onValueChange={(v) => setForm({ ...form, entityType: v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tasks">Tasks</SelectItem>
|
||||
<SelectItem value="habits">Habits</SelectItem>
|
||||
<SelectItem value="projects">Projects</SelectItem>
|
||||
<SelectItem value="notes">Notes</SelectItem>
|
||||
</SelectContent>
|
||||
</Select></div>
|
||||
{(form.type === "select" || form.type === "multi_select") && (
|
||||
<div><Label>Options (comma-separated)</Label><Input value={form.options} onChange={(e) => setForm({ ...form, options: e.target.value })} placeholder="Option 1, Option 2" /></div>
|
||||
)}
|
||||
<div className="flex items-center gap-2"><Switch checked={form.required} onCheckedChange={(v) => setForm({ ...form, required: v })} /><Label>Required</Label></div>
|
||||
<Button onClick={handleSave} disabled={!form.name.trim()}>Create</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{fields.map((f) => (
|
||||
<div key={f.id} className="flex items-center justify-between p-3 rounded-lg border">
|
||||
<div>
|
||||
<p className="font-medium">{f.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{f.type} · {f.entityType}{f.required ? " · Required" : ""}</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => { setEditField(f); setForm({ name: f.name, type: f.type, entityType: f.entityType, required: f.required, options: f.options?.join(", ") || "" }); }}><Pencil className="h-4 w-4" /></Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild><Button variant="ghost" size="icon" className="h-8 w-8 text-destructive"><Trash2 className="h-4 w-4" /></Button></AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader><AlertDialogTitle>Delete Field</AlertDialogTitle><AlertDialogDescription>Are you sure?</AlertDialogDescription></AlertDialogHeader>
|
||||
<AlertDialogFooter><AlertDialogCancel>Cancel</AlertDialogCancel><AlertDialogAction onClick={() => deleteMutation.mutate(f.id)} className="bg-destructive">Delete</AlertDialogAction></AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{editField && (
|
||||
<Dialog open={!!editField} onOpenChange={(o) => { if (!o) setEditField(null); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Edit Custom Field</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div><Label>Name</Label><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></div>
|
||||
<div><Label>Type</Label><Select value={form.type} onValueChange={(v) => setForm({ ...form, type: v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="text">Text</SelectItem>
|
||||
<SelectItem value="number">Number</SelectItem>
|
||||
<SelectItem value="date">Date</SelectItem>
|
||||
<SelectItem value="select">Select</SelectItem>
|
||||
<SelectItem value="multi_select">Multi Select</SelectItem>
|
||||
<SelectItem value="boolean">Boolean</SelectItem>
|
||||
</SelectContent>
|
||||
</Select></div>
|
||||
{(form.type === "select" || form.type === "multi_select") && (
|
||||
<div><Label>Options (comma-separated)</Label><Input value={form.options} onChange={(e) => setForm({ ...form, options: e.target.value })} /></div>
|
||||
)}
|
||||
<div className="flex items-center gap-2"><Switch checked={form.required} onCheckedChange={(v) => setForm({ ...form, required: v })} /><Label>Required</Label></div>
|
||||
<Button onClick={handleSave} disabled={!form.name.trim()}>Save</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Keyboard Shortcuts Tab ────────────────────────────────────────────────
|
||||
|
||||
function ShortcutsTab() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Keyboard Shortcuts</h3>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(SHORTCUTS_MAP).map(([key, desc]) => (
|
||||
<div key={key} className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-muted/50">
|
||||
<span className="text-sm">{desc}</span>
|
||||
<kbd className="px-2 py-0.5 text-xs font-mono bg-muted rounded border">{key}</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Agents & Permissions Tab ────────────────────────────────────────────
|
||||
|
||||
function AgentsTab() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data } = useApiQuery<PaginatedResponse<Agent>>(["agents"], "/agents");
|
||||
const agents = data?.items || [];
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [form, setForm] = useState({ name: "", description: "", permissionTier: "read_only" });
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (d: any) => api.post<Agent>("/agents", d),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["agents"] }); setCreateOpen(false); },
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete("/agents/" + id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["agents"] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Agents & Permissions</h3>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild><Button size="sm"><Plus className="h-4 w-4 mr-2" />New Agent</Button></DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>New Agent</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div><Label>Name</Label><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></div>
|
||||
<div><Label>Description</Label><Textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></div>
|
||||
<div><Label>Permission Tier</Label><Select value={form.permissionTier} onValueChange={(v) => setForm({ ...form, permissionTier: v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="full_access">Full Access</SelectItem>
|
||||
<SelectItem value="read_only">Read Only</SelectItem>
|
||||
<SelectItem value="content_creator">Content Creator</SelectItem>
|
||||
<SelectItem value="task_manager">Task Manager</SelectItem>
|
||||
<SelectItem value="custom">Custom</SelectItem>
|
||||
</SelectContent>
|
||||
</Select></div>
|
||||
<Button onClick={() => createMutation.mutate(form)} disabled={!form.name.trim() || createMutation.isPending}>Create</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{agents.map((a) => (
|
||||
<div key={a.id} className="flex items-center justify-between p-3 rounded-lg border">
|
||||
<div>
|
||||
<p className="font-medium">{a.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{a.permissionTier.replace(/_/g, " ")} · {a.status}</p>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild><Button variant="ghost" size="icon" className="text-destructive"><Trash2 className="h-4 w-4" /></Button></AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader><AlertDialogTitle>Delete Agent</AlertDialogTitle><AlertDialogDescription>Are you sure?</AlertDialogDescription></AlertDialogHeader>
|
||||
<AlertDialogFooter><AlertDialogCancel>Cancel</AlertDialogCancel><AlertDialogAction onClick={() => deleteMutation.mutate(a.id)} className="bg-destructive">Delete</AlertDialogAction></AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Webhooks Tab ────────────────────────────────────────────────────────
|
||||
|
||||
function WebhooksTab() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data } = useApiQuery<PaginatedResponse<WebhookType>>(["webhooks"], "/webhooks");
|
||||
const webhooks = data?.items || [];
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [form, setForm] = useState({ name: "", url: "", events: "task.created,note.created" });
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (d: any) => api.post<Webhook>("/webhooks", d),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["webhooks"] }); setCreateOpen(false); },
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete("/webhooks/" + id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["webhooks"] }),
|
||||
});
|
||||
const testMutation = useMutation({
|
||||
mutationFn: (id: string) => api.post("/webhooks/" + id + "/test", {}),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Webhooks</h3>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild><Button size="sm"><Plus className="h-4 w-4 mr-2" />New Webhook</Button></DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>New Webhook</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div><Label>Name</Label><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></div>
|
||||
<div><Label>URL</Label><Input value={form.url} onChange={(e) => setForm({ ...form, url: e.target.value })} placeholder="https://example.com/webhook" /></div>
|
||||
<div><Label>Events (comma-separated)</Label><Input value={form.events} onChange={(e) => setForm({ ...form, events: e.target.value })} /></div>
|
||||
<Button onClick={() => createMutation.mutate({ name: form.name, url: form.url, events: form.events.split(",").map((s) => s.trim()) })} disabled={!form.name.trim() || !form.url.trim() || createMutation.isPending}>Create</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{webhooks.map((w) => (
|
||||
<div key={w.id} className="flex items-center justify-between p-3 rounded-lg border">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{w.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{w.url}</p>
|
||||
<div className="flex gap-1 mt-1">
|
||||
{w.events.slice(0, 3).map((e) => <Badge key={e} variant="secondary" className="text-[10px]">{e}</Badge>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={() => testMutation.mutate(w.id)} disabled={testMutation.isPending}>
|
||||
<TestTube className="h-3.5 w-3.5 mr-1" />Test
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild><Button variant="ghost" size="icon" className="h-8 w-8 text-destructive"><Trash2 className="h-4 w-4" /></Button></AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader><AlertDialogTitle>Delete Webhook</AlertDialogTitle><AlertDialogDescription>Are you sure?</AlertDialogDescription></AlertDialogHeader>
|
||||
<AlertDialogFooter><AlertDialogCancel>Cancel</AlertDialogCancel><AlertDialogAction onClick={() => deleteMutation.mutate(w.id)} className="bg-destructive">Delete</AlertDialogAction></AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Import & Export Tab ─────────────────────────────────────────────────
|
||||
|
||||
function ImportExportTab() {
|
||||
const queryClient = useQueryClient();
|
||||
const [importData, setImportData] = useState("");
|
||||
const [importResult, setImportResult] = useState<any>(null);
|
||||
const [exportFormat, setExportFormat] = useState("json");
|
||||
const [exportCollections, setExportCollections] = useState<string[]>(["tasks", "habits", "projects", "notes"]);
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post("/import", data),
|
||||
onSuccess: (res) => { setImportResult(res); queryClient.invalidateQueries(); },
|
||||
});
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const data = await api.post<any>("/export", { collections: exportCollections });
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "project-e-export.json";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
console.error("Export failed", e);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCollection = (c: string) => {
|
||||
setExportCollections((prev) => prev.includes(c) ? prev.filter((x) => x !== c) : [...prev, c]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Import</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">Paste JSON data to import. Format: {"{"} "version": "1.0", "tasks": [...], "habits": [...], "projects": [...], "notes": [...] {"}"}</p>
|
||||
<Textarea value={importData} onChange={(e) => setImportData(e.target.value)} placeholder='{"version": "1.0", "tasks": [...]}' rows={6} className="font-mono text-sm" />
|
||||
<Button className="mt-2" onClick={() => { try { importMutation.mutate(JSON.parse(importData)); } catch { setImportResult({ success: false, error: "Invalid JSON" }); } }} disabled={!importData.trim() || importMutation.isPending}>
|
||||
<Upload className="h-4 w-4 mr-2" />Import
|
||||
</Button>
|
||||
{importResult && (
|
||||
<div className={cn("mt-3 p-3 rounded-lg text-sm", importResult.success ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600")}>
|
||||
{importResult.success ? "Imported " + importResult.imported + " items" : "Import failed: " + (importResult.error || "Unknown error")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Export</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Format</Label>
|
||||
<Select value={exportFormat} onValueChange={setExportFormat}>
|
||||
<SelectTrigger className="w-48"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="json">JSON</SelectItem>
|
||||
<SelectItem value="csv">CSV</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Collections</Label>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{["tasks", "habits", "projects", "notes", "tags", "agents", "webhooks"].map((c) => (
|
||||
<button key={c} onClick={() => toggleCollection(c)}
|
||||
className={cn("px-3 py-1.5 rounded-full border text-sm transition-colors", exportCollections.includes(c) ? "bg-primary text-primary-foreground border-primary" : "hover:border-muted-foreground/30")}>
|
||||
{c.charAt(0).toUpperCase() + c.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleExport}><Download className="h-4 w-4 mr-2" />Download Export</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Error Log Tab ───────────────────────────────────────────────────────
|
||||
|
||||
function ErrorLogTab() {
|
||||
const [level, setLevel] = useState("");
|
||||
const { data } = useApiQuery<PaginatedResponse<ErrorLog>>(["error-log", level], "/error-log" + (level ? "?level=" + level : ""));
|
||||
const errors = data?.items || [];
|
||||
const queryClient = useQueryClient();
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
const clearMutation = useMutation({
|
||||
mutationFn: () => api.delete("/error-log"),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["error-log"] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Error Log</h3>
|
||||
<div className="flex gap-2">
|
||||
<Select value={level} onValueChange={setLevel}>
|
||||
<SelectTrigger className="w-32"><SelectValue placeholder="All levels" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value=" ">All levels</SelectItem>
|
||||
<SelectItem value="error">Error</SelectItem>
|
||||
<SelectItem value="warn">Warning</SelectItem>
|
||||
<SelectItem value="info">Info</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="destructive" size="sm" onClick={() => clearMutation.mutate()} disabled={clearMutation.isPending}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />Clear All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{errors.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">No errors logged</p>
|
||||
) : (
|
||||
errors.map((e) => (
|
||||
<div key={e.id} className="border rounded-lg">
|
||||
<button onClick={() => setExpanded(expanded === e.id ? null : e.id)} className="w-full flex items-center justify-between p-3 text-left hover:bg-muted/50">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className={cn("w-2 h-2 rounded-full shrink-0", e.level === "error" ? "bg-red-500" : e.level === "warn" ? "bg-amber-500" : "bg-blue-500")} />
|
||||
<span className="text-sm truncate">{e.message}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground shrink-0 ml-2">{new Date(e.createdAt).toLocaleString()}</span>
|
||||
</button>
|
||||
{expanded === e.id && (
|
||||
<div className="px-3 pb-3 space-y-2">
|
||||
<Separator />
|
||||
{e.stack && <pre className="text-xs text-muted-foreground bg-muted/50 p-2 rounded overflow-auto max-h-40">{e.stack}</pre>}
|
||||
{e.context && <pre className="text-xs text-muted-foreground bg-muted/50 p-2 rounded overflow-auto max-h-40">{JSON.stringify(e.context, null, 2)}</pre>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Settings Page ──────────────────────────────────────────────────
|
||||
|
||||
function SettingsPage() {
|
||||
const [activeTab, setActiveTab] = useState("appearance");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh]">
|
||||
<h1 className="text-3xl font-bold mb-2">Settings</h1>
|
||||
<p className="text-muted-foreground">Coming in T7 — user preferences.</p>
|
||||
<div className="flex gap-6 h-[calc(100vh-5rem)]">
|
||||
{/* Sidebar tabs */}
|
||||
<div className="w-56 shrink-0 space-y-1">
|
||||
{SETTINGS_TABS.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-left",
|
||||
activeTab === tab.id ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Separator orientation="vertical" />
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<ScrollArea className="h-full pr-4">
|
||||
{activeTab === "appearance" && <AppearanceTab />}
|
||||
{activeTab === "domains" && <DomainsTab />}
|
||||
{activeTab === "tags" && <TagsTab />}
|
||||
{activeTab === "custom-fields" && <CustomFieldsTab />}
|
||||
{activeTab === "shortcuts" && <ShortcutsTab />}
|
||||
{activeTab === "agents" && <AgentsTab />}
|
||||
{activeTab === "webhooks" && <WebhooksTab />}
|
||||
{activeTab === "import-export" && <ImportExportTab />}
|
||||
{activeTab === "error-log" && <ErrorLogTab />}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user