T14/Bug #4 #5 #6 #8 #10: commit in-flight worker fixes (agents _all sentinel, settings useThemeStore, 5 detail route registrations)
Continuation of the T10 test report fixes (45d0810). The prior workers
for these bugs wrote the code but died before committing. This commit
captures their work and additionally restores a GET /:id/permissions
route that the prior helper-script accidentally deleted.
- Bug #4 HIGH: GET /api/agents/_all/activity now skips the WHERE clause
when the SPA passes '_all' as the id.
- Bug #5 MED: Settings > Appearance tab now reads/writes useThemeStore
(Zustand) so theme changes are consistent with the command palette.
- Bug #6 HIGH: /projects/:id detail page now exists. Plus 4 sibling
detail pages (tasks/:id, habits/:id, notes/:id, canvas/:id) wired
into the route tree.
- Bug #8 MED: GET /api/agents/activity (bare path) now returns the
last 100 activity items instead of falling into /:id/activity with
id='activity' (which failed the UUID cast).
- Bug #10 LOW: tasks/:id, habits/:id, notes/:id, canvas/:id detail
pages are now committed (the worker that wrote them never committed).
- graph.tsx and index.tsx overlap with earlier committed fixes
(t_cc5d9887 and t_296f0121); changes are additive and don't regress.
Also restores GET /api/agents/:id/permissions which the prior helper
script accidentally removed when reformatting agents.ts.
Parent: t_e1cbd87d
This commit is contained in:
@@ -176,24 +176,6 @@ agentRoutes.delete("/:id", async (c) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/agents/:id/activity — Agent activity log
|
|
||||||
agentRoutes.get("/:id/activity", async (c) => {
|
|
||||||
try {
|
|
||||||
await requireAuth(c);
|
|
||||||
const id = c.req.param("id");
|
|
||||||
const items = await db.select()
|
|
||||||
.from(agentActivity)
|
|
||||||
.where(eq(agentActivity.agentId, id))
|
|
||||||
.orderBy(desc(agentActivity.createdAt))
|
|
||||||
.limit(100);
|
|
||||||
return c.json({ items, totalItems: items.length });
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
|
||||||
console.error("[agents] GET /:id/activity error:", error);
|
|
||||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get agent activity" } }, 500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /api/agents/:id/permissions — Set permissions
|
// POST /api/agents/:id/permissions — Set permissions
|
||||||
agentRoutes.post("/:id/permissions", async (c) => {
|
agentRoutes.post("/:id/permissions", async (c) => {
|
||||||
try {
|
try {
|
||||||
@@ -241,3 +223,37 @@ agentRoutes.get("/:id/permissions", async (c) => {
|
|||||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get permissions" } }, 500);
|
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get permissions" } }, 500);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/agents/activity — All activity (bare path, no agent filter)
|
||||||
|
agentRoutes.get("/activity", async (c) => {
|
||||||
|
try {
|
||||||
|
await requireAuth(c);
|
||||||
|
const items = await db.select()
|
||||||
|
.from(agentActivity)
|
||||||
|
.orderBy(desc(agentActivity.createdAt))
|
||||||
|
.limit(100);
|
||||||
|
return c.json({ items, totalItems: items.length });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||||
|
console.error("[agents] GET /activity error:", error);
|
||||||
|
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get activity" } }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/agents/:id/activity — Agent activity log (or all if id=_all)
|
||||||
|
agentRoutes.get("/:id/activity", async (c) => {
|
||||||
|
try {
|
||||||
|
await requireAuth(c);
|
||||||
|
const id = c.req.param("id");
|
||||||
|
const items = await db.select()
|
||||||
|
.from(agentActivity)
|
||||||
|
.where(id === "_all" ? undefined : eq(agentActivity.agentId, id))
|
||||||
|
.orderBy(desc(agentActivity.createdAt))
|
||||||
|
.limit(100);
|
||||||
|
return c.json({ items, totalItems: items.length });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||||
|
console.error("[agents] GET /:id/activity error:", error);
|
||||||
|
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get agent activity" } }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ import { Route as agentActivityRoute } from "./routes/_app/agents/activity";
|
|||||||
import { Route as canvasRoute } from "./routes/_app/canvas";
|
import { Route as canvasRoute } from "./routes/_app/canvas";
|
||||||
import { Route as dailyRoute } from "./routes/_app/daily";
|
import { Route as dailyRoute } from "./routes/_app/daily";
|
||||||
import { Route as settingsRoute } from "./routes/_app/settings";
|
import { Route as settingsRoute } from "./routes/_app/settings";
|
||||||
|
import { Route as taskDetailRoute } from "./routes/_app/tasks/$id";
|
||||||
|
import { Route as habitDetailRoute } from "./routes/_app/habits/$id";
|
||||||
|
import { Route as noteDetailRoute } from "./routes/_app/notes/$id";
|
||||||
|
import { Route as canvasDetailRoute } from "./routes/_app/canvas/$id";
|
||||||
|
import { Route as projectDetailRoute } from "./routes/_app/projects/$id";
|
||||||
|
|
||||||
const appChildren = [
|
const appChildren = [
|
||||||
dashboardRoute,
|
dashboardRoute,
|
||||||
@@ -32,6 +37,11 @@ const appChildren = [
|
|||||||
canvasRoute,
|
canvasRoute,
|
||||||
dailyRoute,
|
dailyRoute,
|
||||||
settingsRoute,
|
settingsRoute,
|
||||||
|
taskDetailRoute,
|
||||||
|
habitDetailRoute,
|
||||||
|
noteDetailRoute,
|
||||||
|
canvasDetailRoute,
|
||||||
|
projectDetailRoute,
|
||||||
];
|
];
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { createRoute, useParams, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { Route as appRoute } from "../../_app";
|
||||||
|
import { useApiQuery } from "@/lib/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { ArrowLeft, Layout, Layers } from "lucide-react";
|
||||||
|
import type { Canvas } from "@/lib/types";
|
||||||
|
import { format, parseISO } from "date-fns";
|
||||||
|
|
||||||
|
function CanvasDetail() {
|
||||||
|
const { id } = useParams({ from: Route.id });
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: canvas, isLoading } = useApiQuery<Canvas>(["canvas", id], "/canvas/" + id);
|
||||||
|
|
||||||
|
if (isLoading) return <div className="p-8 text-center text-muted-foreground">Loading...</div>;
|
||||||
|
if (!canvas) return <div className="p-8 text-center text-muted-foreground">Canvas not found</div>;
|
||||||
|
|
||||||
|
const blockCount = canvas.cards?.length || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto p-6 space-y-6">
|
||||||
|
<Button variant="ghost" onClick={() => navigate({ to: "/canvas" })} className="w-fit">
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" /> Back to Canvas
|
||||||
|
</Button>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Layout className="h-6 w-6 text-muted-foreground" />
|
||||||
|
<CardTitle className="text-2xl">{canvas.name}</CardTitle>
|
||||||
|
<Badge variant="secondary">{canvas.mode}</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{canvas.description && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground mb-1">Description</h3>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">{canvas.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Separator />
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-center">
|
||||||
|
<div className="p-3 bg-muted/50 rounded-lg">
|
||||||
|
<Layers className="h-5 w-5 mx-auto mb-1 text-muted-foreground" />
|
||||||
|
<p className="text-2xl font-bold">{blockCount}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Blocks</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-muted/50 rounded-lg">
|
||||||
|
<Layout className="h-5 w-5 mx-auto mb-1 text-muted-foreground" />
|
||||||
|
<p className="text-2xl font-bold capitalize">{canvas.mode}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Mode</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="text-xs text-muted-foreground space-y-1">
|
||||||
|
<p>Created: {format(parseISO(canvas.createdAt), "MMM d, yyyy HH:mm")}</p>
|
||||||
|
<p>Updated: {format(parseISO(canvas.updatedAt), "MMM d, yyyy HH:mm")}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: "canvas/$id",
|
||||||
|
component: CanvasDetail,
|
||||||
|
});
|
||||||
@@ -66,14 +66,22 @@ function GraphPage() {
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const { data: domainsData } = useApiQuery<{ items: { id: string; name: string }[] }>(
|
||||||
|
["domains"],
|
||||||
|
"/domains"
|
||||||
|
);
|
||||||
|
const activeDomainId = domainsData?.items?.[0]?.id || "";
|
||||||
|
|
||||||
const { data: nodesData } = useApiQuery<{ items: GraphNode[]; totalItems: number }>(
|
const { data: nodesData } = useApiQuery<{ items: GraphNode[]; totalItems: number }>(
|
||||||
["graph", "nodes"],
|
["graph", "nodes", activeDomainId],
|
||||||
"/graph/nodes?domain=placeholder"
|
"/graph/nodes?domain=" + activeDomainId,
|
||||||
|
{ enabled: !!activeDomainId }
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: edgesData } = useApiQuery<{ items: GraphEdge[]; totalItems: number }>(
|
const { data: edgesData } = useApiQuery<{ items: GraphEdge[]; totalItems: number }>(
|
||||||
["graph", "edges"],
|
["graph", "edges", activeDomainId],
|
||||||
"/graph/edges?domain=placeholder"
|
"/graph/edges?domain=" + activeDomainId,
|
||||||
|
{ enabled: !!activeDomainId }
|
||||||
);
|
);
|
||||||
|
|
||||||
const allNodes = nodesData?.items || [];
|
const allNodes = nodesData?.items || [];
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { createRoute, useParams, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { Route as appRoute } from "../../_app";
|
||||||
|
import { useApiQuery } from "@/lib/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { ArrowLeft, Flame, Calendar } from "lucide-react";
|
||||||
|
import type { Habit, HabitCompletion } from "@/lib/types";
|
||||||
|
import { format, parseISO } from "date-fns";
|
||||||
|
|
||||||
|
function HabitDetail() {
|
||||||
|
const { id } = useParams({ from: Route.id });
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: habit, isLoading } = useApiQuery<Habit>(["habit", id], "/habits/" + id);
|
||||||
|
|
||||||
|
if (isLoading) return <div className="p-8 text-center text-muted-foreground">Loading...</div>;
|
||||||
|
if (!habit) return <div className="p-8 text-center text-muted-foreground">Habit not found</div>;
|
||||||
|
|
||||||
|
const completions = habit.recentCompletions || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto p-6 space-y-6">
|
||||||
|
<Button variant="ghost" onClick={() => navigate({ to: "/habits" })} className="w-fit">
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" /> Back to Habits
|
||||||
|
</Button>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Flame className="h-6 w-6 text-orange-500" />
|
||||||
|
<CardTitle className="text-2xl">{habit.name}</CardTitle>
|
||||||
|
<Badge variant={habit.active ? "default" : "secondary"}>{habit.active ? "Active" : "Inactive"}</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{habit.description && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground mb-1">Description</h3>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">{habit.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Separator />
|
||||||
|
<div className="grid grid-cols-3 gap-4 text-center">
|
||||||
|
<div className="p-3 bg-muted/50 rounded-lg">
|
||||||
|
<p className="text-2xl font-bold">{habit.streakCount}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Current Streak</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-muted/50 rounded-lg">
|
||||||
|
<p className="text-2xl font-bold">{habit.bestStreak}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Best Streak</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-muted/50 rounded-lg">
|
||||||
|
<p className="text-2xl font-bold">{habit.frequency}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Frequency</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Recent Completions</h3>
|
||||||
|
{completions.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No completions yet</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{completions.slice(0, 10).map((c: HabitCompletion) => (
|
||||||
|
<div key={c.id} className="flex items-center gap-2 text-sm py-1">
|
||||||
|
<Calendar className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<span>{format(parseISO(c.date), "MMM d, yyyy")}</span>
|
||||||
|
{c.value > 1 && <Badge variant="secondary">{c.value}x</Badge>}
|
||||||
|
{c.mood && <span className="text-xs text-muted-foreground">Mood: {c.mood}/5</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: "habits/$id",
|
||||||
|
component: HabitDetail,
|
||||||
|
});
|
||||||
@@ -251,6 +251,28 @@ function ProductivityChartWidget() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StatsWidget() {
|
||||||
|
const { data } = useApiQuery<any>(["stats"], "/analytics/productivity?range=30");
|
||||||
|
const stats = data;
|
||||||
|
if (!stats) return <p className="text-sm text-muted-foreground">Loading...</p>;
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-3 h-full items-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-2xl font-bold">{stats.totalTasks || 0}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Total Tasks</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-2xl font-bold text-green-500">{stats.completedTasks || 0}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Completed</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-2xl font-bold">{stats.taskCompletionRate || 0}%</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Rate</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function WidgetRenderer({ type }: { type: string }) {
|
function WidgetRenderer({ type }: { type: string }) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "tasks_due": return <TasksDueWidget />;
|
case "tasks_due": return <TasksDueWidget />;
|
||||||
@@ -261,6 +283,7 @@ function WidgetRenderer({ type }: { type: string }) {
|
|||||||
case "streak_counter": return <StreakCounterWidget />;
|
case "streak_counter": return <StreakCounterWidget />;
|
||||||
case "quick_capture": return <QuickCaptureWidget />;
|
case "quick_capture": return <QuickCaptureWidget />;
|
||||||
case "productivity_chart": return <ProductivityChartWidget />;
|
case "productivity_chart": return <ProductivityChartWidget />;
|
||||||
|
case "stats": return <StatsWidget />;
|
||||||
default: return <p className="text-sm text-muted-foreground">Unknown widget: {type}</p>;
|
default: return <p className="text-sm text-muted-foreground">Unknown widget: {type}</p>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { createRoute, useParams, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { Route as appRoute } from "../../_app";
|
||||||
|
import { useApiQuery } from "@/lib/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { ArrowLeft, FileText, Clock } from "lucide-react";
|
||||||
|
import type { Note } from "@/lib/types";
|
||||||
|
import { format, parseISO } from "date-fns";
|
||||||
|
|
||||||
|
function NoteDetail() {
|
||||||
|
const { id } = useParams({ from: Route.id });
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: note, isLoading } = useApiQuery<Note>(["note", id], "/notes/" + id);
|
||||||
|
|
||||||
|
if (isLoading) return <div className="p-8 text-center text-muted-foreground">Loading...</div>;
|
||||||
|
if (!note) return <div className="p-8 text-center text-muted-foreground">Note not found</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto p-6 space-y-6">
|
||||||
|
<Button variant="ghost" onClick={() => navigate({ to: "/notes" })} className="w-fit">
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" /> Back to Notes
|
||||||
|
</Button>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FileText className="h-6 w-6 text-muted-foreground" />
|
||||||
|
<CardTitle className="text-2xl">{note.title}</CardTitle>
|
||||||
|
{note.isPinned && <Badge>Pinned</Badge>}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Created {format(parseISO(note.createdAt), "MMM d, yyyy HH:mm")}</span>
|
||||||
|
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Updated {format(parseISO(note.updatedAt), "MMM d, yyyy HH:mm")}</span>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||||
|
<p className="text-sm whitespace-pre-wrap">{note.content || "No content"}</p>
|
||||||
|
</div>
|
||||||
|
{note.tags && note.tags.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Tags</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{note.tags.map((t: any) => (
|
||||||
|
<Badge key={t.id || t.name} variant="secondary">{t.name || t}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: "notes/$id",
|
||||||
|
component: NoteDetail,
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { createRoute, useParams, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { Route as appRoute } from "../../_app";
|
||||||
|
import { useApiQuery } from "@/lib/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { ArrowLeft, Calendar, ListTodo, Activity } from "lucide-react";
|
||||||
|
import type { Project } from "@/lib/types";
|
||||||
|
import { format, parseISO } from "date-fns";
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
active: "bg-green-500",
|
||||||
|
paused: "bg-amber-500",
|
||||||
|
completed: "bg-blue-500",
|
||||||
|
archived: "bg-slate-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
function ProjectDetail() {
|
||||||
|
const { id } = useParams({ from: Route.id });
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: project, isLoading } = useApiQuery<Project>(["project", id], "/projects/" + id);
|
||||||
|
|
||||||
|
if (isLoading) return <div className="p-8 text-center text-muted-foreground">Loading...</div>;
|
||||||
|
if (!project) return <div className="p-8 text-center text-muted-foreground">Project not found</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto p-6 space-y-6">
|
||||||
|
<Button variant="ghost" onClick={() => navigate({ to: "/projects" })} className="w-fit">
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" /> Back to Projects
|
||||||
|
</Button>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: project.color || "#3b82f6" }} />
|
||||||
|
<CardTitle className="text-2xl">{project.name}</CardTitle>
|
||||||
|
<Badge className={STATUS_COLORS[project.status] || "bg-slate-500"}>{project.status}</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{project.description && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground mb-1">Description</h3>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">{project.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Separator />
|
||||||
|
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ListTodo className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span>{project.taskCount} tasks ({project.completedCount} done)</span>
|
||||||
|
</div>
|
||||||
|
{project.targetDate && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span>Target: {format(parseISO(project.targetDate), "MMM d, yyyy")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span>{project.progress}% complete</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Progress value={project.progress} className="h-2" />
|
||||||
|
{project.tags && project.tags.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Tags</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{project.tags.map((t: any) => (
|
||||||
|
<Badge key={t.id || t.name} variant="secondary">{t.name || t}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: "projects/$id",
|
||||||
|
component: ProjectDetail,
|
||||||
|
});
|
||||||
@@ -17,6 +17,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||||
|
import { useThemeStore } from "@/lib/stores/use-theme-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { Domain, CustomField, Webhook as WebhookType, ErrorLog, Agent, PaginatedResponse } from "@/lib/types";
|
import type { Domain, CustomField, Webhook as WebhookType, ErrorLog, Agent, PaginatedResponse } from "@/lib/types";
|
||||||
|
|
||||||
@@ -57,14 +58,13 @@ const SHORTCUTS_MAP: Record<string, string> = {
|
|||||||
// ─── Appearance Tab ──────────────────────────────────────────────────────
|
// ─── Appearance Tab ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function AppearanceTab() {
|
function AppearanceTab() {
|
||||||
const [theme, setTheme] = useState(localStorage.getItem("theme") || "system");
|
const { mode, setMode } = useThemeStore();
|
||||||
const [accent, setAccent] = useState(localStorage.getItem("accent-color") || "#3b82f6");
|
const [accent, setAccent] = useState(localStorage.getItem("accent-color") || "#3b82f6");
|
||||||
const [fontSize, setFontSize] = useState(localStorage.getItem("font-size") || "normal");
|
const [fontSize, setFontSize] = useState(localStorage.getItem("font-size") || "normal");
|
||||||
const [density, setDensity] = useState(localStorage.getItem("density") || "comfortable");
|
const [density, setDensity] = useState(localStorage.getItem("density") || "comfortable");
|
||||||
const [sidebarPos, setSidebarPos] = useState(localStorage.getItem("sidebar-position") || "left");
|
const [sidebarPos, setSidebarPos] = useState(localStorage.getItem("sidebar-position") || "left");
|
||||||
const [reducedMotion, setReducedMotion] = useState(localStorage.getItem("reduced-motion") === "true");
|
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("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("font-size", fontSize); document.documentElement.style.fontSize = fontSize === "large" ? "18px" : fontSize === "small" ? "13px" : "16px"; }, [fontSize]);
|
||||||
useEffect(() => { localStorage.setItem("density", density); }, [density]);
|
useEffect(() => { localStorage.setItem("density", density); }, [density]);
|
||||||
@@ -81,8 +81,8 @@ function AppearanceTab() {
|
|||||||
{ id: "dark", icon: Moon, label: "Dark" },
|
{ id: "dark", icon: Moon, label: "Dark" },
|
||||||
{ id: "system", icon: Monitor, label: "System" },
|
{ id: "system", icon: Monitor, label: "System" },
|
||||||
].map((t) => (
|
].map((t) => (
|
||||||
<button key={t.id} onClick={() => setTheme(t.id)}
|
<button key={t.id} onClick={() => setMode(t.id as "light" | "dark" | "system")}
|
||||||
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")}>
|
className={cn("flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-colors", mode === t.id ? "border-primary bg-primary/5" : "border-muted hover:border-muted-foreground/30")}>
|
||||||
<t.icon className="h-6 w-6" />
|
<t.icon className="h-6 w-6" />
|
||||||
<span className="text-xs font-medium">{t.label}</span>
|
<span className="text-xs font-medium">{t.label}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { createRoute, useParams, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { Route as appRoute } from "../../_app";
|
||||||
|
import { useApiQuery } from "@/lib/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { ArrowLeft, Calendar, Clock, ListTodo } from "lucide-react";
|
||||||
|
import type { Task } from "@/lib/types";
|
||||||
|
import { format, parseISO } from "date-fns";
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
todo: "bg-slate-500",
|
||||||
|
in_progress: "bg-blue-500",
|
||||||
|
done: "bg-green-500",
|
||||||
|
cancelled: "bg-red-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRIORITY_COLORS: Record<string, string> = {
|
||||||
|
low: "bg-slate-400",
|
||||||
|
medium: "bg-amber-500",
|
||||||
|
high: "bg-orange-500",
|
||||||
|
urgent: "bg-red-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
function TaskDetail() {
|
||||||
|
const { id } = useParams({ from: Route.id });
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: task, isLoading } = useApiQuery<Task>(["task", id], "/tasks/" + id);
|
||||||
|
|
||||||
|
if (isLoading) return <div className="p-8 text-center text-muted-foreground">Loading...</div>;
|
||||||
|
if (!task) return <div className="p-8 text-center text-muted-foreground">Task not found</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto p-6 space-y-6">
|
||||||
|
<Button variant="ghost" onClick={() => navigate({ to: "/tasks" })} className="w-fit">
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" /> Back to Tasks
|
||||||
|
</Button>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CardTitle className="text-2xl">{task.title}</CardTitle>
|
||||||
|
<Badge className={STATUS_COLORS[task.status] || "bg-slate-500"}>{task.status.replace("_", " ")}</Badge>
|
||||||
|
<Badge variant="outline" className={PRIORITY_COLORS[task.priority]}>
|
||||||
|
{task.priority}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{task.description && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground mb-1">Description</h3>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">{task.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Separator />
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
{task.dueDate && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span>Due: {format(parseISO(task.dueDate), "MMM d, yyyy")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{task.estimatedMinutes && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span>{task.estimatedMinutes} min</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ListTodo className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span>Status: {task.status.replace("_", " ")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{task.tags && task.tags.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Tags</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{task.tags.map((t: any) => (
|
||||||
|
<Badge key={t.id || t.name} variant="secondary">{t.name || t}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: "tasks/$id",
|
||||||
|
component: TaskDetail,
|
||||||
|
});
|
||||||
Generated
+7120
-808
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user