UI redesign: data-dense professional treatment across entire app
Foundation: - Rewrote CSS design tokens for light/dark modes (compact density default) - Added JetBrains Mono for monospace accents on numeric data/timestamps - Vibrant semantic color palette (tasks=blue, habits=green, projects=purple, notes=amber) - Removed accent color toggle system, pinned interactive accent blue - New shell surface tokens (sidebar-bg, sidebar-border, topbar-bg) Shell: - Sidebar: colored entity-type icons, always-visible shortcuts, left accent border - Topbar: reduced to h-10, backdrop blur, monospace search shortcut Pages (all 12 existing): - Border treatment (no shadows), hover:bg-muted/20 transitions - font-mono for all numeric data, timestamps, IDs, status codes - Tighter spacing (gap-3, space-y-3), text-xl headers New pages: - Inbox (/inbox) - focused attention view with overdue/today habits/recent notes - Reports (/reports) - richer analytics with stat cards, charts, CSV export - Templates (/templates) - localStorage-based task/note template management Entity detail pages: - Consistent border-t pt-3 dividers, monospace timestamps - Added TagManager to projects (consistency fix) - Removed Card wrappers from activity/comments (flat sections) Login: split-layout with branding panel and floating colored dots Shared components: colored EmptyState icons, monospace error messages Command palette & shortcuts: new nav items for Inbox/Reports/Analytics Cleanup: removed all hover:shadow-md and ACCENT_PALETTE references
This commit is contained in:
@@ -5,6 +5,9 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Project E</title>
|
<title>Project E</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -43,35 +43,35 @@ export function EntityDetailPage({
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("mx-auto space-y-6 p-0", containerClassName || "max-w-4xl")}>
|
<div className={cn("mx-auto space-y-4 p-0", containerClassName || "max-w-4xl")}>
|
||||||
<div className="sticky top-0 z-10 -mx-1 mb-4 border-b bg-background/95 py-3 backdrop-blur">
|
<div className="sticky top-0 z-10 -mx-1 mb-2 border-b bg-background/95 py-2 backdrop-blur">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => navigate({ to: backTo.to })}
|
onClick={() => navigate({ to: backTo.to })}
|
||||||
className="mb-3"
|
className="mb-2 h-7 text-xs"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
<ArrowLeft className="h-3.5 w-3.5 mr-1.5" />
|
||||||
{backTo.label}
|
{backTo.label}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-2.5">
|
||||||
{icon && <div className="mt-1 text-muted-foreground">{icon}</div>}
|
{icon && <div className="mt-0.5 text-muted-foreground">{icon}</div>}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1">
|
||||||
<h1 className="text-2xl font-bold leading-tight">{title}</h1>
|
<h1 className="text-xl font-bold leading-tight">{title}</h1>
|
||||||
{badges && (
|
{badges && (
|
||||||
<div className="flex flex-wrap items-center gap-2">{badges}</div>
|
<div className="flex flex-wrap items-center gap-1.5">{badges}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
|
{actions && <div className="flex shrink-0 items-center gap-1.5">{actions}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue={defaultTab || tabs[0]?.value}>
|
<Tabs defaultValue={defaultTab || tabs[0]?.value}>
|
||||||
<TabsList className="flex-wrap gap-1">
|
<TabsList className="flex-wrap gap-0.5">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<TabsTrigger key={tab.value} value={tab.value}>
|
<TabsTrigger key={tab.value} value={tab.value} className="text-xs">
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { formatDistanceToNow, parseISO } from "date-fns";
|
import { formatDistanceToNow, parseISO } from "date-fns";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { ErrorState, LoadingState } from "@/components/state";
|
import { ErrorState, LoadingState } from "@/components/state";
|
||||||
import { useApiQuery } from "@/lib/api";
|
import { useApiQuery } from "@/lib/api";
|
||||||
import type { ActivityItem } from "@/lib/types";
|
import type { ActivityItem } from "@/lib/types";
|
||||||
@@ -66,15 +65,15 @@ function ActivityRow({ item }: { item: ActivityItem }) {
|
|||||||
const summary = changesSummary(item.action, item.changes);
|
const summary = changesSummary(item.action, item.changes);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-2.5 py-2">
|
||||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted">
|
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-muted">
|
||||||
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
<Icon className="h-3 w-3 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5">
|
<p className="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5">
|
||||||
<span className="text-sm font-medium text-foreground">{item.actor}</span>
|
<span className="text-sm font-medium text-foreground">{item.actor}</span>
|
||||||
<span className="text-sm text-muted-foreground">{meta.label}</span>
|
<span className="text-sm text-muted-foreground">{meta.label}</span>
|
||||||
<span className="text-xs text-muted-foreground/70">
|
<span className="font-mono text-[11px] text-muted-foreground/70">
|
||||||
{formatDistanceToNow(parseISO(item.createdAt), { addSuffix: true })}
|
{formatDistanceToNow(parseISO(item.createdAt), { addSuffix: true })}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -102,29 +101,24 @@ export function EntityActivity({ entityType, entityId, limit }: EntityActivityPr
|
|||||||
const items = data?.items ?? [];
|
const items = data?.items ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div>
|
||||||
<CardHeader>
|
{isLoading ? <LoadingState label="Loading activity..." /> : null}
|
||||||
<CardTitle>Activity</CardTitle>
|
{isError ? (
|
||||||
</CardHeader>
|
<ErrorState
|
||||||
<CardContent>
|
message={error instanceof Error ? error.message : "Failed to load activity"}
|
||||||
{isLoading ? <LoadingState label="Loading activity..." /> : null}
|
onRetry={() => refetch()}
|
||||||
{isError ? (
|
/>
|
||||||
<ErrorState
|
) : null}
|
||||||
message={error instanceof Error ? error.message : "Failed to load activity"}
|
{!isLoading && !isError && items.length === 0 ? (
|
||||||
onRetry={() => refetch()}
|
<p className="py-8 text-center text-sm text-muted-foreground">No activity yet</p>
|
||||||
/>
|
) : null}
|
||||||
) : null}
|
{!isLoading && !isError && items.length > 0 ? (
|
||||||
{!isLoading && !isError && items.length === 0 ? (
|
<div className="divide-y">
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">No activity yet</p>
|
{items.map((item) => (
|
||||||
) : null}
|
<ActivityRow key={item.id} item={item} />
|
||||||
{!isLoading && !isError && items.length > 0 ? (
|
))}
|
||||||
<div className="space-y-3">
|
</div>
|
||||||
{items.map((item) => (
|
) : null}
|
||||||
<ActivityRow key={item.id} item={item} />
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { toast } from "sonner";
|
|||||||
import { formatDistanceToNow, parseISO } from "date-fns";
|
import { formatDistanceToNow, parseISO } from "date-fns";
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { ErrorState, LoadingState } from "@/components/state";
|
import { ErrorState, LoadingState } from "@/components/state";
|
||||||
import { api, useApiMutation, useApiQuery } from "@/lib/api";
|
import { api, useApiMutation, useApiQuery } from "@/lib/api";
|
||||||
@@ -166,7 +165,7 @@ function CommentThread({ node, onDelete, deletePending }: CommentThreadProps) {
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
<p className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||||
<span className="text-sm font-medium">{comment.author}</span>
|
<span className="text-sm font-medium">{comment.author}</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="font-mono text-[11px] text-muted-foreground">
|
||||||
{formatDistanceToNow(parseISO(comment.createdAt), { addSuffix: true })}
|
{formatDistanceToNow(parseISO(comment.createdAt), { addSuffix: true })}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -250,37 +249,32 @@ export function EntityComments({ entityType, entityId }: EntityCommentsProps) {
|
|||||||
const tree = useMemo(() => buildCommentTree(data?.items ?? []), [data?.items]);
|
const tree = useMemo(() => buildCommentTree(data?.items ?? []), [data?.items]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div className="space-y-4">
|
||||||
<CardHeader>
|
<CommentComposer entityType={entityType} entityId={entityId} />
|
||||||
<CardTitle>Comments</CardTitle>
|
{isLoading ? <LoadingState label="Loading comments..." /> : null}
|
||||||
</CardHeader>
|
{isError ? (
|
||||||
<CardContent className="space-y-4">
|
<ErrorState
|
||||||
<CommentComposer entityType={entityType} entityId={entityId} />
|
message={error instanceof Error ? error.message : "Failed to load comments"}
|
||||||
{isLoading ? <LoadingState label="Loading comments..." /> : null}
|
onRetry={() => refetch()}
|
||||||
{isError ? (
|
/>
|
||||||
<ErrorState
|
) : null}
|
||||||
message={error instanceof Error ? error.message : "Failed to load comments"}
|
{!isLoading && !isError && tree.length === 0 ? (
|
||||||
onRetry={() => refetch()}
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
/>
|
No comments yet — start the conversation.
|
||||||
) : null}
|
</p>
|
||||||
{!isLoading && !isError && tree.length === 0 ? (
|
) : null}
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
{!isLoading && !isError && tree.length > 0 ? (
|
||||||
No comments yet — start the conversation.
|
<div className="divide-y">
|
||||||
</p>
|
{tree.map((node) => (
|
||||||
) : null}
|
<CommentThread
|
||||||
{!isLoading && !isError && tree.length > 0 ? (
|
key={node.comment.id}
|
||||||
<div className="space-y-4">
|
node={node}
|
||||||
{tree.map((node) => (
|
onDelete={(id) => deleteMutation.mutate(id)}
|
||||||
<CommentThread
|
deletePending={deleteMutation.isPending}
|
||||||
key={node.comment.id}
|
/>
|
||||||
node={node}
|
))}
|
||||||
onDelete={(id) => deleteMutation.mutate(id)}
|
</div>
|
||||||
deletePending={deleteMutation.isPending}
|
) : null}
|
||||||
/>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,15 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import type { Tag, PaginatedResponse } from "@/lib/types";
|
import type { Tag, PaginatedResponse } from "@/lib/types";
|
||||||
|
|
||||||
const ENTITY_ROUTES: Record<"task" | "habit" | "note", string> = {
|
const ENTITY_ROUTES: Record<"task" | "habit" | "note" | "project", string> = {
|
||||||
task: "tasks",
|
task: "tasks",
|
||||||
habit: "habits",
|
habit: "habits",
|
||||||
note: "notes",
|
note: "notes",
|
||||||
|
project: "projects",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface TagManagerProps {
|
interface TagManagerProps {
|
||||||
entityType: "task" | "habit" | "note";
|
entityType: "task" | "habit" | "note" | "project";
|
||||||
entityId: string;
|
entityId: string;
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
Sun,
|
Sun,
|
||||||
Moon,
|
Moon,
|
||||||
Palette,
|
|
||||||
LogOut,
|
LogOut,
|
||||||
|
Inbox,
|
||||||
|
FileBarChart,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
@@ -27,7 +28,7 @@ import {
|
|||||||
CommandList,
|
CommandList,
|
||||||
CommandSeparator,
|
CommandSeparator,
|
||||||
} from "@/components/ui/command";
|
} from "@/components/ui/command";
|
||||||
import { useThemeStore, type AccentColor, ACCENT_PALETTE } from "@/lib/stores/use-theme-store";
|
import { useThemeStore } from "@/lib/stores/use-theme-store";
|
||||||
import { useCreateDialogStore } from "@/lib/stores/use-create-dialog-store";
|
import { useCreateDialogStore } from "@/lib/stores/use-create-dialog-store";
|
||||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ interface NavItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
|
{ label: "Inbox", href: "/inbox", icon: Inbox },
|
||||||
{ label: "Dashboard", href: "/", icon: LayoutDashboard },
|
{ label: "Dashboard", href: "/", icon: LayoutDashboard },
|
||||||
{ label: "Tasks", href: "/tasks", icon: ListTodo },
|
{ label: "Tasks", href: "/tasks", icon: ListTodo },
|
||||||
{ label: "Habits", href: "/habits", icon: Flame },
|
{ label: "Habits", href: "/habits", icon: Flame },
|
||||||
@@ -45,6 +47,8 @@ const navItems: NavItem[] = [
|
|||||||
{ label: "Notes", href: "/notes", icon: NotebookPen },
|
{ label: "Notes", href: "/notes", icon: NotebookPen },
|
||||||
{ label: "Graph", href: "/graph", icon: Share2 },
|
{ label: "Graph", href: "/graph", icon: Share2 },
|
||||||
{ label: "Calendar", href: "/calendar", icon: CalendarDays },
|
{ label: "Calendar", href: "/calendar", icon: CalendarDays },
|
||||||
|
{ label: "Analytics", href: "/analytics", icon: LayoutDashboard },
|
||||||
|
{ label: "Reports", href: "/reports", icon: FileBarChart },
|
||||||
{ label: "Agent Activity", href: "/agents/activity", icon: Bot },
|
{ label: "Agent Activity", href: "/agents/activity", icon: Bot },
|
||||||
{ label: "Settings", href: "/settings", icon: Settings },
|
{ label: "Settings", href: "/settings", icon: Settings },
|
||||||
];
|
];
|
||||||
@@ -73,7 +77,7 @@ function addRecentPage(href: string) {
|
|||||||
export function CommandPalette() {
|
export function CommandPalette() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { mode, setMode, accent, setAccent } = useThemeStore();
|
const { mode, setMode } = useThemeStore();
|
||||||
const activeDomainId = useApiDomain();
|
const activeDomainId = useApiDomain();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [searchResults, setSearchResults] = useState<
|
const [searchResults, setSearchResults] = useState<
|
||||||
@@ -152,13 +156,6 @@ export function CommandPalette() {
|
|||||||
icon: mode === "dark" ? Sun : Moon,
|
icon: mode === "dark" ? Sun : Moon,
|
||||||
action: () => setMode(mode === "dark" ? "light" : "dark"),
|
action: () => setMode(mode === "dark" ? "light" : "dark"),
|
||||||
},
|
},
|
||||||
...(Object.entries(ACCENT_PALETTE) as [AccentColor, { name: string; hsl: string }][]).map(
|
|
||||||
([key, val]) => ({
|
|
||||||
label: `Accent: ${val.name}`,
|
|
||||||
icon: Palette,
|
|
||||||
action: () => setAccent(key),
|
|
||||||
})
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Settings actions
|
// Settings actions
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
const shortcutGroups = [
|
const shortcutGroups = [
|
||||||
{
|
{
|
||||||
heading: "Navigation",
|
heading: "Navigation",
|
||||||
|
color: "text-blue-500",
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
{ keys: "g then d", description: "Go to Dashboard" },
|
{ keys: "g then d", description: "Go to Dashboard" },
|
||||||
{ keys: "g then t", description: "Go to Tasks" },
|
{ keys: "g then t", description: "Go to Tasks" },
|
||||||
@@ -23,6 +24,7 @@ const shortcutGroups = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
heading: "Create",
|
heading: "Create",
|
||||||
|
color: "text-amber-500",
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
{ keys: "n then t", description: "New task" },
|
{ keys: "n then t", description: "New task" },
|
||||||
{ keys: "n then h", description: "New habit" },
|
{ keys: "n then h", description: "New habit" },
|
||||||
@@ -33,6 +35,7 @@ const shortcutGroups = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
heading: "General",
|
heading: "General",
|
||||||
|
color: "text-muted-foreground",
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
{ keys: "⌘K / Ctrl+K", description: "Open command palette" },
|
{ keys: "⌘K / Ctrl+K", description: "Open command palette" },
|
||||||
{ keys: "/", description: "Open command palette" },
|
{ keys: "/", description: "Open command palette" },
|
||||||
@@ -43,6 +46,7 @@ const shortcutGroups = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
heading: "Command Palette",
|
heading: "Command Palette",
|
||||||
|
color: "text-muted-foreground",
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
{ keys: "↑↓", description: "Navigate items" },
|
{ keys: "↑↓", description: "Navigate items" },
|
||||||
{ keys: "Enter", description: "Select item" },
|
{ keys: "Enter", description: "Select item" },
|
||||||
@@ -62,24 +66,24 @@ export function ShortcutsHelp() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md border">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Keyboard Shortcuts</DialogTitle>
|
<DialogTitle>Keyboard Shortcuts</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Use these shortcuts to navigate quickly. Press two-key combos like "g then t" in sequence within 1.5 seconds.
|
Use these shortcuts to navigate quickly. Press two-key combos like "g then t" in sequence within 1.5 seconds.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
{shortcutGroups.map((group) => (
|
{shortcutGroups.map((group) => (
|
||||||
<div key={group.heading}>
|
<div key={group.heading}>
|
||||||
<h4 className="mb-2 text-sm font-semibold text-muted-foreground">
|
<h4 className={`mb-1 text-[10px] font-semibold uppercase tracking-wider ${group.color}`}>
|
||||||
{group.heading}
|
{group.heading}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
{group.shortcuts.map((s) => (
|
{group.shortcuts.map((s) => (
|
||||||
<div
|
<div
|
||||||
key={s.keys}
|
key={s.keys}
|
||||||
className="flex items-center justify-between text-sm"
|
className="flex items-center justify-between py-0.5 text-xs"
|
||||||
>
|
>
|
||||||
<span className="text-muted-foreground">{s.description}</span>
|
<span className="text-muted-foreground">{s.description}</span>
|
||||||
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
|
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
Flame,
|
Flame,
|
||||||
FolderKanban,
|
FolderKanban,
|
||||||
NotebookPen,
|
NotebookPen,
|
||||||
BookOpen,
|
|
||||||
Share2,
|
Share2,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
Search,
|
Search,
|
||||||
@@ -21,6 +20,9 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
LogOut,
|
LogOut,
|
||||||
User,
|
User,
|
||||||
|
Inbox,
|
||||||
|
FileBarChart,
|
||||||
|
FileStack,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
@@ -56,6 +58,7 @@ interface NavItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
|
{ href: "/inbox", label: "Inbox", icon: Inbox, shortcut: "g i" },
|
||||||
{ href: "/", label: "Dashboard", icon: LayoutDashboard, shortcut: "g d" },
|
{ href: "/", label: "Dashboard", icon: LayoutDashboard, shortcut: "g d" },
|
||||||
{ href: "/tasks", label: "Tasks", icon: ListTodo, shortcut: "g t" },
|
{ href: "/tasks", label: "Tasks", icon: ListTodo, shortcut: "g t" },
|
||||||
{ href: "/habits", label: "Habits", icon: Flame, shortcut: "g h" },
|
{ href: "/habits", label: "Habits", icon: Flame, shortcut: "g h" },
|
||||||
@@ -65,14 +68,40 @@ const navItems: NavItem[] = [
|
|||||||
{ href: "/graph", label: "Graph", icon: Share2, shortcut: "g g" },
|
{ href: "/graph", label: "Graph", icon: Share2, shortcut: "g g" },
|
||||||
{ href: "/search", label: "Search", icon: Search },
|
{ href: "/search", label: "Search", icon: Search },
|
||||||
{ href: "/analytics", label: "Analytics", icon: BarChart3 },
|
{ href: "/analytics", label: "Analytics", icon: BarChart3 },
|
||||||
|
{ href: "/reports", label: "Reports", icon: FileBarChart, shortcut: "g r" },
|
||||||
{ href: "/agents/activity", label: "Agent Activity", icon: Bot },
|
{ href: "/agents/activity", label: "Agent Activity", icon: Bot },
|
||||||
{ href: "/daily", label: "Daily Notes", icon: PenLine },
|
{ href: "/daily", label: "Daily Notes", icon: PenLine },
|
||||||
|
{ href: "/templates", label: "Templates", icon: FileStack },
|
||||||
];
|
];
|
||||||
|
|
||||||
const bottomItems: NavItem[] = [
|
const bottomItems: NavItem[] = [
|
||||||
{ href: "/settings", label: "Settings", icon: Settings, shortcut: "g s" },
|
{ href: "/settings", label: "Settings", icon: Settings, shortcut: "g s" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Map nav href to an ENTITY key for icon coloring. */
|
||||||
|
function entityKey(href: string): string {
|
||||||
|
if (href === "/") return "dashboard";
|
||||||
|
const segment = href.split("/")[1];
|
||||||
|
return segment || "dashboard";
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICON_COLORS: Record<string, string> = {
|
||||||
|
dashboard: "hsl(var(--foreground))",
|
||||||
|
inbox: "#f97316",
|
||||||
|
tasks: "#3b82f6",
|
||||||
|
habits: "#10b981",
|
||||||
|
projects: "#8b5cf6",
|
||||||
|
notes: "#f59e0b",
|
||||||
|
calendar: "hsl(var(--foreground))",
|
||||||
|
graph: "hsl(var(--foreground))",
|
||||||
|
search: "hsl(var(--foreground))",
|
||||||
|
analytics: "hsl(var(--foreground))",
|
||||||
|
reports: "#6366f1",
|
||||||
|
agents: "hsl(var(--foreground))",
|
||||||
|
daily: "hsl(var(--foreground))",
|
||||||
|
templates: "#6366f1",
|
||||||
|
};
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -81,9 +110,6 @@ export function Sidebar() {
|
|||||||
const userName = user?.name || user?.email?.split("@")[0] || "User";
|
const userName = user?.name || user?.email?.split("@")[0] || "User";
|
||||||
const userInitials = (user?.name || user?.email || "U").slice(0, 2).toUpperCase();
|
const userInitials = (user?.name || user?.email || "U").slice(0, 2).toUpperCase();
|
||||||
|
|
||||||
// Sidebar position (left/right) is set in Settings. Read once on mount and
|
|
||||||
// update live via the "sidebar-position-change" custom event dispatched by
|
|
||||||
// the settings page.
|
|
||||||
const [sidebarPos, setSidebarPos] = useState<"left" | "right">(() =>
|
const [sidebarPos, setSidebarPos] = useState<"left" | "right">(() =>
|
||||||
typeof window !== "undefined" && localStorage.getItem("sidebar-position") === "right"
|
typeof window !== "undefined" && localStorage.getItem("sidebar-position") === "right"
|
||||||
? "right"
|
? "right"
|
||||||
@@ -106,25 +132,31 @@ export function Sidebar() {
|
|||||||
|
|
||||||
const renderNavLink = (item: NavItem, isCollapsed: boolean, onNavigate?: () => void) => {
|
const renderNavLink = (item: NavItem, isCollapsed: boolean, onNavigate?: () => void) => {
|
||||||
const active = isActive(item.href);
|
const active = isActive(item.href);
|
||||||
|
const key = entityKey(item.href);
|
||||||
|
const iconColor = ICON_COLORS[key] ?? "hsl(var(--foreground))";
|
||||||
|
|
||||||
const link = (
|
const link = (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
to={item.href}
|
to={item.href}
|
||||||
onClick={onNavigate}
|
onClick={onNavigate}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
"group/item flex items-center gap-3 rounded-md py-2 pl-3 pr-2 text-sm font-medium transition-colors",
|
||||||
active
|
active
|
||||||
? "bg-accent text-accent-foreground"
|
? "border-l-[3px] border-l-[hsl(var(--accent-hsl))] bg-accent/50 text-accent-foreground"
|
||||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
: "border-l-[3px] border-l-transparent hover:bg-accent/30 text-muted-foreground hover:text-accent-foreground",
|
||||||
|
isCollapsed && "!justify-center pl-0 pr-0 border-l-0"
|
||||||
)}
|
)}
|
||||||
activeProps={{ className: "bg-accent text-accent-foreground" }}
|
activeProps={{ className: "border-l-[3px] border-l-[hsl(var(--accent-hsl))] bg-accent/50 text-accent-foreground" }}
|
||||||
>
|
>
|
||||||
<item.icon className="h-4 w-4 shrink-0" aria-hidden="true" />
|
<span style={active ? { color: iconColor } : undefined} className="inline-flex shrink-0">
|
||||||
|
<item.icon className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<>
|
<>
|
||||||
<span className="flex-1">{item.label}</span>
|
<span className="flex-1 truncate">{item.label}</span>
|
||||||
{item.shortcut && (
|
{item.shortcut && (
|
||||||
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground opacity-0 group-hover/item:opacity-100 transition-opacity">
|
<kbd className="pointer-events-none inline-flex h-5 shrink-0 select-none items-center rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground transition-opacity">
|
||||||
{item.shortcut}
|
{item.shortcut}
|
||||||
</kbd>
|
</kbd>
|
||||||
)}
|
)}
|
||||||
@@ -136,24 +168,26 @@ export function Sidebar() {
|
|||||||
if (isCollapsed) {
|
if (isCollapsed) {
|
||||||
return (
|
return (
|
||||||
<Tooltip key={item.href}>
|
<Tooltip key={item.href}>
|
||||||
<TooltipTrigger asChild>{link}</TooltipTrigger>
|
<TooltipTrigger asChild>
|
||||||
|
<div className="flex justify-center">{link}</div>
|
||||||
|
</TooltipTrigger>
|
||||||
<TooltipContent side={sidebarPos === "right" ? "left" : "right"}>{item.label}</TooltipContent>
|
<TooltipContent side={sidebarPos === "right" ? "left" : "right"}>{item.label}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div key={item.href} className="group/item">{link}</div>;
|
return link;
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigation = (isCollapsed: boolean, onNavigate?: () => void) => (
|
const navigation = (isCollapsed: boolean, onNavigate?: () => void) => (
|
||||||
<ScrollArea className="flex-1 py-2">
|
<ScrollArea className="flex-1 py-2">
|
||||||
<nav className="flex flex-col gap-1 px-2" aria-label="Primary">
|
<nav className="flex flex-col gap-0.5 px-2" aria-label="Primary">
|
||||||
{navItems.map((item) => renderNavLink(item, isCollapsed, onNavigate))}
|
{navItems.map((item) => renderNavLink(item, isCollapsed, onNavigate))}
|
||||||
</nav>
|
</nav>
|
||||||
<Separator className="my-3" />
|
<Separator className="my-2" />
|
||||||
<nav className="flex flex-col gap-1 px-2" aria-label="Workspace">
|
<nav className="flex flex-col gap-0.5 px-2" aria-label="Workspace">
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<p className="px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
<p className="px-3 pt-1 pb-1.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||||
Workspace
|
Workspace
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -166,26 +200,28 @@ export function Sidebar() {
|
|||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"hidden flex-col bg-card transition-all duration-200 md:flex",
|
"hidden flex-col bg-sidebar transition-all duration-200 md:flex",
|
||||||
collapsed ? "w-16" : "w-60",
|
collapsed ? "w-16" : "w-60",
|
||||||
sidebarPos === "right" ? "order-last border-l" : "border-r"
|
sidebarPos === "right"
|
||||||
|
? "order-last border-l border-sidebar-border"
|
||||||
|
: "border-r border-sidebar-border"
|
||||||
)}
|
)}
|
||||||
aria-label="Main navigation"
|
aria-label="Main navigation"
|
||||||
>
|
>
|
||||||
{/* Logo + collapse */}
|
{/* Logo + collapse */}
|
||||||
<div className="flex h-14 items-center justify-between px-4">
|
<div className="flex h-12 items-center justify-between px-3">
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<Link to="/" className="flex items-center gap-2 font-semibold">
|
<Link to="/" className="flex items-center gap-2 font-semibold">
|
||||||
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary text-primary-foreground text-sm font-bold">
|
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-primary text-primary-foreground text-xs font-bold">
|
||||||
E
|
E
|
||||||
</span>
|
</span>
|
||||||
Project E
|
<span className="text-sm">Project E</span>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-11 w-11 shrink-0"
|
className="h-8 w-8 shrink-0"
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||||
>
|
>
|
||||||
@@ -202,13 +238,13 @@ export function Sidebar() {
|
|||||||
{navigation(collapsed)}
|
{navigation(collapsed)}
|
||||||
|
|
||||||
{/* User avatar + logout at bottom */}
|
{/* User avatar + logout at bottom */}
|
||||||
<div className="border-t p-2">
|
<div className="border-t border-sidebar-border p-2">
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-10 w-10">
|
<Button variant="ghost" size="icon" className="h-9 w-9">
|
||||||
<Avatar className="h-8 w-8">
|
<Avatar className="h-7 w-7">
|
||||||
<AvatarFallback className="text-xs">{userInitials}</AvatarFallback>
|
<AvatarFallback className="text-[10px]">{userInitials}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -231,9 +267,9 @@ export function Sidebar() {
|
|||||||
) : (
|
) : (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" className="w-full justify-start gap-3 px-3">
|
<Button variant="ghost" className="w-full justify-start gap-2.5 px-3">
|
||||||
<Avatar className="h-8 w-8">
|
<Avatar className="h-7 w-7">
|
||||||
<AvatarFallback className="text-xs">{userInitials}</AvatarFallback>
|
<AvatarFallback className="text-[10px]">{userInitials}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<span className="text-sm font-medium truncate">{userName}</span>
|
<span className="text-sm font-medium truncate">{userName}</span>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -260,12 +296,12 @@ export function Sidebar() {
|
|||||||
|
|
||||||
{/* Mobile sheet */}
|
{/* Mobile sheet */}
|
||||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||||
<SheetContent side={sidebarPos === "right" ? "right" : "left"} className="flex w-72 flex-col p-0 md:hidden">
|
<SheetContent side={sidebarPos === "right" ? "right" : "left"} className="flex w-72 flex-col p-0 bg-sidebar md:hidden">
|
||||||
<SheetHeader className="border-b px-4 py-4 pr-12">
|
<SheetHeader className="border-b border-sidebar-border px-4 py-3 pr-12">
|
||||||
<SheetTitle>Project E</SheetTitle>
|
<SheetTitle>Project E</SheetTitle>
|
||||||
<SheetDescription>Navigate your workspace.</SheetDescription>
|
<SheetDescription>Navigate your workspace.</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="border-b p-4">
|
<div className="border-b border-sidebar-border p-3">
|
||||||
<DomainPicker />
|
<DomainPicker />
|
||||||
</div>
|
</div>
|
||||||
{navigation(false, () => setMobileOpen(false))}
|
{navigation(false, () => setMobileOpen(false))}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useThemeStore, ACCENT_PALETTE, type AccentColor } from "@/lib/stores/use-theme-store";
|
import { useThemeStore } from "@/lib/stores/use-theme-store";
|
||||||
|
|
||||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
const { mode, accent } = useThemeStore();
|
const { mode } = useThemeStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
|
|
||||||
// Apply dark/light mode
|
|
||||||
if (mode === "system") {
|
if (mode === "system") {
|
||||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
const handler = (e: MediaQueryListEvent | MediaQueryList) => {
|
const handler = (e: MediaQueryListEvent | MediaQueryList) => {
|
||||||
@@ -22,11 +21,5 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, [mode]);
|
}, [mode]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const root = document.documentElement;
|
|
||||||
const palette = ACCENT_PALETTE[accent as AccentColor] || ACCENT_PALETTE.blue;
|
|
||||||
root.style.setProperty("--accent-hsl", palette.hsl);
|
|
||||||
}, [accent]);
|
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function Topbar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className="sticky top-0 z-10 flex h-14 items-center gap-4 border-b bg-card px-6"
|
className="sticky top-0 z-10 flex h-10 items-center gap-4 border-b bg-topbar px-6 backdrop-blur-sm"
|
||||||
role="banner"
|
role="banner"
|
||||||
>
|
>
|
||||||
{/* Mobile menu */}
|
{/* Mobile menu */}
|
||||||
@@ -78,19 +78,20 @@ export function Topbar() {
|
|||||||
onClick={() => setMobileOpen(true)}
|
onClick={() => setMobileOpen(true)}
|
||||||
aria-label="Open navigation menu"
|
aria-label="Open navigation menu"
|
||||||
>
|
>
|
||||||
<Menu className="h-5 w-5" />
|
<Menu className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Search input */}
|
{/* Search input */}
|
||||||
<div className="flex-1 md:max-w-sm">
|
<div className="flex-1 md:max-w-sm">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
className="w-full justify-start text-muted-foreground"
|
className="w-full justify-start text-muted-foreground"
|
||||||
onClick={openPalette}
|
onClick={openPalette}
|
||||||
aria-label="Open search (Cmd+K)"
|
aria-label="Open search (Cmd+K)"
|
||||||
>
|
>
|
||||||
<Search className="mr-2 h-4 w-4" aria-hidden="true" />
|
<Search className="mr-2 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||||
Search or jump to...{" "}
|
<span className="truncate">Search or jump to...</span>
|
||||||
<kbd className="ml-auto pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
|
<kbd className="ml-auto pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
|
||||||
⌘K
|
⌘K
|
||||||
</kbd>
|
</kbd>
|
||||||
@@ -101,7 +102,7 @@ export function Topbar() {
|
|||||||
<DomainPicker />
|
<DomainPicker />
|
||||||
|
|
||||||
{/* Right side */}
|
{/* Right side */}
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-1">
|
||||||
{/* Quick add */}
|
{/* Quick add */}
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -109,10 +110,11 @@ export function Topbar() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
onClick={openPalette}
|
onClick={openPalette}
|
||||||
aria-label="Quick add"
|
aria-label="Quick add"
|
||||||
>
|
>
|
||||||
<Plus className="h-5 w-5" />
|
<Plus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Quick add (Cmd+K)</TooltipContent>
|
<TooltipContent>Quick add (Cmd+K)</TooltipContent>
|
||||||
@@ -125,8 +127,8 @@ export function Topbar() {
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="relative" aria-label="Notifications">
|
<Button variant="ghost" size="icon" className="h-8 w-8 relative" aria-label="Notifications">
|
||||||
<Bell className="h-5 w-5" />
|
<Bell className="h-4 w-4" />
|
||||||
{count > 0 && (
|
{count > 0 && (
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -177,9 +179,9 @@ export function Topbar() {
|
|||||||
{/* User avatar */}
|
{/* User avatar */}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-9 w-9">
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
<Avatar className="h-8 w-8">
|
<Avatar className="h-7 w-7">
|
||||||
<AvatarFallback className="text-xs">U</AvatarFallback>
|
<AvatarFallback className="text-[10px]">{userInitials}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import { Button } from "@/components/ui/button";
|
|||||||
*/
|
*/
|
||||||
export function LoadingState({ label }: { label?: string }) {
|
export function LoadingState({ label }: { label?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-12 text-muted-foreground">
|
<div className="flex flex-col items-center justify-center gap-2 py-8 text-muted-foreground">
|
||||||
<Loader2 className="h-6 w-6 animate-spin" />
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
{label ? <p className="text-sm">{label}</p> : null}
|
{label ? <p className="text-xs">{label}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -24,22 +24,24 @@ export function EmptyState({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
action,
|
action,
|
||||||
|
color,
|
||||||
}: {
|
}: {
|
||||||
icon?: LucideIcon;
|
icon?: LucideIcon;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
action?: ReactNode;
|
action?: ReactNode;
|
||||||
|
color?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
|
<div className="flex flex-col items-center justify-center gap-2 py-8 text-center">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
|
||||||
<Icon className="h-6 w-6 text-muted-foreground" />
|
<Icon className={`h-5 w-5 ${color ?? "text-muted-foreground"}`} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-0.5">
|
||||||
<p className="font-semibold">{title}</p>
|
<p className="text-sm font-semibold">{title}</p>
|
||||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
{action ? <div className="pt-1">{action}</div> : null}
|
{action ? <div className="pt-0.5">{action}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -50,12 +52,12 @@ export function EmptyState({
|
|||||||
*/
|
*/
|
||||||
export function ErrorState({ message, onRetry }: { message: string; onRetry?: () => void }) {
|
export function ErrorState({ message, onRetry }: { message: string; onRetry?: () => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
|
<div className="flex flex-col items-center justify-center gap-2 py-8 text-center">
|
||||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||||
<p className="text-sm font-medium text-destructive">{message}</p>
|
<p className="text-xs font-medium text-destructive font-mono">{message}</p>
|
||||||
{onRetry ? (
|
{onRetry ? (
|
||||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||||
<RotateCw className="h-4 w-4" />
|
<RotateCw className="h-3 w-3" />
|
||||||
Retry
|
Retry
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export function useKeyboardShortcuts() {
|
|||||||
|
|
||||||
// g+letter navigation sequences
|
// g+letter navigation sequences
|
||||||
const navMap: Record<string, string> = {
|
const navMap: Record<string, string> = {
|
||||||
|
"g i": "/inbox",
|
||||||
"g d": "/",
|
"g d": "/",
|
||||||
"g t": "/tasks",
|
"g t": "/tasks",
|
||||||
"g h": "/habits",
|
"g h": "/habits",
|
||||||
@@ -41,6 +42,7 @@ export function useKeyboardShortcuts() {
|
|||||||
"g n": "/notes",
|
"g n": "/notes",
|
||||||
"g c": "/calendar",
|
"g c": "/calendar",
|
||||||
"g g": "/graph",
|
"g g": "/graph",
|
||||||
|
"g r": "/reports",
|
||||||
"g s": "/settings",
|
"g s": "/settings",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+52
-46
@@ -1,44 +1,50 @@
|
|||||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
/* Typography — Inter via Google Fonts @import above; falls back to system-ui */
|
/* Typography */
|
||||||
--font-sans: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
--font-sans: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
"Helvetica Neue", Arial, sans-serif;
|
"Helvetica Neue", Arial, sans-serif;
|
||||||
|
--font-mono: "JetBrains Mono", ui-monospace, "Cascadia Code", "Source Code Pro", Menlo,
|
||||||
|
Consolas, "DejaVu Sans Mono", monospace;
|
||||||
|
|
||||||
/* Density — scales content spacing (see the density utilities below and the
|
/* Density — compact is the new default */
|
||||||
calc() padding on <main> in the app layout). 1 = comfortable. */
|
--density-scale: 0.85;
|
||||||
--density-scale: 1;
|
|
||||||
|
|
||||||
--background: 0 0% 100%;
|
/* Light mode — clean, near-white with subtle warmth */
|
||||||
--foreground: 222.2 84% 4.9%;
|
--background: 0 0% 99%;
|
||||||
|
--foreground: 222 47% 5%;
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 222.2 84% 4.9%;
|
--card-foreground: 222 47% 5%;
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 222.2 84% 4.9%;
|
--popover-foreground: 222 47% 5%;
|
||||||
--primary: 222.2 47.4% 11.2%;
|
--primary: 222 47% 11%;
|
||||||
--primary-foreground: 210 40% 98%;
|
--primary-foreground: 0 0% 98%;
|
||||||
--secondary: 210 40% 96.1%;
|
--secondary: 220 14% 96%;
|
||||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
--secondary-foreground: 222 47% 11%;
|
||||||
--muted: 210 40% 96.1%;
|
--muted: 220 14% 96%;
|
||||||
--muted-foreground: 215.4 16.3% 46.9%;
|
--muted-foreground: 220 9% 46%;
|
||||||
--accent: 210 40% 96.1%;
|
--accent: 220 14% 96%;
|
||||||
--accent-foreground: 222.2 47.4% 11.2%;
|
--accent-foreground: 222 47% 11%;
|
||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 0 84% 60%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 0 0% 98%;
|
||||||
--border: 214.3 31.8% 91.4%;
|
--border: 220 13% 91%;
|
||||||
--input: 214.3 31.8% 91.4%;
|
--input: 220 13% 91%;
|
||||||
--ring: 222.2 84% 4.9%;
|
--ring: 217 91% 60%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.375rem;
|
||||||
|
|
||||||
|
/* Interactive accent — pinned vibrant blue */
|
||||||
--accent-hsl: 217 91% 60%;
|
--accent-hsl: 217 91% 60%;
|
||||||
|
|
||||||
|
/* Shell surfaces */
|
||||||
|
--sidebar-bg: 0 0% 100%;
|
||||||
|
--sidebar-border: 220 13% 91%;
|
||||||
|
--topbar-bg: 0 0% 99%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Density modes: toggled on <html> by the settings page (and applied from
|
/* Density modes */
|
||||||
localStorage on app load). They only flip --density-scale, which the
|
|
||||||
layout padding and the space-y utilities below multiply by. */
|
|
||||||
.density-compact {
|
.density-compact {
|
||||||
--density-scale: 0.85;
|
--density-scale: 0.85;
|
||||||
}
|
}
|
||||||
@@ -47,27 +53,33 @@
|
|||||||
--density-scale: 1.15;
|
--density-scale: 1.15;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dark mode — deep navy with elevated surfaces */
|
||||||
.dark {
|
.dark {
|
||||||
--background: 222.2 84% 4.9%;
|
--background: 222 47% 5%;
|
||||||
--foreground: 210 40% 98%;
|
--foreground: 210 40% 98%;
|
||||||
--card: 222.2 84% 4.9%;
|
--card: 222 40% 8%;
|
||||||
--card-foreground: 210 40% 98%;
|
--card-foreground: 210 40% 98%;
|
||||||
--popover: 222.2 84% 4.9%;
|
--popover: 222 40% 8%;
|
||||||
--popover-foreground: 210 40% 98%;
|
--popover-foreground: 210 40% 98%;
|
||||||
--primary: 210 40% 98%;
|
--primary: 210 40% 98%;
|
||||||
--primary-foreground: 222.2 47.4% 11.2%;
|
--primary-foreground: 222 47% 11%;
|
||||||
--secondary: 217.2 32.6% 17.5%;
|
--secondary: 217 33% 14%;
|
||||||
--secondary-foreground: 210 40% 98%;
|
--secondary-foreground: 210 40% 98%;
|
||||||
--muted: 217.2 32.6% 17.5%;
|
--muted: 217 33% 14%;
|
||||||
--muted-foreground: 215 20.2% 65.1%;
|
--muted-foreground: 215 20% 65%;
|
||||||
--accent: 217.2 32.6% 17.5%;
|
--accent: 217 33% 14%;
|
||||||
--accent-foreground: 210 40% 98%;
|
--accent-foreground: 210 40% 98%;
|
||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: 0 63% 31%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
--border: 217.2 32.6% 17.5%;
|
--border: 217 33% 17%;
|
||||||
--input: 217.2 32.6% 17.5%;
|
--input: 217 33% 17%;
|
||||||
--ring: 212.7 26.8% 83.9%;
|
--ring: 217 91% 60%;
|
||||||
--accent-hsl: 217 91% 60%;
|
--accent-hsl: 217 91% 60%;
|
||||||
|
|
||||||
|
/* Shell surfaces — slightly elevated from background */
|
||||||
|
--sidebar-bg: 222 42% 7%;
|
||||||
|
--sidebar-border: 217 33% 17%;
|
||||||
|
--topbar-bg: 222 47% 5%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,9 +95,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Real reduced-motion support: the settings page toggles `.reduce-motion` on
|
/* Reduced motion */
|
||||||
<html>. This standard override collapses animation/transition durations so
|
|
||||||
users who enable it get an effectively static UI. */
|
|
||||||
.reduce-motion *,
|
.reduce-motion *,
|
||||||
.reduce-motion *::before,
|
.reduce-motion *::before,
|
||||||
.reduce-motion *::after {
|
.reduce-motion *::after {
|
||||||
@@ -95,11 +105,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
/* Density: multiply the vertical rhythm between stacked sections inside page
|
/* Density: multiply vertical rhythm inside <main> */
|
||||||
content. Mirrors Tailwind's own `.space-y-*` selector shape and is scoped
|
|
||||||
to <main> so the sidebar and topbar stay fixed. Combined with the calc()
|
|
||||||
padding on <main> in the app layout, the Density setting now visibly
|
|
||||||
changes spacing (0.85x compact, 1.15x spacious). */
|
|
||||||
.density-compact main .space-y-1 > :not([hidden]) ~ :not([hidden]),
|
.density-compact main .space-y-1 > :not([hidden]) ~ :not([hidden]),
|
||||||
.density-spacious main .space-y-1 > :not([hidden]) ~ :not([hidden]) {
|
.density-spacious main .space-y-1 > :not([hidden]) ~ :not([hidden]) {
|
||||||
margin-top: calc(0.25rem * var(--density-scale));
|
margin-top: calc(0.25rem * var(--density-scale));
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ export const TASK_STATUS: Record<string, StatusToken> = {
|
|||||||
|
|
||||||
/** Task priority. Badges use a soft tint (matching text + translucent bg). */
|
/** Task priority. Badges use a soft tint (matching text + translucent bg). */
|
||||||
export const PRIORITY: Record<string, { label: string; badge: string }> = {
|
export const PRIORITY: Record<string, { label: string; badge: string }> = {
|
||||||
low: { label: "Low", badge: "text-slate-500 bg-slate-500/10" },
|
low: { label: "Low", badge: "text-slate-600 bg-slate-500/15" },
|
||||||
medium: { label: "Medium", badge: "text-blue-500 bg-blue-500/10" },
|
medium: { label: "Medium", badge: "text-blue-600 bg-blue-500/15" },
|
||||||
high: { label: "High", badge: "text-orange-500 bg-orange-500/10" },
|
high: { label: "High", badge: "text-orange-600 bg-orange-500/15" },
|
||||||
urgent: { label: "Urgent", badge: "text-red-500 bg-red-500/10" },
|
urgent: { label: "Urgent", badge: "text-red-600 bg-red-500/15" },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Project lifecycle statuses. */
|
/** Project lifecycle statuses. */
|
||||||
|
|||||||
@@ -2,33 +2,17 @@ import { create } from "zustand";
|
|||||||
import { persist } from "zustand/middleware";
|
import { persist } from "zustand/middleware";
|
||||||
|
|
||||||
export type ThemeMode = "light" | "dark" | "system";
|
export type ThemeMode = "light" | "dark" | "system";
|
||||||
export type AccentColor = "slate" | "blue" | "green" | "purple" | "orange" | "rose";
|
|
||||||
|
|
||||||
interface ThemeState {
|
interface ThemeState {
|
||||||
mode: ThemeMode;
|
mode: ThemeMode;
|
||||||
accent: AccentColor;
|
|
||||||
|
|
||||||
setMode: (mode: ThemeMode) => void;
|
setMode: (mode: ThemeMode) => void;
|
||||||
setAccent: (accent: AccentColor) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ACCENT_PALETTE: Record<AccentColor, { name: string; hsl: string }> = {
|
|
||||||
slate: { name: "Slate", hsl: "215 16% 47%" },
|
|
||||||
blue: { name: "Blue", hsl: "217 91% 60%" },
|
|
||||||
green: { name: "Green", hsl: "142 71% 45%" },
|
|
||||||
purple: { name: "Purple", hsl: "271 81% 56%" },
|
|
||||||
orange: { name: "Orange", hsl: "24 95% 53%" },
|
|
||||||
rose: { name: "Rose", hsl: "340 82% 52%" },
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useThemeStore = create<ThemeState>()(
|
export const useThemeStore = create<ThemeState>()(
|
||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set) => ({
|
||||||
mode: "dark",
|
mode: "dark",
|
||||||
accent: "blue",
|
|
||||||
|
|
||||||
setMode: (mode) => set({ mode }),
|
setMode: (mode) => set({ mode }),
|
||||||
setAccent: (accent) => set({ accent }),
|
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "project-e-theme",
|
name: "project-e-theme",
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ import { Route as analyticsRoute } from "./routes/_app/analytics";
|
|||||||
import { Route as agentsRoute } from "./routes/_app/agents";
|
import { Route as agentsRoute } from "./routes/_app/agents";
|
||||||
import { Route as agentActivityRoute } from "./routes/_app/agents/activity";
|
import { Route as agentActivityRoute } from "./routes/_app/agents/activity";
|
||||||
import { Route as dailyRoute } from "./routes/_app/daily";
|
import { Route as dailyRoute } from "./routes/_app/daily";
|
||||||
|
import { Route as inboxRoute } from "./routes/_app/inbox";
|
||||||
|
import { Route as reportsRoute } from "./routes/_app/reports";
|
||||||
import { Route as settingsRoute } from "./routes/_app/settings";
|
import { Route as settingsRoute } from "./routes/_app/settings";
|
||||||
|
import { Route as templatesRoute } from "./routes/_app/templates";
|
||||||
import { Route as taskDetailRoute } from "./routes/_app/tasks/$id";
|
import { Route as taskDetailRoute } from "./routes/_app/tasks/$id";
|
||||||
import { Route as habitDetailRoute } from "./routes/_app/habits/$id";
|
import { Route as habitDetailRoute } from "./routes/_app/habits/$id";
|
||||||
import { Route as noteDetailRoute } from "./routes/_app/notes/$id";
|
import { Route as noteDetailRoute } from "./routes/_app/notes/$id";
|
||||||
@@ -33,7 +36,10 @@ const appChildren = [
|
|||||||
agentsRoute,
|
agentsRoute,
|
||||||
agentActivityRoute,
|
agentActivityRoute,
|
||||||
dailyRoute,
|
dailyRoute,
|
||||||
|
inboxRoute,
|
||||||
|
reportsRoute,
|
||||||
settingsRoute,
|
settingsRoute,
|
||||||
|
templatesRoute,
|
||||||
taskDetailRoute,
|
taskDetailRoute,
|
||||||
habitDetailRoute,
|
habitDetailRoute,
|
||||||
noteDetailRoute,
|
noteDetailRoute,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ function AppLayout() {
|
|||||||
<Topbar />
|
<Topbar />
|
||||||
<main
|
<main
|
||||||
id="main-content"
|
id="main-content"
|
||||||
className="flex-1 overflow-auto p-[calc(1rem*var(--density-scale))] md:p-[calc(1.5rem*var(--density-scale))]"
|
className="flex-1 overflow-auto p-[calc(0.75rem*var(--density-scale))] md:p-[calc(1rem*var(--density-scale))]"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ const ACTION_TYPES = [
|
|||||||
{ id: "completed", label: "Completed", color: "bg-purple-500" },
|
{ id: "completed", label: "Completed", color: "bg-purple-500" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const AGENT_COLORS = [
|
||||||
|
"bg-cyan-500", "bg-amber-500", "bg-violet-500", "bg-rose-500",
|
||||||
|
"bg-emerald-500", "bg-orange-500", "bg-sky-500", "bg-pink-500",
|
||||||
|
];
|
||||||
|
|
||||||
function AgentActivityPage() {
|
function AgentActivityPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [agentFilter, setAgentFilter] = useState("");
|
const [agentFilter, setAgentFilter] = useState("");
|
||||||
@@ -114,20 +119,25 @@ function AgentActivityPage() {
|
|||||||
return agent?.name || "Unknown Agent";
|
return agent?.name || "Unknown Agent";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getAgentColor = (agentId: string) => {
|
||||||
|
const idx = agents.findIndex((a) => a.id === agentId);
|
||||||
|
return AGENT_COLORS[idx >= 0 ? idx % AGENT_COLORS.length : 0];
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">Agent Activity</h1>
|
<h1 className="text-xl font-bold">Agent Activity</h1>
|
||||||
<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ["agent-activity"] })}>
|
<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ["agent-activity"] })}>
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />Refresh
|
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Select value={agentFilter} onValueChange={setAgentFilter}>
|
<Select value={agentFilter} onValueChange={setAgentFilter}>
|
||||||
<SelectTrigger className="w-44">
|
<SelectTrigger className="w-44 h-8">
|
||||||
<Bot className="h-4 w-4 mr-2" />
|
<Bot className="h-3.5 w-3.5 mr-1.5" />
|
||||||
<SelectValue placeholder="All agents" />
|
<SelectValue placeholder="All agents" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -138,8 +148,8 @@ function AgentActivityPage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={actionFilter} onValueChange={setActionFilter}>
|
<Select value={actionFilter} onValueChange={setActionFilter}>
|
||||||
<SelectTrigger className="w-36">
|
<SelectTrigger className="w-36 h-8">
|
||||||
<Activity className="h-4 w-4 mr-2" />
|
<Activity className="h-3.5 w-3.5 mr-1.5" />
|
||||||
<SelectValue placeholder="All actions" />
|
<SelectValue placeholder="All actions" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -149,8 +159,8 @@ function AgentActivityPage() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Input type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)} className="w-36 h-9" placeholder="From" />
|
<Input type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)} className="w-36 h-8 font-mono text-xs" placeholder="From" />
|
||||||
<Input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)} className="w-36 h-9" placeholder="To" />
|
<Input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)} className="w-36 h-8 font-mono text-xs" placeholder="To" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Timeline */}
|
{/* Timeline */}
|
||||||
@@ -159,36 +169,36 @@ function AgentActivityPage() {
|
|||||||
) : activities.length === 0 ? (
|
) : activities.length === 0 ? (
|
||||||
<EmptyState title="No activity found" description="Agent activity will appear here as agents run" />
|
<EmptyState title="No activity found" description="Agent activity will appear here as agents run" />
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className="border rounded-lg divide-y divide-border">
|
||||||
{activities.map((a, idx) => (
|
{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 key={a.id || idx} className="flex items-start gap-2.5 px-3 py-2 hover:bg-muted/20 transition-colors">
|
||||||
<div className="flex flex-col items-center shrink-0">
|
{/* Colored agent indicator */}
|
||||||
<Avatar className="h-8 w-8">
|
<div className="flex flex-col items-center shrink-0 pt-0.5">
|
||||||
<AvatarFallback className="text-xs bg-primary/10 text-primary">
|
<div className={cn("w-2 h-2 rounded-full", getAgentColor(a.agentId))} />
|
||||||
{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>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium">{getAgentName(a.agentId)}</span>
|
<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={cn(
|
||||||
<span className="text-sm text-muted-foreground">{a.action}</span>
|
"text-[10px] font-mono px-1.5 py-0.5 rounded",
|
||||||
<Badge variant="outline" className="text-[10px]">{a.entityType}</Badge>
|
getActionColor(a.action) + "/15",
|
||||||
|
"text-foreground"
|
||||||
|
)}>
|
||||||
|
{a.action}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" className="text-[9px] font-mono px-1 py-0">{a.entityType}</Badge>
|
||||||
</div>
|
</div>
|
||||||
{a.description && <p className="text-sm text-muted-foreground mt-0.5">{a.description}</p>}
|
{a.description && <p className="text-xs text-muted-foreground mt-0.5 leading-relaxed">{a.description}</p>}
|
||||||
{!a.description && a.details && Object.keys(a.details).length > 0 && (
|
{!a.description && a.details && Object.keys(a.details).length > 0 && (
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">{JSON.stringify(a.details)}</p>
|
<p className="text-xs text-muted-foreground mt-0.5 leading-relaxed">{JSON.stringify(a.details)}</p>
|
||||||
)}
|
)}
|
||||||
{a.errorMessage && <p className="text-sm text-destructive mt-0.5">{a.errorMessage}</p>}
|
{a.errorMessage && <p className="text-xs text-destructive mt-0.5">{a.errorMessage}</p>}
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
<Clock className="h-3 w-3 text-muted-foreground" />
|
<span className="text-[10px] font-mono text-muted-foreground">{format(parseISO(a.createdAt), "MMM d, HH:mm:ss")}</span>
|
||||||
<span className="text-xs text-muted-foreground">{format(parseISO(a.createdAt), "MMM d, HH:mm:ss")}</span>
|
|
||||||
{a.entityId && (
|
{a.entityId && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-[10px] font-mono text-muted-foreground">
|
||||||
<ExternalLink className="h-3 w-3 inline mr-0.5" />
|
<ExternalLink className="h-2.5 w-2.5 inline mr-0.5" />
|
||||||
{a.entityId.slice(0, 8)}...
|
{a.entityId.slice(0, 8)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -57,9 +57,9 @@ function AnalyticsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">Analytics</h1>
|
<h1 className="text-xl font-bold">Analytics</h1>
|
||||||
<Select value={range} onValueChange={setRange}>
|
<Select value={range} onValueChange={setRange}>
|
||||||
<SelectTrigger className="w-32">
|
<SelectTrigger className="w-32">
|
||||||
<Calendar className="h-4 w-4 mr-2" />
|
<Calendar className="h-4 w-4 mr-2" />
|
||||||
@@ -78,27 +78,27 @@ function AnalyticsPage() {
|
|||||||
) : analyticsError ? (
|
) : analyticsError ? (
|
||||||
<ErrorState message={analyticsError.message || "Failed to load analytics"} onRetry={refetchAnalytics} />
|
<ErrorState message={analyticsError.message || "Failed to load analytics"} onRetry={refetchAnalytics} />
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
{/* Tasks completed per day */}
|
{/* Tasks completed per day */}
|
||||||
<Card>
|
<Card className="border rounded-lg hover:bg-muted/20 transition-colors">
|
||||||
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between">
|
||||||
<CardTitle className="text-sm font-semibold">Tasks Completed</CardTitle>
|
<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"], ...dailyItems.map((d) => [d.date, String(d.completed)])])}><Download className="h-3.5 w-3.5" /></Button>
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("tasks-completed.csv", [["Date", "Completed"], ...dailyItems.map((d) => [d.date, String(d.completed)])])}><Download className="h-3.5 w-3.5" /></Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-3">
|
||||||
<LineChart data={dailyItems} xKey="date" yKey="completed" />
|
<LineChart data={dailyItems} xKey="date" yKey="completed" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Tasks created vs completed */}
|
{/* Tasks created vs completed */}
|
||||||
<Card>
|
<Card className="border rounded-lg hover:bg-muted/20 transition-colors">
|
||||||
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between">
|
||||||
<CardTitle className="text-sm font-semibold">Created vs Completed</CardTitle>
|
<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"], ...dailyItems.map((d) => [d.date, String(d.created), String(d.completed)])])}><Download className="h-3.5 w-3.5" /></Button>
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("tasks-created-vs-completed.csv", [["Date", "Created", "Completed"], ...dailyItems.map((d) => [d.date, String(d.created), String(d.completed)])])}><Download className="h-3.5 w-3.5" /></Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-3">
|
||||||
<BarChart data={dailyItems} xKey="date" yKey="created" yKey2="completed" color="#f97316" color2="#3b82f6" />
|
<BarChart data={dailyItems} xKey="date" yKey="created" yKey2="completed" color="#f97316" color2="#3b82f6" />
|
||||||
<div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-3 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-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>
|
<span className="flex items-center gap-1"><div className="w-2.5 h-2.5 rounded bg-blue-500" /> Completed</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,26 +106,26 @@ function AnalyticsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Habit completion rate */}
|
{/* Habit completion rate */}
|
||||||
<Card>
|
<Card className="border rounded-lg hover:bg-muted/20 transition-colors">
|
||||||
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between">
|
||||||
<CardTitle className="text-sm font-semibold">Habit Completion</CardTitle>
|
<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>
|
<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>
|
</CardHeader>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-3">
|
||||||
<PieChartSimple data={habitRateData} labelKey="name" valueKey="value" />
|
<PieChartSimple data={habitRateData} labelKey="name" valueKey="value" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Project progress */}
|
{/* Project progress */}
|
||||||
<Card>
|
<Card className="border rounded-lg hover:bg-muted/20 transition-colors">
|
||||||
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between">
|
||||||
<CardTitle className="text-sm font-semibold">Project Progress</CardTitle>
|
<CardTitle className="text-sm font-semibold">Project Progress</CardTitle>
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("project-progress.csv", [["Project", "Total Tasks", "Completed", "Progress"], ...(projectData?.projects || []).map((p) => [p.name, String(p.totalTasks), String(p.completedTasks), String(Math.round(p.progress * 100)) + "%"])])}><Download className="h-3.5 w-3.5" /></Button>
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("project-progress.csv", [["Project", "Total Tasks", "Completed", "Progress"], ...(projectData?.projects || []).map((p) => [p.name, String(p.totalTasks), String(p.completedTasks), String(Math.round(p.progress * 100)) + "%"])])}><Download className="h-3.5 w-3.5" /></Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-3">
|
||||||
{projectData?.projects.length ? (
|
{projectData?.projects.length ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs text-muted-foreground">{projectData.totalProjects} project{projectData.totalProjects === 1 ? "" : "s"} · progress is % of tasks done</p>
|
<p className="text-xs text-muted-foreground"><span className="font-mono font-semibold">{projectData.totalProjects}</span> project{projectData.totalProjects === 1 ? "" : "s"} · progress is % of tasks done</p>
|
||||||
<HorizontalBar
|
<HorizontalBar
|
||||||
data={projectData.projects.map((p) => ({ name: p.name, progress: Math.round(p.progress * 100) }))}
|
data={projectData.projects.map((p) => ({ name: p.name, progress: Math.round(p.progress * 100) }))}
|
||||||
xKey="name"
|
xKey="name"
|
||||||
@@ -140,12 +140,12 @@ function AnalyticsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Tasks by project (pie) */}
|
{/* Tasks by project (pie) */}
|
||||||
<Card>
|
<Card className="border rounded-lg hover:bg-muted/20 transition-colors">
|
||||||
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between">
|
||||||
<CardTitle className="text-sm font-semibold">Tasks by Project</CardTitle>
|
<CardTitle className="text-sm font-semibold">Tasks by Project</CardTitle>
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("tasks-by-project.csv", [["Project", "Total Tasks"], ...(projectData?.projects || []).map((p) => [p.name, String(p.totalTasks)])])}><Download className="h-3.5 w-3.5" /></Button>
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("tasks-by-project.csv", [["Project", "Total Tasks"], ...(projectData?.projects || []).map((p) => [p.name, String(p.totalTasks)])])}><Download className="h-3.5 w-3.5" /></Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-3">
|
||||||
{projectData?.projects.length ? (
|
{projectData?.projects.length ? (
|
||||||
<PieChartSimple data={projectData.projects.map((p) => ({ name: p.name, value: p.totalTasks }))} labelKey="name" valueKey="value" />
|
<PieChartSimple data={projectData.projects.map((p) => ({ name: p.name, value: p.totalTasks }))} labelKey="name" valueKey="value" />
|
||||||
) : (
|
) : (
|
||||||
@@ -155,36 +155,36 @@ function AnalyticsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Productivity heatmap */}
|
{/* Productivity heatmap */}
|
||||||
<Card>
|
<Card className="border rounded-lg hover:bg-muted/20 transition-colors">
|
||||||
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between">
|
||||||
<CardTitle className="text-sm font-semibold">Productivity Heatmap</CardTitle>
|
<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"], ...dailyItems.map((d) => [d.date, String(d.completed)])])}><Download className="h-3.5 w-3.5" /></Button>
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("heatmap.csv", [["Date", "Count"], ...dailyItems.map((d) => [d.date, String(d.completed)])])}><Download className="h-3.5 w-3.5" /></Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-3">
|
||||||
<CalendarHeatmap data={dailyItems.map((d) => ({ date: d.date, count: d.completed }))} days={parseInt(range)} />
|
<CalendarHeatmap data={dailyItems.map((d) => ({ date: d.date, count: d.completed }))} days={parseInt(range)} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Velocity */}
|
{/* Velocity */}
|
||||||
<Card>
|
<Card className="border rounded-lg hover:bg-muted/20 transition-colors">
|
||||||
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between">
|
||||||
<CardTitle className="text-sm font-semibold">Velocity</CardTitle>
|
<CardTitle className="text-sm font-semibold">Velocity</CardTitle>
|
||||||
<span className="text-xs text-muted-foreground">avg {velocityData?.avg ?? 0}/day</span>
|
<span className="text-xs text-muted-foreground font-mono">avg <span className="font-semibold">{velocityData?.avg ?? 0}</span>/day</span>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-3">
|
||||||
{velocityData?.items ? <LineChart data={velocityData.items} xKey="date" yKey="completed" /> : <p className="text-sm text-muted-foreground">No data</p>}
|
{velocityData?.items ? <LineChart data={velocityData.items} xKey="date" yKey="completed" /> : <p className="text-sm text-muted-foreground">No data</p>}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Cycle time */}
|
{/* Cycle time */}
|
||||||
<Card>
|
<Card className="border rounded-lg hover:bg-muted/20 transition-colors">
|
||||||
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between">
|
||||||
<CardTitle className="text-sm font-semibold">Cycle Time</CardTitle>
|
<CardTitle className="text-sm font-semibold">Cycle Time</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-3">
|
||||||
<div className="text-center py-4">
|
<div className="text-center py-4">
|
||||||
<p className="text-2xl font-bold">{cycleData ? `${cycleData.median}d median · ${cycleData.avg}d avg` : "—"}</p>
|
<p className="text-lg font-bold font-mono">{cycleData ? <>{cycleData.median}d median · {cycleData.avg}d avg</> : "—"}</p>
|
||||||
<p className="text-xs text-muted-foreground">{cycleData?.count ?? 0} tasks completed in range</p>
|
<p className="text-xs text-muted-foreground font-mono">{cycleData?.count ?? 0} tasks completed in range</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ function CustomToolbar({ date, onNavigate, label }: any) {
|
|||||||
const goToToday = () => onNavigate(Navigate.TODAY);
|
const goToToday = () => onNavigate(Navigate.TODAY);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
|
<div className="flex items-center justify-between mb-2 flex-wrap gap-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button variant="outline" size="icon" onClick={goToBack} aria-label="Previous">
|
<Button variant="outline" size="icon" onClick={goToBack} aria-label="Previous">
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
@@ -88,7 +88,7 @@ function CustomToolbar({ date, onNavigate, label }: any) {
|
|||||||
<Button variant="outline" size="icon" onClick={goToNext} aria-label="Next">
|
<Button variant="outline" size="icon" onClick={goToNext} aria-label="Next">
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="ml-2 text-lg font-semibold">{label}</span>
|
<span className="ml-2 text-lg font-semibold font-mono">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -132,7 +132,7 @@ function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => v
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="title">Title</Label>
|
<Label htmlFor="title">Title</Label>
|
||||||
<Input id="title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Event title" required />
|
<Input id="title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Event title" required />
|
||||||
@@ -141,7 +141,7 @@ function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => v
|
|||||||
<input type="checkbox" id="allDay" checked={allDay} onChange={(e) => setAllDay(e.target.checked)} className="rounded" />
|
<input type="checkbox" id="allDay" checked={allDay} onChange={(e) => setAllDay(e.target.checked)} className="rounded" />
|
||||||
<Label htmlFor="allDay">All day</Label>
|
<Label htmlFor="allDay">All day</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="start">Start</Label>
|
<Label htmlFor="start">Start</Label>
|
||||||
<Input id="start" type={allDay ? "date" : "datetime-local"} value={startTime} onChange={(e) => setStartTime(e.target.value)} required />
|
<Input id="start" type={allDay ? "date" : "datetime-local"} value={startTime} onChange={(e) => setStartTime(e.target.value)} required />
|
||||||
@@ -276,9 +276,9 @@ function CalendarPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">Calendar</h1>
|
<h1 className="text-xl font-bold">Calendar</h1>
|
||||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button aria-label="New event"><Plus className="h-4 w-4 mr-2" />New Event</Button>
|
<Button aria-label="New event"><Plus className="h-4 w-4 mr-2" />New Event</Button>
|
||||||
@@ -358,10 +358,10 @@ function CalendarPage() {
|
|||||||
<DialogTitle>{selectedEvent?.title || "Event"}</DialogTitle>
|
<DialogTitle>{selectedEvent?.title || "Event"}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{selectedEvent && (
|
{selectedEvent && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<p><strong>Start:</strong> {new Date(selectedEvent.startTime).toLocaleString()}</p>
|
<p><strong>Start:</strong> <span className="font-mono">{new Date(selectedEvent.startTime).toLocaleString()}</span></p>
|
||||||
{selectedEvent.endTime && <p><strong>End:</strong> {new Date(selectedEvent.endTime).toLocaleString()}</p>}
|
{selectedEvent.endTime && <p><strong>End:</strong> <span className="font-mono">{new Date(selectedEvent.endTime).toLocaleString()}</span></p>}
|
||||||
{selectedEvent.description && <p className="mt-2">{selectedEvent.description}</p>}
|
{selectedEvent.description && <p className="mt-2">{selectedEvent.description}</p>}
|
||||||
</div>
|
</div>
|
||||||
<EventForm event={selectedEvent} onClose={() => setEventDetailOpen(false)} />
|
<EventForm event={selectedEvent} onClose={() => setEventDetailOpen(false)} />
|
||||||
|
|||||||
@@ -38,19 +38,19 @@ function CalendarSidebar({ selectedDate, onSelectDate }: { selectedDate: Date; o
|
|||||||
const noteDates = new Set(notes.map((n) => n.date.slice(0, 10)));
|
const noteDates = new Set(notes.map((n) => n.date.slice(0, 10)));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-64 shrink-0">
|
<div className="w-56 shrink-0">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-sm font-semibold">{format(currentMonth, "MMMM yyyy")}</span>
|
<span className="text-xs font-semibold font-mono">{format(currentMonth, "MMM yyyy")}</span>
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-7 gap-0.5 text-center">
|
<div className="grid grid-cols-7 gap-px text-center">
|
||||||
{dayNames.map((d) => (
|
{dayNames.map((d) => (
|
||||||
<div key={d} className="text-[10px] text-muted-foreground font-medium py-1">{d}</div>
|
<div key={d} className="text-[9px] text-muted-foreground font-mono font-medium py-0.5">{d}</div>
|
||||||
))}
|
))}
|
||||||
{Array.from({ length: startDay }).map((_, i) => (
|
{Array.from({ length: startDay }).map((_, i) => (
|
||||||
<div key={"empty-" + i} />
|
<div key={"empty-" + i} />
|
||||||
@@ -65,21 +65,25 @@ function CalendarSidebar({ selectedDate, onSelectDate }: { selectedDate: Date; o
|
|||||||
key={key}
|
key={key}
|
||||||
onClick={() => onSelectDate(d)}
|
onClick={() => onSelectDate(d)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 w-8 rounded-full text-xs flex items-center justify-center transition-colors relative",
|
"h-7 w-7 rounded text-[11px] font-mono 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",
|
isSelected
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: today
|
||||||
|
? "bg-primary/10 text-primary font-semibold"
|
||||||
|
: "hover:bg-muted/40 text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{format(d, "d")}
|
{format(d, "d")}
|
||||||
{hasNote && !isSelected && (
|
{hasNote && !isSelected && (
|
||||||
<div className="absolute bottom-0.5 w-1 h-1 rounded-full bg-primary" />
|
<div className="absolute bottom-0.5 w-1 h-1 rounded-full bg-emerald-500" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3">
|
<div className="mt-2">
|
||||||
<Button variant="outline" size="sm" className="w-full" onClick={() => onSelectDate(new Date())}>
|
<Button variant="outline" size="sm" className="w-full h-7 text-xs" onClick={() => onSelectDate(new Date())}>
|
||||||
<Calendar className="h-3.5 w-3.5 mr-2" />Today
|
<Calendar className="h-3 w-3 mr-1.5" />Today
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -247,19 +251,19 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 space-y-4">
|
<div className="flex-1 space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-xl font-bold">{format(date, "EEEE, MMMM d, yyyy")}</h2>
|
<h2 className="text-xl font-bold font-mono">{format(date, "EEE, MMM d, yyyy")}</h2>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{noteId && (
|
{noteId && (
|
||||||
<>
|
<>
|
||||||
<Badge variant="secondary" className="text-[10px]">
|
<Badge variant="secondary" className="text-[10px] font-mono">
|
||||||
<Save className="h-3 w-3 mr-1" />Saved
|
<Save className="h-3 w-3 mr-1" />Saved
|
||||||
</Badge>
|
</Badge>
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" aria-label="Delete daily note">
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" aria-label="Delete daily note">
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
@@ -279,10 +283,10 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mood & Energy */}
|
{/* Mood & Energy */}
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-5">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground mb-1">Mood</p>
|
<p className="text-[10px] text-muted-foreground font-mono uppercase tracking-wider mb-1">Mood</p>
|
||||||
<div role="radiogroup" aria-label="Mood" className="flex gap-1">
|
<div role="radiogroup" aria-label="Mood" className="flex gap-0.5">
|
||||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => (
|
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => (
|
||||||
<button
|
<button
|
||||||
key={v}
|
key={v}
|
||||||
@@ -291,7 +295,7 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
|||||||
aria-label={`Mood ${v}`}
|
aria-label={`Mood ${v}`}
|
||||||
onClick={() => handleMoodChange(v)}
|
onClick={() => handleMoodChange(v)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-6 h-6 rounded text-[10px] font-medium transition-colors",
|
"w-5 h-5 rounded text-[9px] font-mono font-medium transition-colors",
|
||||||
mood === v ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/70 text-muted-foreground"
|
mood === v ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/70 text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -301,8 +305,8 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground mb-1">Energy</p>
|
<p className="text-[10px] text-muted-foreground font-mono uppercase tracking-wider mb-1">Energy</p>
|
||||||
<div role="radiogroup" aria-label="Energy" className="flex gap-1">
|
<div role="radiogroup" aria-label="Energy" className="flex gap-0.5">
|
||||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => (
|
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => (
|
||||||
<button
|
<button
|
||||||
key={v}
|
key={v}
|
||||||
@@ -311,7 +315,7 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
|||||||
aria-label={`Energy ${v}`}
|
aria-label={`Energy ${v}`}
|
||||||
onClick={() => handleEnergyChange(v)}
|
onClick={() => handleEnergyChange(v)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-6 h-6 rounded text-[10px] font-medium transition-colors",
|
"w-5 h-5 rounded text-[9px] font-mono font-medium transition-colors",
|
||||||
energy === v ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/70 text-muted-foreground"
|
energy === v ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/70 text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -326,16 +330,16 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
|||||||
|
|
||||||
{/* Editor */}
|
{/* Editor */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading...</div>
|
<div className="flex items-center justify-center py-10 text-muted-foreground text-sm">Loading...</div>
|
||||||
) : isNew && !content ? (
|
) : isNew && !content ? (
|
||||||
<div className="text-center py-8 space-y-4">
|
<div className="text-center py-6 space-y-3">
|
||||||
<p className="text-muted-foreground">No note for this day</p>
|
<p className="text-muted-foreground text-sm">No note for this day</p>
|
||||||
{tasksDue.length > 0 && <p className="text-xs text-muted-foreground">{tasksDue.length} tasks due · {habitsToday.length} habits</p>}
|
{tasksDue.length > 0 && <p className="text-[10px] font-mono text-muted-foreground">{tasksDue.length} tasks due · {habitsToday.length} habits</p>}
|
||||||
<div className="flex gap-2 justify-center">
|
<div className="flex gap-2 justify-center">
|
||||||
<Button variant="outline" onClick={() => textareaRef.current?.focus()}>
|
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => textareaRef.current?.focus()}>
|
||||||
<Plus className="h-4 w-4 mr-2" />Start Writing
|
<Plus className="h-3 w-3 mr-1" />Start Writing
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" onClick={insertTemplate}>Insert Daily Template</Button>
|
<Button variant="secondary" size="sm" className="h-7 text-xs" onClick={insertTemplate}>Insert Daily Template</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -344,7 +348,7 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
|||||||
value={content}
|
value={content}
|
||||||
onChange={(e) => handleContentChange(e.target.value)}
|
onChange={(e) => handleContentChange(e.target.value)}
|
||||||
placeholder="Write your daily note here..."
|
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"
|
className="w-full min-h-[280px] bg-transparent border-none outline-none resize-none text-sm font-mono leading-relaxed placeholder:text-muted-foreground/50"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -357,10 +361,10 @@ function DailyNotesPage() {
|
|||||||
const [selectedDate, setSelectedDate] = useState(new Date());
|
const [selectedDate, setSelectedDate] = useState(new Date());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-6 h-[calc(100vh-5rem)]">
|
<div className="flex gap-4 h-[calc(100vh-5rem)]">
|
||||||
<CalendarSidebar selectedDate={selectedDate} onSelectDate={setSelectedDate} />
|
<CalendarSidebar selectedDate={selectedDate} onSelectDate={setSelectedDate} />
|
||||||
<Separator orientation="vertical" />
|
<Separator orientation="vertical" />
|
||||||
<ScrollArea className="flex-1 pr-4">
|
<ScrollArea className="flex-1 pr-3">
|
||||||
<DailyNoteEditor date={selectedDate} />
|
<DailyNoteEditor date={selectedDate} />
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { createRoute } from "@tanstack/react-router";
|
import { createRoute } from "@tanstack/react-router";
|
||||||
import { Route as appRoute } from ".";
|
import { Route as appRoute } from "../_app";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { api, useApiQuery } from "@/lib/api";
|
import { api, useApiQuery } from "@/lib/api";
|
||||||
import { useRealtime } from "@/hooks/use-realtime";
|
import { useRealtime } from "@/hooks/use-realtime";
|
||||||
@@ -10,7 +10,6 @@ import { toast } from "sonner";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
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 { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -21,16 +20,26 @@ import type { DashboardWidget, Task, Habit, Note, Project, CalendarEvent, Pagina
|
|||||||
import { format, isToday, isPast, addDays, parseISO } from "date-fns";
|
import { format, isToday, isPast, addDays, parseISO } from "date-fns";
|
||||||
|
|
||||||
const WIDGET_TYPES = [
|
const WIDGET_TYPES = [
|
||||||
{ id: "tasks_due", label: "Tasks Due Today", icon: ListTodo, defaultW: 2, defaultH: 2 },
|
{ id: "tasks_due", label: "Tasks Due Today", icon: ListTodo, defaultW: 2, defaultH: 2, accent: "text-amber-500", border: "border-l-amber-500" },
|
||||||
{ id: "habits_today", label: "Habits Today", icon: Flame, defaultW: 2, defaultH: 2 },
|
{ id: "habits_today", label: "Habits Today", icon: Flame, defaultW: 2, defaultH: 2, accent: "text-orange-500", border: "border-l-orange-500" },
|
||||||
{ id: "recent_notes", label: "Recent Notes", icon: FileText, defaultW: 2, defaultH: 2 },
|
{ id: "recent_notes", label: "Recent Notes", icon: FileText, defaultW: 2, defaultH: 2, accent: "text-blue-500", border: "border-l-blue-500" },
|
||||||
{ id: "active_projects", label: "Active Projects", icon: FolderKanban, defaultW: 2, defaultH: 2 },
|
{ id: "active_projects", label: "Active Projects", icon: FolderKanban, defaultW: 2, defaultH: 2, accent: "text-purple-500", border: "border-l-purple-500" },
|
||||||
{ id: "upcoming_events", label: "Upcoming Events", icon: Calendar, defaultW: 2, defaultH: 2 },
|
{ id: "upcoming_events", label: "Upcoming Events", icon: Calendar, defaultW: 2, defaultH: 2, accent: "text-sky-500", border: "border-l-sky-500" },
|
||||||
{ id: "streak_counter", label: "Streak Counter", icon: Flame, defaultW: 1, defaultH: 1 },
|
{ id: "streak_counter", label: "Streak Counter", icon: Flame, defaultW: 1, defaultH: 1, accent: "text-orange-500", border: "border-l-orange-500" },
|
||||||
{ id: "quick_capture", label: "Quick Capture", icon: Zap, defaultW: 2, defaultH: 1 },
|
{ id: "quick_capture", label: "Quick Capture", icon: Zap, defaultW: 2, defaultH: 1, accent: "text-yellow-500", border: "border-l-yellow-500" },
|
||||||
{ id: "productivity_chart", label: "Productivity Chart", icon: TrendingUp, defaultW: 3, defaultH: 2 },
|
{ id: "productivity_chart", label: "Productivity Chart", icon: TrendingUp, defaultW: 3, defaultH: 2, accent: "text-green-500", border: "border-l-green-500" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
function getWidgetAccent(type: string) {
|
||||||
|
const found = WIDGET_TYPES.find((t) => t.id === type);
|
||||||
|
return found?.accent || "text-muted-foreground";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWidgetBorder(type: string) {
|
||||||
|
const found = WIDGET_TYPES.find((t) => t.id === type);
|
||||||
|
return found?.border || "border-l-muted-foreground";
|
||||||
|
}
|
||||||
|
|
||||||
function TasksDueWidget() {
|
function TasksDueWidget() {
|
||||||
const activeDomainId = useApiDomain();
|
const activeDomainId = useApiDomain();
|
||||||
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
||||||
@@ -39,16 +48,16 @@ function TasksDueWidget() {
|
|||||||
const today = tasks.filter((t) => t.dueDate && isToday(parseISO(t.dueDate)));
|
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");
|
const overdue = tasks.filter((t) => t.dueDate && isPast(parseISO(t.dueDate)) && t.status !== "done");
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
{today.length === 0 && overdue.length === 0 ? (
|
{today.length === 0 && overdue.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No tasks due today</p>
|
<p className="text-xs text-muted-foreground">No tasks due today</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{overdue.length > 0 && (
|
{overdue.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold text-destructive mb-1">Overdue ({overdue.length})</p>
|
<p className="text-[10px] font-semibold text-destructive mb-0.5">Overdue ({overdue.length})</p>
|
||||||
{overdue.slice(0, 3).map((t) => (
|
{overdue.slice(0, 3).map((t) => (
|
||||||
<div key={t.id} className="flex items-center gap-2 text-sm py-1">
|
<div key={t.id} className="flex items-center gap-1.5 text-xs py-0.5">
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-destructive shrink-0" />
|
<div className="w-1.5 h-1.5 rounded-full bg-destructive shrink-0" />
|
||||||
<span className="truncate flex-1">{t.title}</span>
|
<span className="truncate flex-1">{t.title}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -57,9 +66,9 @@ function TasksDueWidget() {
|
|||||||
)}
|
)}
|
||||||
{today.length > 0 && (
|
{today.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold text-amber-500 mb-1">Today ({today.length})</p>
|
<p className="text-[10px] font-semibold text-amber-500 mb-0.5">Today ({today.length})</p>
|
||||||
{today.slice(0, 5).map((t) => (
|
{today.slice(0, 5).map((t) => (
|
||||||
<div key={t.id} className="flex items-center gap-2 text-sm py-1">
|
<div key={t.id} className="flex items-center gap-1.5 text-xs py-0.5">
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-amber-500 shrink-0" />
|
<div className="w-1.5 h-1.5 rounded-full bg-amber-500 shrink-0" />
|
||||||
<span className="truncate flex-1">{t.title}</span>
|
<span className="truncate flex-1">{t.title}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,9 +97,9 @@ function HabitsTodayWidget() {
|
|||||||
onError: (err) => toast.error(err.message || "Failed to complete habit"),
|
onError: (err) => toast.error(err.message || "Failed to complete habit"),
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-0.5">
|
||||||
{habits.length === 0 ? (
|
{habits.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No habits yet</p>
|
<p className="text-xs text-muted-foreground">No habits yet</p>
|
||||||
) : (
|
) : (
|
||||||
habits.slice(0, 6).map((h) => {
|
habits.slice(0, 6).map((h) => {
|
||||||
const doneToday = (h.recentCompletions || []).some((c) => {
|
const doneToday = (h.recentCompletions || []).some((c) => {
|
||||||
@@ -99,18 +108,18 @@ function HabitsTodayWidget() {
|
|||||||
return d.getUTCFullYear() === today.getUTCFullYear() && d.getUTCMonth() === today.getUTCMonth() && d.getUTCDate() === today.getUTCDate();
|
return d.getUTCFullYear() === today.getUTCFullYear() && d.getUTCMonth() === today.getUTCMonth() && d.getUTCDate() === today.getUTCDate();
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<div key={h.id} className="flex items-center gap-2 py-1">
|
<div key={h.id} className="flex items-center gap-1.5 py-0.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => completeMutation.mutate(h.id)}
|
onClick={() => completeMutation.mutate(h.id)}
|
||||||
className={cn("w-4 h-4 rounded border shrink-0 flex items-center justify-center", doneToday ? "bg-green-500 border-green-500" : "border-muted-foreground/30 hover:border-primary")}
|
className={cn("w-3.5 h-3.5 rounded border shrink-0 flex items-center justify-center", doneToday ? "bg-green-500 border-green-500" : "border-muted-foreground/30 hover:border-primary")}
|
||||||
aria-label={doneToday ? h.name + " (completed)" : "Complete " + h.name}
|
aria-label={doneToday ? h.name + " (completed)" : "Complete " + h.name}
|
||||||
>
|
>
|
||||||
{doneToday && <span className="text-[10px] text-white">\u2713</span>}
|
{doneToday && <span className="text-[8px] text-white">\u2713</span>}
|
||||||
</button>
|
</button>
|
||||||
<span className="text-sm truncate flex-1">{h.name}</span>
|
<span className="text-xs truncate flex-1">{h.name}</span>
|
||||||
{h.streakCount > 0 && (
|
{h.streakCount > 0 && (
|
||||||
<Badge variant="secondary" className="text-[10px] shrink-0">
|
<Badge variant="secondary" className="text-[9px] px-1 shrink-0">
|
||||||
<Flame className="h-2.5 w-2.5 mr-0.5" />{h.streakCount}
|
<Flame className="h-2 w-2 mr-0" />{h.streakCount}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -127,14 +136,14 @@ function RecentNotesWidget() {
|
|||||||
const { data } = useApiQuery<PaginatedResponse<Note>>(["recent-notes", activeDomainId], "/notes?limit=5&sort=-updated" + domainSuffix);
|
const { data } = useApiQuery<PaginatedResponse<Note>>(["recent-notes", activeDomainId], "/notes?limit=5&sort=-updated" + domainSuffix);
|
||||||
const notes = data?.items || [];
|
const notes = data?.items || [];
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
{notes.length === 0 ? (
|
{notes.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No notes yet</p>
|
<p className="text-xs text-muted-foreground">No notes yet</p>
|
||||||
) : (
|
) : (
|
||||||
notes.map((n) => (
|
notes.map((n) => (
|
||||||
<div key={n.id} className="text-sm py-1 border-b last:border-0">
|
<div key={n.id} className="text-xs py-1 border-b last:border-0">
|
||||||
<p className="font-medium truncate">{n.title}</p>
|
<p className="font-medium truncate">{n.title}</p>
|
||||||
<p className="text-xs text-muted-foreground">{format(parseISO(n.updatedAt), "MMM d, HH:mm")}</p>
|
<p className="text-[10px] text-muted-foreground">{format(parseISO(n.updatedAt), "MMM d, HH:mm")}</p>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -148,17 +157,17 @@ function ActiveProjectsWidget() {
|
|||||||
const { data } = useApiQuery<PaginatedResponse<Project>>(["active-projects", activeDomainId], "/projects?limit=10&status=active" + domainSuffix);
|
const { data } = useApiQuery<PaginatedResponse<Project>>(["active-projects", activeDomainId], "/projects?limit=10&status=active" + domainSuffix);
|
||||||
const projects = data?.items || [];
|
const projects = data?.items || [];
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
{projects.length === 0 ? (
|
{projects.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No active projects</p>
|
<p className="text-xs text-muted-foreground">No active projects</p>
|
||||||
) : (
|
) : (
|
||||||
projects.slice(0, 5).map((p) => (
|
projects.slice(0, 5).map((p) => (
|
||||||
<div key={p.id} className="space-y-1">
|
<div key={p.id} className="space-y-0.5">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-xs">
|
||||||
<span className="truncate flex-1">{p.name}</span>
|
<span className="truncate flex-1">{p.name}</span>
|
||||||
<span className="text-xs text-muted-foreground shrink-0 ml-2">{p.progress || 0}%</span>
|
<span className="text-[10px] font-mono text-muted-foreground shrink-0 ml-2">{p.progress || 0}%</span>
|
||||||
</div>
|
</div>
|
||||||
<Progress value={p.progress || 0} className="h-1.5" />
|
<Progress value={p.progress || 0} className="h-1" />
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -178,15 +187,15 @@ function UpcomingEventsWidget() {
|
|||||||
return start >= now && start <= weekFromNow;
|
return start >= now && start <= weekFromNow;
|
||||||
}).sort((a, b) => parseISO(a.startTime).getTime() - parseISO(b.startTime).getTime());
|
}).sort((a, b) => parseISO(a.startTime).getTime() - parseISO(b.startTime).getTime());
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
{upcoming.length === 0 ? (
|
{upcoming.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No upcoming events</p>
|
<p className="text-xs text-muted-foreground">No upcoming events</p>
|
||||||
) : (
|
) : (
|
||||||
upcoming.slice(0, 5).map((e) => (
|
upcoming.slice(0, 5).map((e) => (
|
||||||
<div key={e.id} className="flex items-center gap-2 text-sm py-1">
|
<div key={e.id} className="flex items-center gap-1.5 text-xs py-0.5">
|
||||||
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: e.color || "#3b82f6" }} />
|
<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="truncate flex-1">{e.title}</span>
|
||||||
<span className="text-xs text-muted-foreground shrink-0">{format(parseISO(e.startTime), "MMM d, HH:mm")}</span>
|
<span className="text-[10px] text-muted-foreground shrink-0">{format(parseISO(e.startTime), "MMM d, HH:mm")}</span>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -202,16 +211,16 @@ function StreakCounterWidget() {
|
|||||||
const bestStreak = Math.max(...habits.map((h) => h.streakCount || 0), 0);
|
const bestStreak = Math.max(...habits.map((h) => h.streakCount || 0), 0);
|
||||||
const totalActive = habits.filter((h) => h.streakCount > 0).length;
|
const totalActive = habits.filter((h) => h.streakCount > 0).length;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-4 h-full">
|
<div className="flex items-center gap-3 h-full">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<Flame className="h-8 w-8 text-orange-500" />
|
<Flame className="h-6 w-6 text-orange-500" />
|
||||||
<span className="text-2xl font-bold">{bestStreak}</span>
|
<span className="text-xl font-mono font-semibold">{bestStreak}</span>
|
||||||
<span className="text-xs text-muted-foreground">Best streak</span>
|
<span className="text-[10px] text-muted-foreground">Best streak</span>
|
||||||
</div>
|
</div>
|
||||||
<Separator orientation="vertical" />
|
<Separator orientation="vertical" />
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<span className="text-2xl font-bold">{totalActive}</span>
|
<span className="text-xl font-mono font-semibold">{totalActive}</span>
|
||||||
<span className="text-xs text-muted-foreground">Active streaks</span>
|
<span className="text-[10px] text-muted-foreground">Active streaks</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -248,18 +257,18 @@ function QuickCaptureWidget() {
|
|||||||
else createNote.mutate(text.trim());
|
else createNote.mutate(text.trim());
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="flex gap-2">
|
<form onSubmit={handleSubmit} className="flex gap-1.5">
|
||||||
<div className="flex-1 flex gap-2">
|
<div className="flex-1 flex gap-1.5">
|
||||||
<Select value={type} onValueChange={(v) => setType(v as "task" | "note")}>
|
<Select value={type} onValueChange={(v) => setType(v as "task" | "note")}>
|
||||||
<SelectTrigger className="w-20 h-9"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="w-18 h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="task">Task</SelectItem>
|
<SelectItem value="task">Task</SelectItem>
|
||||||
<SelectItem value="note">Note</SelectItem>
|
<SelectItem value="note">Note</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Input placeholder="Quick capture..." value={text} onChange={(e) => setText(e.target.value)} className="h-9" />
|
<Input placeholder="Quick capture..." value={text} onChange={(e) => setText(e.target.value)} className="h-8 font-mono text-xs" />
|
||||||
</div>
|
</div>
|
||||||
<Button type="button" size="sm" className="h-9" disabled={!text.trim() || createTask.isPending || createNote.isPending} onClick={handleSubmit}>Add</Button>
|
<Button type="button" size="sm" className="h-8 px-2.5 text-xs" disabled={!text.trim() || createTask.isPending || createNote.isPending} onClick={handleSubmit}>Add</Button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -269,24 +278,24 @@ function ProductivityChartWidget() {
|
|||||||
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
||||||
const { data } = useApiQuery<any>(["productivity-chart", activeDomainId], "/analytics/productivity?range=30" + domainSuffix);
|
const { data } = useApiQuery<any>(["productivity-chart", activeDomainId], "/analytics/productivity?range=30" + domainSuffix);
|
||||||
const stats = data;
|
const stats = data;
|
||||||
if (!stats) return <p className="text-sm text-muted-foreground">Loading...</p>;
|
if (!stats) return <p className="text-xs text-muted-foreground">Loading...</p>;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
<div className="text-center p-2 bg-muted/50 rounded">
|
<div className="text-center p-1.5 bg-muted/30 rounded">
|
||||||
<p className="text-lg font-bold">{stats.totalTasks || 0}</p>
|
<p className="text-base font-mono font-semibold">{stats.totalTasks || 0}</p>
|
||||||
<p className="text-[10px] text-muted-foreground">Total</p>
|
<p className="text-[9px] text-muted-foreground">Total</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center p-2 bg-muted/50 rounded">
|
<div className="text-center p-1.5 bg-muted/30 rounded">
|
||||||
<p className="text-lg font-bold text-green-500">{stats.completedTasks || 0}</p>
|
<p className="text-base font-mono font-semibold text-green-500">{stats.completedTasks || 0}</p>
|
||||||
<p className="text-[10px] text-muted-foreground">Done</p>
|
<p className="text-[9px] text-muted-foreground">Done</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center p-2 bg-muted/50 rounded">
|
<div className="text-center p-1.5 bg-muted/30 rounded">
|
||||||
<p className="text-lg font-bold">{stats.taskCompletionRate || 0}%</p>
|
<p className="text-base font-mono font-semibold">{stats.taskCompletionRate || 0}%</p>
|
||||||
<p className="text-[10px] text-muted-foreground">Rate</p>
|
<p className="text-[9px] text-muted-foreground">Rate</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground text-center">Last {stats.period || 30} days</p>
|
<p className="text-[10px] text-muted-foreground text-center">Last {stats.period || 30} days</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -296,20 +305,20 @@ function StatsWidget() {
|
|||||||
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
||||||
const { data } = useApiQuery<any>(["stats", activeDomainId], "/analytics/productivity?range=30" + domainSuffix);
|
const { data } = useApiQuery<any>(["stats", activeDomainId], "/analytics/productivity?range=30" + domainSuffix);
|
||||||
const stats = data;
|
const stats = data;
|
||||||
if (!stats) return <p className="text-sm text-muted-foreground">Loading...</p>;
|
if (!stats) return <p className="text-xs text-muted-foreground">Loading...</p>;
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-3 gap-3 h-full items-center">
|
<div className="grid grid-cols-3 gap-2 h-full items-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-2xl font-bold">{stats.totalTasks || 0}</p>
|
<p className="text-xl font-mono font-semibold">{stats.totalTasks || 0}</p>
|
||||||
<p className="text-xs text-muted-foreground">Total Tasks</p>
|
<p className="text-[10px] text-muted-foreground">Total Tasks</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-2xl font-bold text-green-500">{stats.completedTasks || 0}</p>
|
<p className="text-xl font-mono font-semibold text-green-500">{stats.completedTasks || 0}</p>
|
||||||
<p className="text-xs text-muted-foreground">Completed</p>
|
<p className="text-[10px] text-muted-foreground">Completed</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-2xl font-bold">{stats.taskCompletionRate || 0}%</p>
|
<p className="text-xl font-mono font-semibold">{stats.taskCompletionRate || 0}%</p>
|
||||||
<p className="text-xs text-muted-foreground">Rate</p>
|
<p className="text-[10px] text-muted-foreground">Rate</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -326,7 +335,7 @@ function WidgetRenderer({ type }: { type: string }) {
|
|||||||
case "quick_capture": return <QuickCaptureWidget />;
|
case "quick_capture": return <QuickCaptureWidget />;
|
||||||
case "productivity_chart": return <ProductivityChartWidget />;
|
case "productivity_chart": return <ProductivityChartWidget />;
|
||||||
case "stats": return <StatsWidget />;
|
case "stats": return <StatsWidget />;
|
||||||
default: return <p className="text-sm text-muted-foreground">Unknown widget: {type}</p>;
|
default: return <p className="text-xs text-muted-foreground">Unknown widget: {type}</p>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,24 +343,27 @@ function WidgetCard({ widget, onConfigure, onDelete }: { widget: DashboardWidget
|
|||||||
const typeInfo = WIDGET_TYPES.find((t) => t.id === widget.type);
|
const typeInfo = WIDGET_TYPES.find((t) => t.id === widget.type);
|
||||||
const Icon = typeInfo?.icon || Target;
|
const Icon = typeInfo?.icon || Target;
|
||||||
return (
|
return (
|
||||||
<Card
|
<div
|
||||||
className="h-full flex flex-col group lg:[grid-column:span_var(--w)] lg:[grid-row:span_var(--h)]"
|
className={cn(
|
||||||
|
"h-full flex flex-col group border rounded-lg border-l-[3px] transition-colors hover:bg-muted/20 lg:[grid-column:span_var(--w)] lg:[grid-row:span_var(--h)]",
|
||||||
|
getWidgetBorder(widget.type)
|
||||||
|
)}
|
||||||
style={{ "--w": Math.min(widget.layout.w || 2, 4), "--h": widget.layout.h || 2 } as React.CSSProperties}
|
style={{ "--w": Math.min(widget.layout.w || 2, 4), "--h": widget.layout.h || 2 } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between gap-2">
|
<div className="px-2.5 pt-2.5 pb-0 flex flex-row items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
<Icon className={cn("h-3.5 w-3.5 shrink-0", getWidgetAccent(widget.type))} />
|
||||||
<CardTitle className="text-sm font-semibold truncate">{widget.title || typeInfo?.label || widget.type}</CardTitle>
|
<span className="text-xs font-semibold truncate">{widget.title || typeInfo?.label || widget.type}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
<div className="flex items-center gap-0.5 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-5 w-5" onClick={onConfigure} aria-label="Configure widget"><Settings2 className="h-3 w-3" /></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>
|
<Button variant="ghost" size="icon" className="h-5 w-5 text-destructive" onClick={onDelete} aria-label="Remove widget"><Trash2 className="h-3 w-3" /></Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className="p-3 flex-1 overflow-auto">
|
<div className="p-2.5 flex-1 overflow-auto">
|
||||||
<WidgetRenderer type={widget.type} />
|
<WidgetRenderer type={widget.type} />
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,15 +371,15 @@ function AddWidgetDialog({ open, onOpenChange, onAdd }: { open: boolean; onOpenC
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader><DialogTitle>Add Widget</DialogTitle></DialogHeader>
|
<DialogHeader><DialogTitle className="text-sm">Add Widget</DialogTitle></DialogHeader>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{WIDGET_TYPES.map((wt) => {
|
{WIDGET_TYPES.map((wt) => {
|
||||||
const Icon = wt.icon;
|
const Icon = wt.icon;
|
||||||
return (
|
return (
|
||||||
<button key={wt.id} onClick={() => { onAdd(wt.id); onOpenChange(false); }}
|
<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">
|
className="flex flex-col items-center gap-1.5 p-3 rounded-lg border hover:bg-accent hover:border-primary transition-colors text-center">
|
||||||
<Icon className="h-6 w-6" />
|
<Icon className={cn("h-5 w-5", wt.accent)} />
|
||||||
<span className="text-xs font-medium">{wt.label}</span>
|
<span className="text-[10px] font-medium">{wt.label}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -388,17 +400,17 @@ function ConfigureWidgetDialog({ widget, open, onOpenChange, onSave }: { widget:
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader><DialogTitle>Configure Widget</DialogTitle></DialogHeader>
|
<DialogHeader><DialogTitle className="text-sm">Configure Widget</DialogTitle></DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="widget-title">Title</Label>
|
<Label htmlFor="widget-title" className="text-xs">Title</Label>
|
||||||
<Input id="widget-title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Widget title" />
|
<Input id="widget-title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Widget title" className="h-8 text-xs mt-1" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="widget-w">Width (columns)</Label>
|
<Label htmlFor="widget-w" className="text-xs">Width (columns)</Label>
|
||||||
<Select value={String(w)} onValueChange={(v) => setW(parseInt(v))}>
|
<Select value={String(w)} onValueChange={(v) => setW(parseInt(v))}>
|
||||||
<SelectTrigger id="widget-w"><SelectValue /></SelectTrigger>
|
<SelectTrigger id="widget-w" className="h-8 text-xs mt-1"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="1">1 column</SelectItem>
|
<SelectItem value="1">1 column</SelectItem>
|
||||||
<SelectItem value="2">2 columns</SelectItem>
|
<SelectItem value="2">2 columns</SelectItem>
|
||||||
@@ -408,9 +420,9 @@ function ConfigureWidgetDialog({ widget, open, onOpenChange, onSave }: { widget:
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="widget-h">Height (rows)</Label>
|
<Label htmlFor="widget-h" className="text-xs">Height (rows)</Label>
|
||||||
<Select value={String(h)} onValueChange={(v) => setH(parseInt(v))}>
|
<Select value={String(h)} onValueChange={(v) => setH(parseInt(v))}>
|
||||||
<SelectTrigger id="widget-h"><SelectValue /></SelectTrigger>
|
<SelectTrigger id="widget-h" className="h-8 text-xs mt-1"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="1">1 row</SelectItem>
|
<SelectItem value="1">1 row</SelectItem>
|
||||||
<SelectItem value="2">2 rows</SelectItem>
|
<SelectItem value="2">2 rows</SelectItem>
|
||||||
@@ -421,8 +433,8 @@ function ConfigureWidgetDialog({ widget, open, onOpenChange, onSave }: { widget:
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||||
<Button onClick={handleSave}>Save</Button>
|
<Button size="sm" onClick={handleSave}>Save</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -473,26 +485,27 @@ function DashboardPage() {
|
|||||||
};
|
};
|
||||||
const handleDelete = (id: string) => { deleteMutation.mutate(id); };
|
const handleDelete = (id: string) => { deleteMutation.mutate(id); };
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
<h1 className="text-xl font-bold">Dashboard</h1>
|
||||||
<Button onClick={() => setAddOpen(true)} aria-label="Add widget"><Plus className="h-4 w-4 mr-2" />Add Widget</Button>
|
<Button onClick={() => setAddOpen(true)} size="sm" aria-label="Add widget"><Plus className="h-3.5 w-3.5 mr-1.5" />Add Widget</Button>
|
||||||
</div>
|
</div>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading dashboard...</div>
|
<div className="flex items-center justify-center py-10 text-xs text-muted-foreground">Loading dashboard...</div>
|
||||||
) : widgets.length === 0 ? (
|
) : widgets.length === 0 ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-10 border rounded-lg border-dashed">
|
||||||
<p className="text-muted-foreground mb-4">Your dashboard is empty. Add some widgets to get started!</p>
|
<Zap className="h-6 w-6 mx-auto mb-2 text-muted-foreground/50" />
|
||||||
<Button onClick={() => {
|
<p className="text-xs text-muted-foreground mb-3">Your dashboard is empty. Add some widgets to get started.</p>
|
||||||
|
<Button size="sm" onClick={() => {
|
||||||
const defaults = ["tasks_due", "habits_today", "recent_notes", "active_projects", "upcoming_events", "streak_counter", "quick_capture", "productivity_chart"];
|
const defaults = ["tasks_due", "habits_today", "recent_notes", "active_projects", "upcoming_events", "streak_counter", "quick_capture", "productivity_chart"];
|
||||||
defaults.forEach((type, i) => {
|
defaults.forEach((type, i) => {
|
||||||
const typeInfo = WIDGET_TYPES.find((t) => t.id === type);
|
const typeInfo = WIDGET_TYPES.find((t) => t.id === type);
|
||||||
createMutation.mutate({ type, title: typeInfo?.label || type, layout: { x: 0, y: i, w: typeInfo?.defaultW || 2, h: typeInfo?.defaultH || 2 } });
|
createMutation.mutate({ type, title: typeInfo?.label || type, layout: { x: 0, y: i, w: typeInfo?.defaultW || 2, h: typeInfo?.defaultH || 2 } });
|
||||||
});
|
});
|
||||||
}}><Plus className="h-4 w-4 mr-2" />Add Default Widgets</Button>
|
}}><Plus className="h-3.5 w-3.5 mr-1.5" />Add Default Widgets</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-4 auto-rows-[minmax(120px,auto)] sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid grid-cols-1 gap-3 auto-rows-[minmax(110px,auto)] sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{widgets.map((w) => (
|
{widgets.map((w) => (
|
||||||
<WidgetCard key={w.id} widget={w} onConfigure={() => handleConfigure(w)} onDelete={() => handleDelete(w.id)} />
|
<WidgetCard key={w.id} widget={w} onConfigure={() => handleConfigure(w)} onDelete={() => handleDelete(w.id)} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ function GraphPage() {
|
|||||||
{/* Graph canvas area */}
|
{/* Graph canvas area */}
|
||||||
<div ref={containerRef} className="flex-1 relative bg-muted/20">
|
<div ref={containerRef} className="flex-1 relative bg-muted/20">
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="absolute top-4 left-4 z-10 flex flex-wrap items-center gap-2">
|
<div className="absolute top-3 left-3 z-10 flex flex-wrap items-center gap-2">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
@@ -356,10 +356,13 @@ function GraphPage() {
|
|||||||
<Button variant="outline" size="icon" onClick={() => setFilterOpen(true)} aria-label="Filters">
|
<Button variant="outline" size="icon" onClick={() => setFilterOpen(true)} aria-label="Filters">
|
||||||
<Filter className="h-4 w-4" />
|
<Filter className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Badge variant="secondary" className="font-mono text-[10px]">
|
||||||
|
{displayNodes.length} nodes · {displayEdges.length} edges
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Zoom controls */}
|
{/* Zoom controls */}
|
||||||
<div className="absolute bottom-4 right-4 z-10 flex flex-col gap-1">
|
<div className="absolute bottom-3 right-3 z-10 flex flex-col gap-1">
|
||||||
<Button variant="secondary" size="icon" onClick={handleZoomIn} aria-label="Zoom in">
|
<Button variant="secondary" size="icon" onClick={handleZoomIn} aria-label="Zoom in">
|
||||||
<ZoomIn className="h-4 w-4" />
|
<ZoomIn className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -373,9 +376,9 @@ function GraphPage() {
|
|||||||
|
|
||||||
{/* Performance warning */}
|
{/* Performance warning */}
|
||||||
{exceeded && (
|
{exceeded && (
|
||||||
<div className="absolute top-4 right-4 z-10">
|
<div className="absolute top-3 right-3 z-10">
|
||||||
<Badge variant="destructive" className="text-xs">
|
<Badge variant="destructive" className="text-xs font-mono">
|
||||||
Showing {MAX_NODES} of {filteredNodes.length} nodes (cap reached)
|
Showing <span className="font-semibold">{MAX_NODES}</span> of <span className="font-semibold">{filteredNodes.length}</span> nodes
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -418,9 +421,12 @@ function GraphPage() {
|
|||||||
<SheetTitle>Filters</SheetTitle>
|
<SheetTitle>Filters</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<ScrollArea className="h-full pr-4">
|
<ScrollArea className="h-full pr-4">
|
||||||
<div className="space-y-6 pt-4">
|
<div className="space-y-4 pt-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold mb-2">Entity Types</h3>
|
<h3 className="text-sm font-semibold mb-2 flex items-center justify-between">
|
||||||
|
Entity Types
|
||||||
|
<span className="font-mono text-[10px] text-muted-foreground">{enabledTypes.size}/{ENTITY_TYPES.length}</span>
|
||||||
|
</h3>
|
||||||
{ENTITY_TYPES.map((type) => (
|
{ENTITY_TYPES.map((type) => (
|
||||||
<div key={type} className="flex items-center gap-2 py-1">
|
<div key={type} className="flex items-center gap-2 py-1">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -437,7 +443,10 @@ function GraphPage() {
|
|||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold mb-2">Relationship Types</h3>
|
<h3 className="text-sm font-semibold mb-2 flex items-center justify-between">
|
||||||
|
Relationship Types
|
||||||
|
<span className="font-mono text-[10px] text-muted-foreground">{enabledRelationships.size}/{RELATIONSHIP_TYPES.length}</span>
|
||||||
|
</h3>
|
||||||
{RELATIONSHIP_TYPES.map((type) => (
|
{RELATIONSHIP_TYPES.map((type) => (
|
||||||
<div key={type} className="flex items-center gap-2 py-1">
|
<div key={type} className="flex items-center gap-2 py-1">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -464,12 +473,12 @@ function GraphPage() {
|
|||||||
<SheetTitle>{selectedNode?.label || "Node"}</SheetTitle>
|
<SheetTitle>{selectedNode?.label || "Node"}</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
{selectedNode && (
|
{selectedNode && (
|
||||||
<div className="space-y-4 pt-4">
|
<div className="space-y-3 pt-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: selectedNode.color }} />
|
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: selectedNode.color }} />
|
||||||
<Badge variant="secondary">{selectedNode.type}</Badge>
|
<Badge variant="secondary" className="font-mono">{selectedNode.type}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">ID: {selectedNode.id}</p>
|
<p className="text-sm text-muted-foreground font-mono">ID: {selectedNode.id}</p>
|
||||||
<Separator />
|
<Separator />
|
||||||
{NODE_TYPE_ROUTES[selectedNode.type] ? (
|
{NODE_TYPE_ROUTES[selectedNode.type] ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -484,7 +493,12 @@ function GraphPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<Separator />
|
<Separator />
|
||||||
<h4 className="text-sm font-semibold">Connected nodes</h4>
|
<h4 className="text-sm font-semibold flex items-center justify-between">
|
||||||
|
Connected nodes
|
||||||
|
<span className="font-mono text-[10px] text-muted-foreground">
|
||||||
|
{displayEdges.filter((e) => e.source === selectedNode.id || e.target === selectedNode.id).length}
|
||||||
|
</span>
|
||||||
|
</h4>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{displayEdges
|
{displayEdges
|
||||||
.filter((e) => e.source === selectedNode.id || e.target === selectedNode.id)
|
.filter((e) => e.source === selectedNode.id || e.target === selectedNode.id)
|
||||||
@@ -510,7 +524,7 @@ function GraphPage() {
|
|||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Badge variant="outline" className="text-[10px]">{e.type.replace(/_/g, " ")}</Badge>
|
<Badge variant="outline" className="text-[10px] font-mono">{e.type.replace(/_/g, " ")}</Badge>
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ function HabitForm({ habit, onClose }: { habit?: Habit; onClose: () => void }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="name">Name</Label>
|
<Label htmlFor="name">Name</Label>
|
||||||
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Habit name" required />
|
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Habit name" required />
|
||||||
@@ -59,7 +59,7 @@ function HabitForm({ habit, onClose }: { habit?: Habit; onClose: () => void }) {
|
|||||||
<Label htmlFor="desc">Description</Label>
|
<Label htmlFor="desc">Description</Label>
|
||||||
<Textarea id="desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Description (optional)" rows={2} />
|
<Textarea id="desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Description (optional)" rows={2} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="freq">Frequency</Label>
|
<Label htmlFor="freq">Frequency</Label>
|
||||||
<Select value={frequency} onValueChange={(v) => setFrequency(v as "daily" | "weekly" | "custom")}>
|
<Select value={frequency} onValueChange={(v) => setFrequency(v as "daily" | "weekly" | "custom")}>
|
||||||
@@ -108,7 +108,7 @@ function MiniGrid({ completions, days = 7 }: { completions: HabitCompletion[]; d
|
|||||||
cells.push(
|
cells.push(
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
className={cn("w-3 h-3 rounded-sm", done ? "bg-green-500" : "bg-muted")}
|
className={cn("w-2.5 h-2.5 rounded-sm", done ? "bg-green-500" : "bg-muted")}
|
||||||
title={key + (done ? " ✓" : "")}
|
title={key + (done ? " ✓" : "")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -183,7 +183,7 @@ function HabitsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">Habits</h1>
|
<h1 className="text-xl font-bold">Habits</h1>
|
||||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button aria-label="New habit"><Plus className="h-4 w-4 mr-2" />New Habit</Button>
|
<Button aria-label="New habit"><Plus className="h-4 w-4 mr-2" />New Habit</Button>
|
||||||
@@ -200,24 +200,24 @@ function HabitsPage() {
|
|||||||
) : isError ? (
|
) : isError ? (
|
||||||
<ErrorState message="Failed to load habits." onRetry={() => refetch()} />
|
<ErrorState message="Failed to load habits." onRetry={() => refetch()} />
|
||||||
) : habits.length === 0 ? (
|
) : habits.length === 0 ? (
|
||||||
<EmptyState icon={Flame} title="No habits yet" description="Create your first one!" />
|
<EmptyState icon={Flame} title="No habits yet" description="Create your first one to get started." />
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{habits.map((habit) => (
|
{habits.map((habit) => (
|
||||||
<Card key={habit.id} className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => openHabitDetail(habit)}>
|
<Card key={habit.id} className="cursor-pointer border hover:bg-muted/20 transition-colors" onClick={() => openHabitDetail(habit)}>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
<Flame className={cn("h-5 w-5 shrink-0", habit.streakCount > 0 ? "text-orange-500" : "text-muted-foreground")} />
|
<Flame className={cn("h-5 w-5 shrink-0", habit.streakCount > 0 ? "text-orange-500" : "text-muted-foreground")} />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="font-medium truncate">{habit.name}</p>
|
<p className="font-medium truncate">{habit.name}</p>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="font-mono text-xs text-muted-foreground">
|
||||||
<Flame className="h-3 w-3 inline mr-0.5" />
|
<Flame className="h-3 w-3 inline mr-0.5" />
|
||||||
{habit.streakCount} day streak
|
{habit.streakCount} day streak
|
||||||
</span>
|
</span>
|
||||||
<Badge variant="secondary" className="text-[10px]">{habit.frequency}</Badge>
|
<Badge variant="secondary" className="font-mono text-[10px]">{habit.frequency}</Badge>
|
||||||
<Badge variant="outline" className="text-[10px]">{habit.difficulty}</Badge>
|
<Badge variant="outline" className="font-mono text-[10px]">{habit.difficulty}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -233,6 +233,11 @@ function HabitsPage() {
|
|||||||
})}
|
})}
|
||||||
onClick={(e) => { e.stopPropagation(); completeMutation.mutate(habit.id); }}
|
onClick={(e) => { e.stopPropagation(); completeMutation.mutate(habit.id); }}
|
||||||
aria-label={"Mark " + habit.name + " complete"}
|
aria-label={"Mark " + habit.name + " complete"}
|
||||||
|
className={(habit.recentCompletions || []).some((c) => {
|
||||||
|
const d = new Date(c.date);
|
||||||
|
const t = new Date();
|
||||||
|
return d.getUTCFullYear() === t.getUTCFullYear() && d.getUTCMonth() === t.getUTCMonth() && d.getUTCDate() === t.getUTCDate();
|
||||||
|
}) ? "bg-green-500/10 text-green-600 border-green-500/20" : ""}
|
||||||
>
|
>
|
||||||
<Check className="h-4 w-4 mr-1" />Complete
|
<Check className="h-4 w-4 mr-1" />Complete
|
||||||
</Button>
|
</Button>
|
||||||
@@ -263,15 +268,15 @@ function HabitsPage() {
|
|||||||
|
|
||||||
<EntityDetailPanel open={panelOpen} onOpenChange={(o) => { setPanelOpen(o); if (!o) setDetailTab("overview"); }} title={selectedHabit?.name || "Habit Details"}>
|
<EntityDetailPanel open={panelOpen} onOpenChange={(o) => { setPanelOpen(o); if (!o) setDetailTab("overview"); }} title={selectedHabit?.name || "Habit Details"}>
|
||||||
{selectedHabit && (
|
{selectedHabit && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<Tabs value={detailTab} onValueChange={setDetailTab}>
|
<Tabs value={detailTab} onValueChange={setDetailTab}>
|
||||||
<TabsList className="w-full">
|
<TabsList className="w-full">
|
||||||
<TabsTrigger value="overview" className="flex-1">Overview</TabsTrigger>
|
<TabsTrigger value="overview" className="flex-1">Overview</TabsTrigger>
|
||||||
<TabsTrigger value="history" className="flex-1">History</TabsTrigger>
|
<TabsTrigger value="history" className="flex-1">History</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="overview" className="space-y-4 pt-4">
|
<TabsContent value="overview" className="space-y-3 pt-3">
|
||||||
<HabitForm habit={selectedHabit} onClose={() => setPanelOpen(false)} />
|
<HabitForm habit={selectedHabit} onClose={() => setPanelOpen(false)} />
|
||||||
<div className="pt-4 border-t">
|
<div className="pt-3 border-t">
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="destructive" size="sm"><Trash2 className="h-4 w-4 mr-2" />Delete Habit</Button>
|
<Button variant="destructive" size="sm"><Trash2 className="h-4 w-4 mr-2" />Delete Habit</Button>
|
||||||
@@ -289,7 +294,7 @@ function HabitsPage() {
|
|||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="history" className="pt-4">
|
<TabsContent value="history" className="pt-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="font-semibold text-sm">Completion History</h3>
|
<h3 className="font-semibold text-sm">Completion History</h3>
|
||||||
{selectedHabit.recentCompletions?.length ? (
|
{selectedHabit.recentCompletions?.length ? (
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ function Overview({ habit, patch }: { habit: Habit; patch: PatchFn }) {
|
|||||||
const skipDays = habit.skipDays || [];
|
const skipDays = habit.skipDays || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1 text-sm font-semibold text-muted-foreground">Description</p>
|
<p className="mb-1 text-sm font-semibold text-muted-foreground">Description</p>
|
||||||
<InlineTextarea
|
<InlineTextarea
|
||||||
@@ -237,7 +237,7 @@ function Overview({ habit, patch }: { habit: Habit; patch: PatchFn }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<RepeatIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
<RepeatIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
<InlineSelect
|
<InlineSelect
|
||||||
@@ -267,7 +267,7 @@ function Overview({ habit, patch }: { habit: Habit; patch: PatchFn }) {
|
|||||||
patch({ id: habit.id, data: { goalPerPeriod: Number(goal) || 1 } })
|
patch({ id: habit.id, data: { goalPerPeriod: Number(goal) || 1 } })
|
||||||
}
|
}
|
||||||
display={(goal) => (
|
display={(goal) => (
|
||||||
<span>
|
<span className="font-mono text-xs">
|
||||||
{habit.unit ? `${goal} ${habit.unit} per period` : `${goal} per period`}
|
{habit.unit ? `${goal} ${habit.unit} per period` : `${goal} per period`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -297,7 +297,7 @@ function Overview({ habit, patch }: { habit: Habit; patch: PatchFn }) {
|
|||||||
}
|
}
|
||||||
display={(time) =>
|
display={(time) =>
|
||||||
time ? (
|
time ? (
|
||||||
<span>Reminder {time}</span>
|
<span className="font-mono text-xs">Reminder {time}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground/70">No reminder</span>
|
<span className="text-muted-foreground/70">No reminder</span>
|
||||||
)
|
)
|
||||||
@@ -340,33 +340,33 @@ function Overview({ habit, patch }: { habit: Habit; patch: PatchFn }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-3 text-center">
|
<div className="grid grid-cols-3 gap-2 text-center">
|
||||||
<div className="rounded-lg bg-muted/50 p-3">
|
<div className="rounded-lg bg-muted/50 p-2.5">
|
||||||
<p className="text-2xl font-bold">{habit.streakCount}</p>
|
<p className="text-xl font-bold font-mono">{habit.streakCount}</p>
|
||||||
<p className="text-xs text-muted-foreground">Current Streak</p>
|
<p className="text-[11px] text-muted-foreground">Current Streak</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg bg-muted/50 p-3">
|
<div className="rounded-lg bg-muted/50 p-2.5">
|
||||||
<p className="text-2xl font-bold">{habit.bestStreak}</p>
|
<p className="text-xl font-bold font-mono">{habit.bestStreak}</p>
|
||||||
<p className="text-xs text-muted-foreground">Best Streak</p>
|
<p className="text-[11px] text-muted-foreground">Best Streak</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg bg-muted/50 p-3">
|
<div className="rounded-lg bg-muted/50 p-2.5">
|
||||||
<p className="text-2xl font-bold">{distinctCompletionDays}</p>
|
<p className="text-xl font-bold font-mono">{distinctCompletionDays}</p>
|
||||||
<p className="text-xs text-muted-foreground">Completion This Period</p>
|
<p className="text-[11px] text-muted-foreground">Completion This Period</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t pt-4">
|
<div className="border-t pt-3">
|
||||||
<TagManager entityType="habit" entityId={habit.id} tags={habit.tags || []} />
|
<TagManager entityType="habit" entityId={habit.id} tags={habit.tags || []} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 border-t pt-4 text-xs text-muted-foreground">
|
<div className="flex items-center gap-4 border-t pt-3 text-xs text-muted-foreground">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Clock className="h-3 w-3" />
|
<Clock className="h-3 w-3" />
|
||||||
Created {format(parseISO(habit.createdAt), "MMM d, yyyy HH:mm")}
|
Created <span className="font-mono">{format(parseISO(habit.createdAt), "MMM d, yyyy HH:mm")}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Clock className="h-3 w-3" />
|
<Clock className="h-3 w-3" />
|
||||||
Updated {format(parseISO(habit.updatedAt), "MMM d, yyyy HH:mm")}
|
Updated <span className="font-mono">{format(parseISO(habit.updatedAt), "MMM d, yyyy HH:mm")}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -389,7 +389,7 @@ function Stats({
|
|||||||
const completionRate = Math.round((distinctCompletionDays / days) * 100);
|
const completionRate = Math.round((distinctCompletionDays / days) * 100);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Select value={String(days)} onValueChange={(v) => setDays(Number(v))}>
|
<Select value={String(days)} onValueChange={(v) => setDays(Number(v))}>
|
||||||
<SelectTrigger className="h-8 w-40 text-sm" aria-label="Range">
|
<SelectTrigger className="h-8 w-40 text-sm" aria-label="Range">
|
||||||
@@ -404,26 +404,26 @@ function Stats({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-3 text-center">
|
<div className="grid grid-cols-3 gap-2 text-center">
|
||||||
<div className="rounded-lg bg-muted/50 p-3">
|
<div className="rounded-lg bg-muted/50 p-2.5">
|
||||||
<p className="text-2xl font-bold">{habit.streakCount}</p>
|
<p className="text-xl font-bold font-mono">{habit.streakCount}</p>
|
||||||
<p className="text-xs text-muted-foreground">Current Streak</p>
|
<p className="text-[11px] text-muted-foreground">Current Streak</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg bg-muted/50 p-3">
|
<div className="rounded-lg bg-muted/50 p-2.5">
|
||||||
<p className="text-2xl font-bold">{habit.bestStreak}</p>
|
<p className="text-xl font-bold font-mono">{habit.bestStreak}</p>
|
||||||
<p className="text-xs text-muted-foreground">Best Streak</p>
|
<p className="text-[11px] text-muted-foreground">Best Streak</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg bg-muted/50 p-3">
|
<div className="rounded-lg bg-muted/50 p-2.5">
|
||||||
<p className="text-2xl font-bold">{completionRate}%</p>
|
<p className="text-xl font-bold font-mono">{completionRate}%</p>
|
||||||
<p className="text-xs text-muted-foreground">Completion Rate</p>
|
<p className="text-[11px] text-muted-foreground">Completion Rate</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border bg-muted/30 p-4">
|
<div className="rounded-lg border bg-muted/30 p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||||
<p className="text-sm font-semibold">Completion Heatmap</p>
|
<p className="text-sm font-semibold">Completion Heatmap</p>
|
||||||
<span className="ml-auto text-xs text-muted-foreground">
|
<span className="ml-auto font-mono text-[11px] text-muted-foreground">
|
||||||
Last {days} days
|
Last {days} days
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { createRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { Route as appRoute } from "../_app";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { api, useApiQuery } from "@/lib/api";
|
||||||
|
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||||
|
import { useRealtime } from "@/hooks/use-realtime";
|
||||||
|
import { Inbox as InboxIcon, AlertTriangle, CalendarClock, Flame, FileText, Check, ChevronRight } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { LoadingState, EmptyState } from "@/components/state";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { Task, Habit, Note, PaginatedResponse } from "@/lib/types";
|
||||||
|
import { format, isToday, isPast, parseISO } from "date-fns";
|
||||||
|
|
||||||
|
function InboxPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const activeDomainId = useApiDomain();
|
||||||
|
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
||||||
|
|
||||||
|
useRealtime({ enabled: true });
|
||||||
|
|
||||||
|
// ─── Queries ──────────────────────────────────────────────────────────
|
||||||
|
const { data: tasksData, isLoading: tasksLoading, error: tasksError, refetch: refetchTasks } =
|
||||||
|
useApiQuery<PaginatedResponse<Task>>(
|
||||||
|
["tasks-inbox", activeDomainId],
|
||||||
|
"/tasks?limit=200&status=todo,in_progress&sort=due_date" + domainSuffix,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: habitsData, isLoading: habitsLoading, error: habitsError, refetch: refetchHabits } =
|
||||||
|
useApiQuery<PaginatedResponse<Habit>>(
|
||||||
|
["habits-inbox", activeDomainId],
|
||||||
|
"/habits?limit=50" + domainSuffix,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: notesData, isLoading: notesLoading, error: notesError, refetch: refetchNotes } =
|
||||||
|
useApiQuery<PaginatedResponse<Note>>(
|
||||||
|
["notes-inbox", activeDomainId],
|
||||||
|
"/notes?limit=5&sort=-updated" + domainSuffix,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Filtering ────────────────────────────────────────────────────────
|
||||||
|
const tasks = tasksData?.items || [];
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
const overdueTasks = useMemo(
|
||||||
|
() =>
|
||||||
|
tasks.filter((t) => {
|
||||||
|
if (!t.dueDate) return false;
|
||||||
|
const d = parseISO(t.dueDate);
|
||||||
|
return isPast(d) && !isToday(d);
|
||||||
|
}),
|
||||||
|
[tasks],
|
||||||
|
);
|
||||||
|
|
||||||
|
const dueTodayTasks = useMemo(
|
||||||
|
() => tasks.filter((t) => t.dueDate && isToday(parseISO(t.dueDate))),
|
||||||
|
[tasks],
|
||||||
|
);
|
||||||
|
|
||||||
|
const habits = habitsData?.items || [];
|
||||||
|
const notes = notesData?.items || [];
|
||||||
|
|
||||||
|
// ─── Mutations ────────────────────────────────────────────────────────
|
||||||
|
const completeTaskMutation = useMutation({
|
||||||
|
mutationFn: (taskId: string) => api.patch<Task>("/tasks/" + taskId, { status: "done" }),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["tasks-inbox"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const completeHabitMutation = useMutation({
|
||||||
|
mutationFn: (habitId: string) => api.post("/habits/" + habitId + "/complete", {}),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["habits-inbox"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLoading = tasksLoading || habitsLoading || notesLoading;
|
||||||
|
const hasError = tasksError || habitsError || notesError;
|
||||||
|
|
||||||
|
if (isLoading) return <LoadingState label="Loading inbox..." />;
|
||||||
|
if (hasError) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h1 className="text-xl font-bold flex items-center gap-2">
|
||||||
|
<InboxIcon className="h-5 w-5" /> Inbox
|
||||||
|
</h1>
|
||||||
|
<EmptyState icon={AlertTriangle} title="Failed to load inbox" description="Check your connection and try again." />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Page header */}
|
||||||
|
<h1 className="text-xl font-bold flex items-center gap-2">
|
||||||
|
<InboxIcon className="h-5 w-5" /> Inbox
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Overdue tasks */}
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div className="h-2 w-2 rounded-full bg-destructive" />
|
||||||
|
<h2 className="text-sm font-semibold text-destructive">Overdue</h2>
|
||||||
|
{overdueTasks.length > 0 && (
|
||||||
|
<Badge variant="destructive" className="font-mono text-[10px] px-1.5">{overdueTasks.length}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{overdueTasks.length === 0 ? (
|
||||||
|
<div className="py-6 text-center">
|
||||||
|
<AlertTriangle className="h-6 w-6 mx-auto mb-1 text-green-500" />
|
||||||
|
<p className="text-xs text-muted-foreground">No overdue tasks</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{overdueTasks.map((task) => (
|
||||||
|
<Card key={task.id} className="border border-destructive/20 hover:bg-muted/20 transition-colors">
|
||||||
|
<CardContent className="p-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 shrink-0"
|
||||||
|
disabled={completeTaskMutation.isPending}
|
||||||
|
onClick={() => completeTaskMutation.mutate(task.id)}
|
||||||
|
aria-label={"Complete " + task.title}
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<div
|
||||||
|
className="flex-1 min-w-0 cursor-pointer"
|
||||||
|
onClick={() => navigate({ to: "/tasks/$id", params: { id: task.id } })}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium truncate">{task.title}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<span className="font-mono text-[10px] text-destructive">
|
||||||
|
{format(parseISO(task.dueDate!), "MMM d")}
|
||||||
|
</span>
|
||||||
|
<Badge variant="secondary" className="font-mono text-[10px]">
|
||||||
|
{task.priority}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Due today tasks */}
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div className="h-2 w-2 rounded-full bg-amber-500" />
|
||||||
|
<h2 className="text-sm font-semibold text-amber-500">Due Today</h2>
|
||||||
|
{dueTodayTasks.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="font-mono text-[10px] px-1.5">{dueTodayTasks.length}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{dueTodayTasks.length === 0 ? (
|
||||||
|
<div className="py-6 text-center">
|
||||||
|
<CalendarClock className="h-6 w-6 mx-auto mb-1 text-muted-foreground/50" />
|
||||||
|
<p className="text-xs text-muted-foreground">No tasks due today</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{dueTodayTasks.map((task) => (
|
||||||
|
<Card key={task.id} className="border border-amber-500/20 hover:bg-muted/20 transition-colors">
|
||||||
|
<CardContent className="p-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 shrink-0"
|
||||||
|
disabled={completeTaskMutation.isPending}
|
||||||
|
onClick={() => completeTaskMutation.mutate(task.id)}
|
||||||
|
aria-label={"Complete " + task.title}
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<div
|
||||||
|
className="flex-1 min-w-0 cursor-pointer"
|
||||||
|
onClick={() => navigate({ to: "/tasks/$id", params: { id: task.id } })}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium truncate">{task.title}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<span className="font-mono text-[10px] text-amber-500">Today</span>
|
||||||
|
<Badge variant="secondary" className="font-mono text-[10px]">
|
||||||
|
{task.priority}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Habits */}
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div className="h-2 w-2 rounded-full bg-orange-500" />
|
||||||
|
<h2 className="text-sm font-semibold text-orange-500">Habits</h2>
|
||||||
|
{habits.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="font-mono text-[10px] px-1.5">{habits.length}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{habits.length === 0 ? (
|
||||||
|
<div className="py-6 text-center">
|
||||||
|
<Flame className="h-6 w-6 mx-auto mb-1 text-muted-foreground/50" />
|
||||||
|
<p className="text-xs text-muted-foreground">No habits yet</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{habits.map((habit) => {
|
||||||
|
const doneToday = (habit.recentCompletions || []).some((c) => {
|
||||||
|
const d = new Date(c.date);
|
||||||
|
return (
|
||||||
|
d.getUTCFullYear() === today.getUTCFullYear() &&
|
||||||
|
d.getUTCMonth() === today.getUTCMonth() &&
|
||||||
|
d.getUTCDate() === today.getUTCDate()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<Card key={habit.id} className="hover:bg-muted/20 transition-colors">
|
||||||
|
<CardContent className="p-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"w-5 h-5 rounded border shrink-0 flex items-center justify-center transition-colors",
|
||||||
|
doneToday
|
||||||
|
? "bg-green-500 border-green-500"
|
||||||
|
: "border-muted-foreground/30 hover:border-primary",
|
||||||
|
)}
|
||||||
|
disabled={completeHabitMutation.isPending}
|
||||||
|
onClick={() => completeHabitMutation.mutate(habit.id)}
|
||||||
|
aria-label={doneToday ? habit.name + " (completed)" : "Complete " + habit.name}
|
||||||
|
>
|
||||||
|
{doneToday && <Check className="h-3 w-3 text-white" />}
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className="flex-1 min-w-0 cursor-pointer"
|
||||||
|
onClick={() => navigate({ to: "/habits/$id", params: { id: habit.id } })}
|
||||||
|
>
|
||||||
|
<p className={cn("text-sm font-medium truncate", doneToday && "line-through text-muted-foreground")}>
|
||||||
|
{habit.name}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<span className="font-mono text-[10px] text-muted-foreground">{habit.frequency}</span>
|
||||||
|
{habit.streakCount > 0 && (
|
||||||
|
<Badge variant="secondary" className="font-mono text-[10px] px-1">
|
||||||
|
<Flame className="h-2.5 w-2.5 mr-0.5 text-orange-500" />
|
||||||
|
{habit.streakCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Recent notes */}
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div className="h-2 w-2 rounded-full bg-blue-500" />
|
||||||
|
<h2 className="text-sm font-semibold text-blue-500">Recent Notes</h2>
|
||||||
|
</div>
|
||||||
|
{notes.length === 0 ? (
|
||||||
|
<div className="py-6 text-center">
|
||||||
|
<FileText className="h-6 w-6 mx-auto mb-1 text-muted-foreground/50" />
|
||||||
|
<p className="text-xs text-muted-foreground">No notes yet</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{notes.map((note) => (
|
||||||
|
<Card
|
||||||
|
key={note.id}
|
||||||
|
className="hover:bg-muted/20 transition-colors cursor-pointer"
|
||||||
|
onClick={() => navigate({ to: "/notes/$id", params: { id: note.id } })}
|
||||||
|
>
|
||||||
|
<CardContent className="p-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="h-4 w-4 text-blue-500 shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{note.title}</p>
|
||||||
|
<p className="font-mono text-[10px] text-muted-foreground mt-0.5">
|
||||||
|
{format(parseISO(note.updatedAt), "MMM d, HH:mm")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: "/inbox",
|
||||||
|
component: InboxPage,
|
||||||
|
});
|
||||||
@@ -44,11 +44,17 @@ const NoteTitleInput = memo(function NoteTitleInput({ noteId, initialTitle }: {
|
|||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
autoFocus
|
autoFocus
|
||||||
className="flex h-9 w-full rounded-md border-0 bg-transparent px-0 py-1 text-lg font-semibold text-base shadow-none transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50"
|
className="flex h-9 w-full rounded-md border-0 bg-transparent px-0 py-1 text-lg font-semibold shadow-none transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Count words in HTML content (rough estimate)
|
||||||
|
function countWords(html: string | null): number {
|
||||||
|
if (!html) return 0;
|
||||||
|
return html.replace(/<[^>]+>/g, "").trim().split(/\s+/).filter(Boolean).length;
|
||||||
|
}
|
||||||
|
|
||||||
// Memoized right pane - only re-renders when note changes, not on parent re-renders
|
// Memoized right pane - only re-renders when note changes, not on parent re-renders
|
||||||
const NoteEditorPane = memo(function NoteEditorPane({ note, onDelete, onOpenNote }: { note: Note; onDelete: (id: string) => void; onOpenNote: (note: Note) => void }) {
|
const NoteEditorPane = memo(function NoteEditorPane({ note, onDelete, onOpenNote }: { note: Note; onDelete: (id: string) => void; onOpenNote: (note: Note) => void }) {
|
||||||
const [showBacklinks, setShowBacklinks] = useState(false);
|
const [showBacklinks, setShowBacklinks] = useState(false);
|
||||||
@@ -67,19 +73,19 @@ const NoteEditorPane = memo(function NoteEditorPane({ note, onDelete, onOpenNote
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2 p-3 border-b">
|
<div className="flex items-center gap-2 px-3 py-2 border-b">
|
||||||
<NoteTitleInput key={note.id} noteId={note.id} initialTitle={note.title} />
|
<NoteTitleInput key={note.id} noteId={note.id} initialTitle={note.title} />
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-0.5 shrink-0">
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setShowBacklinks(!showBacklinks)} aria-label="Backlinks">
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setShowBacklinks(!showBacklinks)} aria-label="Backlinks">
|
||||||
<LinkIcon className="h-4 w-4" />
|
<LinkIcon className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => { setShowVersions(!showVersions); if (!showVersions) { api.get<{ items: { id: string; createdAt: string; action?: string; changes?: Record<string, unknown> | null }[] }>("/notes/" + note.id + "/versions").then((data) => setVersions(data.items || [])).catch(() => setVersions([])); } }} aria-label="Version history">
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setShowVersions(!showVersions); if (!showVersions) { api.get<{ items: { id: string; createdAt: string; action?: string; changes?: Record<string, unknown> | null }[] }>("/notes/" + note.id + "/versions").then((data) => setVersions(data.items || [])).catch(() => setVersions([])); } }} aria-label="Version history">
|
||||||
<History className="h-4 w-4" />
|
<History className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" aria-label="Delete note">
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" aria-label="Delete note">
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
@@ -95,20 +101,45 @@ const NoteEditorPane = memo(function NoteEditorPane({ note, onDelete, onOpenNote
|
|||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Metadata strip */}
|
||||||
|
<div className="flex items-center gap-3 px-3 py-1.5 border-b text-xs text-muted-foreground">
|
||||||
|
<span className="font-mono">{format(parseISO(note.updatedAt), "yyyy-MM-dd HH:mm")}</span>
|
||||||
|
<span className="text-border">·</span>
|
||||||
|
<span className="font-mono">{countWords(note.content)} words</span>
|
||||||
|
{note.isPinned && (
|
||||||
|
<>
|
||||||
|
<span className="text-border">·</span>
|
||||||
|
<span className="font-mono text-amber-500">pinned</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{note.tags && note.tags.length > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-border">·</span>
|
||||||
|
<span className="font-mono">{note.tags.length} tag{note.tags.length !== 1 ? 's' : ''}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{note.backlinks && note.backlinks.length > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-border">·</span>
|
||||||
|
<span className="font-mono">{note.backlinks.length} backlink{note.backlinks.length !== 1 ? 's' : ''}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<NoteEditor key={note.id} initialContent={note.content || ''} onSave={handleSave} />
|
<NoteEditor key={note.id} initialContent={note.content || ''} onSave={handleSave} />
|
||||||
</div>
|
</div>
|
||||||
{/* Backlinks section */}
|
{/* Backlinks section */}
|
||||||
{showBacklinks && note.backlinks && note.backlinks.length > 0 && (
|
{showBacklinks && note.backlinks && note.backlinks.length > 0 && (
|
||||||
<div className="border-t p-3">
|
<div className="border-t px-3 py-2">
|
||||||
<h4 className="text-sm font-semibold mb-2">Linked from</h4>
|
<h4 className="text-xs font-semibold mb-1 uppercase tracking-wider text-muted-foreground">Linked from</h4>
|
||||||
<div className="space-y-1">
|
<div className="space-y-0.5">
|
||||||
{note.backlinks.map((bl) => (
|
{note.backlinks.map((bl) => (
|
||||||
<div
|
<div
|
||||||
key={bl.id}
|
key={bl.id}
|
||||||
className="text-sm text-muted-foreground hover:text-foreground cursor-pointer"
|
className="text-xs text-muted-foreground hover:text-foreground cursor-pointer flex items-center gap-1.5"
|
||||||
onClick={() => { const linked: Note = { ...note, id: bl.id, title: bl.title }; onOpenNote(linked); }}
|
onClick={() => { const linked: Note = { ...note, id: bl.id, title: bl.title }; onOpenNote(linked); }}
|
||||||
>
|
>
|
||||||
|
<LinkIcon className="h-3 w-3 shrink-0" />
|
||||||
{bl.title}
|
{bl.title}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -117,16 +148,16 @@ const NoteEditorPane = memo(function NoteEditorPane({ note, onDelete, onOpenNote
|
|||||||
)}
|
)}
|
||||||
{/* Version history */}
|
{/* Version history */}
|
||||||
{showVersions && (
|
{showVersions && (
|
||||||
<div className="border-t p-3">
|
<div className="border-t px-3 py-2">
|
||||||
<h4 className="text-sm font-semibold mb-2">Version History</h4>
|
<h4 className="text-xs font-semibold mb-1 uppercase tracking-wider text-muted-foreground">Version History</h4>
|
||||||
{versions.length === 0 ? (
|
{versions.length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground">No versions yet.</p>
|
<p className="text-xs text-muted-foreground">No versions yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className="space-y-0.5">
|
||||||
{versions.map((v) => (
|
{versions.map((v) => (
|
||||||
<div key={v.id} className="flex items-center justify-between text-xs text-muted-foreground">
|
<div key={v.id} className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
<span>{format(parseISO(v.createdAt), "MMM d, yyyy HH:mm")}</span>
|
<span className="font-mono">{format(parseISO(v.createdAt), "yyyy-MM-dd HH:mm")}</span>
|
||||||
{v.action && <Badge variant="secondary" className="text-[10px] capitalize">{v.action}</Badge>}
|
{v.action && <Badge variant="secondary" className="text-[10px] capitalize font-mono">{v.action}</Badge>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -214,48 +245,53 @@ function NotesPage() {
|
|||||||
<div className="flex flex-col md:flex-row h-auto min-h-[calc(100vh-8rem)] md:h-[calc(100vh-8rem)] -m-4 md:-m-6">
|
<div className="flex flex-col md:flex-row h-auto min-h-[calc(100vh-8rem)] md:h-[calc(100vh-8rem)] -m-4 md:-m-6">
|
||||||
{/* Left pane - note list */}
|
{/* Left pane - note list */}
|
||||||
<div className="w-full md:w-72 h-64 md:h-auto border-b md:border-b-0 md:border-r flex flex-col shrink-0">
|
<div className="w-full md:w-72 h-64 md:h-auto border-b md:border-b-0 md:border-r flex flex-col shrink-0">
|
||||||
<div className="p-3 border-b">
|
<div className="px-3 py-2 border-b">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
<Input placeholder="Search notes..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-8" aria-label="Search notes" />
|
<Input placeholder="Search notes..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-8 h-8 text-sm" aria-label="Search notes" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2">
|
<div className="px-3 py-2 border-b">
|
||||||
<Button size="sm" className="w-full" onClick={() => createMutation.mutate()} aria-label="New note">
|
<Button size="sm" className="w-full h-8 text-xs" onClick={() => createMutation.mutate()} aria-label="New note">
|
||||||
<Plus className="h-4 w-4 mr-2" />New Note
|
<Plus className="h-3.5 w-3.5 mr-1" />New Note
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="p-4 text-sm text-muted-foreground">Loading...</div>
|
<div className="px-3 py-2 text-xs text-muted-foreground">Loading...</div>
|
||||||
) : notes.length === 0 ? (
|
) : notes.length === 0 ? (
|
||||||
<div className="p-4 text-sm text-muted-foreground">No notes yet.</div>
|
<div className="px-3 py-4 text-center">
|
||||||
|
<FileText className="h-8 w-8 mx-auto mb-2 text-amber-400" />
|
||||||
|
<p className="text-xs text-muted-foreground">No notes yet</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-0.5 p-2">
|
<div>
|
||||||
{notes.map((note) => (
|
{notes.map((note) => (
|
||||||
<button
|
<button
|
||||||
key={note.id}
|
key={note.id}
|
||||||
onClick={() => selectNote(note)}
|
onClick={() => selectNote(note)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full text-left px-3 py-2 rounded-md text-sm transition-colors",
|
"w-full text-left px-3 py-1.5 text-sm transition-colors border-b border-border/50 last:border-b-0",
|
||||||
selectedNoteId === note.id ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
|
selectedNoteId === note.id ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1.5">
|
||||||
{note.isPinned && <Pin className="h-3 w-3 shrink-0 text-muted-foreground" />}
|
{note.isPinned && <Pin className="h-3 w-3 shrink-0 text-amber-500" />}
|
||||||
<span className="truncate font-medium">{note.title}</span>
|
<span className="truncate font-medium text-xs">{note.title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5 text-[10px] text-muted-foreground font-mono">
|
||||||
|
<span>{new Date(note.updatedAt).toLocaleDateString()}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{countWords(note.content)}w</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
|
||||||
{new Date(note.updatedAt).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{hasMoreNotes && (
|
{hasMoreNotes && (
|
||||||
<div className="p-2">
|
<div className="px-3 py-1.5">
|
||||||
<Button variant="outline" size="sm" className="w-full" onClick={loadMoreNotes} disabled={loadingMoreNotes}>
|
<Button variant="outline" size="sm" className="w-full h-7 text-xs" onClick={loadMoreNotes} disabled={loadingMoreNotes}>
|
||||||
{loadingMoreNotes ? "Loading..." : "Load more notes"}
|
{loadingMoreNotes ? "Loading..." : "Load more"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -269,8 +305,8 @@ function NotesPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center flex-1 text-muted-foreground">
|
<div className="flex items-center justify-center flex-1 text-muted-foreground">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<FileText className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
<FileText className="h-10 w-10 mx-auto mb-2 text-amber-400" />
|
||||||
<p>Select a note or create a new one</p>
|
<p className="text-sm text-muted-foreground">Select a note or create a new one</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ function NoteDetail() {
|
|||||||
|
|
||||||
function EditorTab({ note, patch }: { note: Note; patch: PatchFn }) {
|
function EditorTab({ note, patch }: { note: Note; patch: PatchFn }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1 text-sm font-semibold text-muted-foreground">Content</p>
|
<p className="mb-1 text-sm font-semibold text-muted-foreground">Content</p>
|
||||||
<div className="rounded-lg border">
|
<div className="rounded-lg border">
|
||||||
@@ -207,18 +207,18 @@ function EditorTab({ note, patch }: { note: Note; patch: PatchFn }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t pt-4">
|
<div className="border-t pt-3">
|
||||||
<TagManager entityType="note" entityId={note.id} tags={note.tags || []} />
|
<TagManager entityType="note" entityId={note.id} tags={note.tags || []} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 border-t pt-4 text-xs text-muted-foreground">
|
<div className="flex items-center gap-4 border-t pt-3 text-xs text-muted-foreground">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Clock className="h-3 w-3" />
|
<Clock className="h-3 w-3" />
|
||||||
Created {format(parseISO(note.createdAt), "MMM d, yyyy HH:mm")}
|
Created <span className="font-mono">{format(parseISO(note.createdAt), "MMM d, yyyy HH:mm")}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Clock className="h-3 w-3" />
|
<Clock className="h-3 w-3" />
|
||||||
Updated {format(parseISO(note.updatedAt), "MMM d, yyyy HH:mm")}
|
Updated <span className="font-mono">{format(parseISO(note.updatedAt), "MMM d, yyyy HH:mm")}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { createRoute, useNavigate } from "@tanstack/react-router";
|
import { createRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { Route as appRoute } from "../_app";
|
import { Route as appRoute } from "../_app";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
import { api, useApiQuery } from "@/lib/api";
|
||||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||||
import { useRealtime } from "@/hooks/use-realtime";
|
import { useRealtime } from "@/hooks/use-realtime";
|
||||||
import { useOpenCreateDialog } from "@/hooks/use-open-create-dialog";
|
import { useOpenCreateDialog } from "@/hooks/use-open-create-dialog";
|
||||||
import { Plus, Pencil, Trash2, FolderKanban, Users, Calendar, ListTodo, GripVertical } from "lucide-react";
|
import { Plus, Pencil, Trash2, Calendar, LayoutGrid, List } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -15,10 +15,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Progress } from "@/components/ui/progress";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
||||||
import { EntityDetailPanel } from "@/components/entities/entity-detail-panel";
|
import { EntityDetailPanel } from "@/components/entities/entity-detail-panel";
|
||||||
import { LoadingState, EmptyState, ErrorState } from "@/components/state";
|
import { LoadingState, EmptyState, ErrorState } from "@/components/state";
|
||||||
import { PROJECT_STATUS, TASK_STATUS } from "@/lib/status-colors";
|
import { PROJECT_STATUS, TASK_STATUS } from "@/lib/status-colors";
|
||||||
@@ -95,9 +93,80 @@ function ProjectForm({ project, onClose }: { project?: Project; onClose: () => v
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProgressBar({ value, color }: { value: number; color?: string | null }) {
|
||||||
|
return (
|
||||||
|
<div className="relative h-1.5 w-full overflow-hidden rounded-full bg-secondary">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${value}%`,
|
||||||
|
backgroundColor: color || "hsl(var(--primary))",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectCard({
|
||||||
|
project,
|
||||||
|
onOpenDetail,
|
||||||
|
onOpenPanel,
|
||||||
|
}: {
|
||||||
|
project: Project;
|
||||||
|
onOpenDetail: (p: Project) => void;
|
||||||
|
onOpenPanel: (p: Project) => void;
|
||||||
|
}) {
|
||||||
|
const status = PROJECT_STATUS[project.status];
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className="cursor-pointer border rounded-lg transition-colors hover:bg-muted/20"
|
||||||
|
onClick={() => onOpenDetail(project)}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: project.color || "#3b82f6" }} />
|
||||||
|
<CardTitle className="text-base truncate">{project.name}</CardTitle>
|
||||||
|
<div className="ml-auto flex items-center gap-1.5">
|
||||||
|
{status && (
|
||||||
|
<Badge variant="secondary" className="font-mono text-[10px] gap-1.5">
|
||||||
|
<span className={cn("h-1.5 w-1.5 rounded-full", status.dot)} />
|
||||||
|
{status.label}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onOpenPanel(project); }}
|
||||||
|
aria-label={"Edit " + project.name}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
{project.description && (
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-2 mb-2">{project.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="mb-2">
|
||||||
|
<ProgressBar value={project.progress} color={project.color} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span className="font-mono">{project.completedCount}/{project.taskCount} tasks</span>
|
||||||
|
{project.targetDate && (
|
||||||
|
<span className="flex items-center gap-1"><Calendar className="h-3 w-3" />{new Date(project.targetDate).toLocaleDateString()}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ProjectsPage() {
|
function ProjectsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [view, setView] = useState<"grid" | "list">("grid");
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||||||
const [panelOpen, setPanelOpen] = useState(false);
|
const [panelOpen, setPanelOpen] = useState(false);
|
||||||
@@ -157,16 +226,24 @@ function ProjectsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">Projects</h1>
|
<h1 className="text-xl font-bold">Projects</h1>
|
||||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
<div className="flex items-center gap-2">
|
||||||
<DialogTrigger asChild>
|
<Tabs value={view} onValueChange={(v) => setView(v as "grid" | "list")}>
|
||||||
<Button aria-label="New project"><Plus className="h-4 w-4 mr-2" />New Project</Button>
|
<TabsList>
|
||||||
</DialogTrigger>
|
<TabsTrigger value="grid" aria-label="Grid view"><LayoutGrid className="h-4 w-4" /></TabsTrigger>
|
||||||
<DialogContent>
|
<TabsTrigger value="list" aria-label="List view"><List className="h-4 w-4" /></TabsTrigger>
|
||||||
<DialogHeader><DialogTitle>New Project</DialogTitle></DialogHeader>
|
</TabsList>
|
||||||
<ProjectForm onClose={() => setCreateOpen(false)} />
|
</Tabs>
|
||||||
</DialogContent>
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
</Dialog>
|
<DialogTrigger asChild>
|
||||||
|
<Button aria-label="New project"><Plus className="h-4 w-4 mr-2" />New Project</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>New Project</DialogTitle></DialogHeader>
|
||||||
|
<ProjectForm onClose={() => setCreateOpen(false)} />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -175,43 +252,64 @@ function ProjectsPage() {
|
|||||||
<ErrorState message="Failed to load projects." onRetry={() => refetch()} />
|
<ErrorState message="Failed to load projects." onRetry={() => refetch()} />
|
||||||
) : projects.length === 0 ? (
|
) : projects.length === 0 ? (
|
||||||
<EmptyState title="No projects yet" description="Create your first project to get started." />
|
<EmptyState title="No projects yet" description="Create your first project to get started." />
|
||||||
) : (
|
) : view === "grid" ? (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
<Card key={project.id} className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => openProjectDetail(project)}>
|
<ProjectCard
|
||||||
<CardHeader className="pb-2">
|
key={project.id}
|
||||||
<div className="flex items-center gap-2">
|
project={project}
|
||||||
<div className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: project.color || "#3b82f6" }} />
|
onOpenDetail={openProjectDetail}
|
||||||
<CardTitle className="text-base truncate">{project.name}</CardTitle>
|
onOpenPanel={openProjectPanel}
|
||||||
<Badge className={cn("ml-auto text-[10px]", PROJECT_STATUS[project.status]?.badge)}>
|
/>
|
||||||
{PROJECT_STATUS[project.status]?.label ?? project.status}
|
|
||||||
</Badge>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 shrink-0"
|
|
||||||
onClick={(e) => { e.stopPropagation(); openProjectPanel(project); }}
|
|
||||||
aria-label={"Edit " + project.name}
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{project.description && (
|
|
||||||
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">{project.description}</p>
|
|
||||||
)}
|
|
||||||
<Progress value={project.progress} className="h-1.5 mb-2" />
|
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
||||||
<span>{project.completedCount}/{project.taskCount} tasks</span>
|
|
||||||
{project.targetDate && (
|
|
||||||
<span><Calendar className="h-3 w-3 inline mr-1" />{new Date(project.targetDate).toLocaleDateString()}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{projects.map((project) => {
|
||||||
|
const status = PROJECT_STATUS[project.status];
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={project.id}
|
||||||
|
className="cursor-pointer border rounded-lg transition-colors hover:bg-muted/20"
|
||||||
|
onClick={() => openProjectDetail(project)}
|
||||||
|
>
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: project.color || "#3b82f6" }} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-sm truncate">{project.name}</span>
|
||||||
|
{status && (
|
||||||
|
<Badge variant="secondary" className="font-mono text-[10px] gap-1.5 shrink-0">
|
||||||
|
<span className={cn("h-1.5 w-1.5 rounded-full", status.dot)} />
|
||||||
|
{status.label}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{project.description && (
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-1 mt-0.5">{project.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ProgressBar value={project.progress} color={project.color} />
|
||||||
|
<span className="font-mono text-xs text-muted-foreground shrink-0 w-12 text-right">{project.completedCount}/{project.taskCount}</span>
|
||||||
|
{project.targetDate && (
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0 flex items-center gap-1"><Calendar className="h-3 w-3" />{new Date(project.targetDate).toLocaleDateString()}</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
onClick={(e) => { e.stopPropagation(); openProjectPanel(project); }}
|
||||||
|
aria-label={"Edit " + project.name}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasMoreProjects && (
|
{hasMoreProjects && (
|
||||||
@@ -232,7 +330,7 @@ function ProjectsPage() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="overview" className="space-y-4 pt-4">
|
<TabsContent value="overview" className="space-y-4 pt-4">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<Progress value={selectedProject.progress} className="h-2 flex-1" />
|
<ProgressBar value={selectedProject.progress} color={selectedProject.color} />
|
||||||
<span className="text-sm font-medium">{selectedProject.progress}%</span>
|
<span className="text-sm font-medium">{selectedProject.progress}%</span>
|
||||||
</div>
|
</div>
|
||||||
<ProjectForm project={selectedProject} onClose={() => setPanelOpen(false)} />
|
<ProjectForm project={selectedProject} onClose={() => setPanelOpen(false)} />
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
} from "@/components/entities/inline-edit";
|
} from "@/components/entities/inline-edit";
|
||||||
import { EntityActivity } from "@/components/entities/entity-activity";
|
import { EntityActivity } from "@/components/entities/entity-activity";
|
||||||
import { EntityComments } from "@/components/entities/entity-comments";
|
import { EntityComments } from "@/components/entities/entity-comments";
|
||||||
|
import { TagManager } from "@/components/entities/tag-manager";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -263,7 +264,7 @@ function Overview({ project, patch }: { project: Project; patch: PatchFn }) {
|
|||||||
const countdown = targetCountdown(project.targetDate);
|
const countdown = targetCountdown(project.targetDate);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1 text-sm font-semibold text-muted-foreground">Description</p>
|
<p className="mb-1 text-sm font-semibold text-muted-foreground">Description</p>
|
||||||
<InlineTextarea
|
<InlineTextarea
|
||||||
@@ -275,19 +276,19 @@ function Overview({ project, patch }: { project: Project; patch: PatchFn }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border bg-muted/30 p-4">
|
<div className="rounded-lg border bg-muted/30 p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ListTodo className="h-4 w-4 text-muted-foreground" />
|
<ListTodo className="h-4 w-4 text-muted-foreground" />
|
||||||
<p className="text-sm font-semibold">Progress</p>
|
<p className="text-sm font-semibold">Progress</p>
|
||||||
<span className="ml-auto text-sm font-medium">{project.progress}%</span>
|
<span className="ml-auto font-mono text-xs font-medium">{project.progress}%</span>
|
||||||
</div>
|
</div>
|
||||||
<Progress value={project.progress} className="mt-3 h-2" />
|
<Progress value={project.progress} className="mt-2 h-1.5" />
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
<p className="mt-1.5 font-mono text-[11px] text-muted-foreground">
|
||||||
{project.completedCount} of {project.taskCount} tasks done · {project.progress}%
|
{project.completedCount} of {project.taskCount} tasks done · {project.progress}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />
|
<Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
<InlineDate
|
<InlineDate
|
||||||
@@ -300,27 +301,18 @@ function Overview({ project, patch }: { project: Project; patch: PatchFn }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{project.tags && project.tags.length > 0 ? (
|
<div className="border-t pt-3">
|
||||||
<div>
|
<TagManager entityType="project" entityId={project.id} tags={project.tags || []} />
|
||||||
<p className="mb-2 text-sm font-semibold text-muted-foreground">Tags</p>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{project.tags.map((t) => (
|
|
||||||
<Badge key={t.id} variant="secondary">
|
|
||||||
{t.name}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4 border-t pt-4 text-xs text-muted-foreground">
|
<div className="flex items-center gap-4 border-t pt-3 text-xs text-muted-foreground">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Clock className="h-3 w-3" />
|
<Clock className="h-3 w-3" />
|
||||||
Created {format(parseISO(project.createdAt), "MMM d, yyyy HH:mm")}
|
Created <span className="font-mono">{format(parseISO(project.createdAt), "MMM d, yyyy HH:mm")}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Clock className="h-3 w-3" />
|
<Clock className="h-3 w-3" />
|
||||||
Updated {format(parseISO(project.updatedAt), "MMM d, yyyy HH:mm")}
|
Updated <span className="font-mono">{format(parseISO(project.updatedAt), "MMM d, yyyy HH:mm")}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,416 @@
|
|||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import { createRoute } from "@tanstack/react-router";
|
||||||
|
import { Route as appRoute } from "../_app";
|
||||||
|
import { useApiQuery } from "@/lib/api";
|
||||||
|
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||||
|
import { useRealtime } from "@/hooks/use-realtime";
|
||||||
|
import { BarChart3, Download, Calendar, ListTodo, Flame, FileText, FolderKanban } 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 { LoadingState, ErrorState } from "@/components/state";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { DailyAnalytics, HabitAnalytics, ProjectAnalytics } from "@/lib/types";
|
||||||
|
import { LineChart, BarChart, HorizontalBar, PieChartSimple } from "@/components/charts";
|
||||||
|
|
||||||
|
const RANGE_OPTIONS = [
|
||||||
|
{ value: "7", label: "Last 7 days" },
|
||||||
|
{ value: "30", label: "Last 30 days" },
|
||||||
|
{ value: "90", label: "Last 90 days" },
|
||||||
|
{ value: "all", label: "All time" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
icon: Icon,
|
||||||
|
color,
|
||||||
|
bg,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
icon: React.ElementType;
|
||||||
|
color: string;
|
||||||
|
bg: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className="border rounded-lg hover:bg-muted/20 transition-colors">
|
||||||
|
<CardContent className="p-3 flex items-center gap-3">
|
||||||
|
<div className={cn("h-9 w-9 rounded-lg flex items-center justify-center shrink-0", bg)}>
|
||||||
|
<Icon className={cn("h-4.5 w-4.5", color)} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-2xl font-mono font-bold leading-none">{value}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">{label}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportsPage() {
|
||||||
|
const [range, setRange] = useState("30");
|
||||||
|
const activeDomainId = useApiDomain();
|
||||||
|
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
||||||
|
|
||||||
|
useRealtime({ enabled: true });
|
||||||
|
|
||||||
|
const { data: habitData, isLoading: habitsLoading, error: habitsError, refetch: refetchHabits } =
|
||||||
|
useApiQuery<HabitAnalytics>(
|
||||||
|
["analytics-habits", activeDomainId, range],
|
||||||
|
"/analytics/habits?range=" + range + domainSuffix,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: projectData, isLoading: projectsLoading, error: projectsError, refetch: refetchProjects } =
|
||||||
|
useApiQuery<ProjectAnalytics>(
|
||||||
|
["analytics-projects", activeDomainId, range],
|
||||||
|
"/analytics/projects?range=" + range + domainSuffix,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: dailyData, isLoading: dailyLoading, error: dailyError, refetch: refetchDaily } =
|
||||||
|
useApiQuery<DailyAnalytics>(
|
||||||
|
["analytics-daily", activeDomainId, range],
|
||||||
|
"/analytics/daily?range=" + range + domainSuffix,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: velocityData } = useApiQuery<{ items: Array<{ date: string; completed: number }>; avg: number }>(
|
||||||
|
["analytics-velocity", activeDomainId, range],
|
||||||
|
"/analytics/velocity?range=" + range + domainSuffix,
|
||||||
|
);
|
||||||
|
|
||||||
|
const analyticsLoading = habitsLoading || projectsLoading || dailyLoading;
|
||||||
|
const analyticsError = habitsError || projectsError || dailyError;
|
||||||
|
|
||||||
|
const refetchAnalytics = () => {
|
||||||
|
refetchHabits();
|
||||||
|
refetchProjects();
|
||||||
|
refetchDaily();
|
||||||
|
};
|
||||||
|
|
||||||
|
const dailyItems = dailyData?.items || [];
|
||||||
|
|
||||||
|
// ─── Derived stats ────────────────────────────────────────────────────
|
||||||
|
const tasksCompleted = useMemo(
|
||||||
|
() => dailyItems.reduce((sum, d) => sum + d.completed, 0),
|
||||||
|
[dailyItems],
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeStreaks = habitData?.activeStreaks ?? 0;
|
||||||
|
const bestStreak = habitData?.bestStreak ?? 0;
|
||||||
|
const totalProjects = projectData?.totalProjects ?? 0;
|
||||||
|
|
||||||
|
// Notes count: use dailyAnalytics created tasks as a proxy, or 0 if unavailable.
|
||||||
|
// The notes API doesn't have a dedicated analytics endpoint, so we estimate from the daily data.
|
||||||
|
const notesWritten = useMemo(() => dailyItems.reduce((sum, d) => sum + d.created, 0), [dailyItems]);
|
||||||
|
|
||||||
|
// ─── Habit pie data ───────────────────────────────────────────────────
|
||||||
|
const habitRateData = useMemo(() => {
|
||||||
|
const expected = (habitData as any)?.totalExpected ?? (habitData?.totalHabits || 0) * parseInt(range);
|
||||||
|
return [
|
||||||
|
{ name: "Completed", value: habitData?.totalLogs || 0 },
|
||||||
|
{ name: "Missed", value: Math.max(0, expected - (habitData?.totalLogs || 0)) },
|
||||||
|
];
|
||||||
|
}, [habitData, range]);
|
||||||
|
|
||||||
|
// ─── CSV export ───────────────────────────────────────────────────────
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rangeLabel = RANGE_OPTIONS.find((r) => r.value === range)?.label || range + " days";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Page header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-bold flex items-center gap-2">
|
||||||
|
<BarChart3 className="h-5 w-5" /> Reports
|
||||||
|
</h1>
|
||||||
|
<Select value={range} onValueChange={setRange}>
|
||||||
|
<SelectTrigger className="w-36">
|
||||||
|
<Calendar className="h-4 w-4 mr-2" />
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{RANGE_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{analyticsLoading ? (
|
||||||
|
<LoadingState label="Loading reports..." />
|
||||||
|
) : analyticsError ? (
|
||||||
|
<ErrorState message={analyticsError.message || "Failed to load reports"} onRetry={refetchAnalytics} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Stat cards */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
<StatCard
|
||||||
|
label="Tasks Completed"
|
||||||
|
value={tasksCompleted}
|
||||||
|
icon={ListTodo}
|
||||||
|
color="text-blue-500"
|
||||||
|
bg="bg-blue-500/10"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Active Streaks"
|
||||||
|
value={activeStreaks}
|
||||||
|
icon={Flame}
|
||||||
|
color="text-green-500"
|
||||||
|
bg="bg-green-500/10"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Tasks Created"
|
||||||
|
value={notesWritten}
|
||||||
|
icon={FileText}
|
||||||
|
color="text-amber-500"
|
||||||
|
bg="bg-amber-500/10"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Active Projects"
|
||||||
|
value={totalProjects}
|
||||||
|
icon={FolderKanban}
|
||||||
|
color="text-purple-500"
|
||||||
|
bg="bg-purple-500/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{/* Task velocity line chart */}
|
||||||
|
<Card className="border rounded-lg hover:bg-muted/20 transition-colors">
|
||||||
|
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="text-sm font-semibold">Task Velocity</CardTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{velocityData?.avg !== undefined && (
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">
|
||||||
|
avg <span className="font-semibold">{velocityData.avg}</span>/day
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() =>
|
||||||
|
downloadCSV("task-velocity.csv", [
|
||||||
|
["Date", "Completed"],
|
||||||
|
...(velocityData?.items || []).map((d) => [d.date, String(d.completed)]),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-3">
|
||||||
|
{velocityData?.items && velocityData.items.length > 0 ? (
|
||||||
|
<LineChart data={velocityData.items} xKey="date" yKey="completed" />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-8">No data for this range</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Habit completion pie chart */}
|
||||||
|
<Card className="border rounded-lg hover:bg-muted/20 transition-colors">
|
||||||
|
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="text-sm font-semibold">Habit Completion</CardTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{habitData && (
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">
|
||||||
|
<span className="font-semibold">{habitData.habitConsistency}%</span> rate
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() =>
|
||||||
|
downloadCSV("habit-completion.csv", [
|
||||||
|
["Status", "Count"],
|
||||||
|
...habitRateData.map((d) => [d.name, String(d.value)]),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-3">
|
||||||
|
{habitData && (habitData.totalLogs > 0 || habitRateData.some((d) => d.value > 0)) ? (
|
||||||
|
<PieChartSimple data={habitRateData} labelKey="name" valueKey="value" />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-8">No habit data</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Project distribution horizontal bar */}
|
||||||
|
<Card className="border rounded-lg hover:bg-muted/20 transition-colors">
|
||||||
|
<CardHeader className="p-3 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", [
|
||||||
|
["Project", "Total Tasks", "Completed", "Progress"],
|
||||||
|
...(projectData?.projects || []).map((p) => [
|
||||||
|
p.name,
|
||||||
|
String(p.totalTasks),
|
||||||
|
String(p.completedTasks),
|
||||||
|
Math.round(p.progress * 100) + "%",
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-3">
|
||||||
|
{projectData?.projects && projectData.projects.length > 0 ? (
|
||||||
|
<HorizontalBar
|
||||||
|
data={projectData.projects.map((p) => ({
|
||||||
|
name: p.name,
|
||||||
|
progress: Math.round(p.progress * 100),
|
||||||
|
}))}
|
||||||
|
xKey="name"
|
||||||
|
yKey="progress"
|
||||||
|
height={Math.max(100, projectData.projects.length * 26)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-8">No projects yet</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Created vs completed bar chart */}
|
||||||
|
<Card className="border rounded-lg hover:bg-muted/20 transition-colors">
|
||||||
|
<CardHeader className="p-3 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"],
|
||||||
|
...dailyItems.map((d) => [d.date, String(d.created), String(d.completed)]),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-3">
|
||||||
|
{dailyItems.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<BarChart
|
||||||
|
data={dailyItems}
|
||||||
|
xKey="date"
|
||||||
|
yKey="created"
|
||||||
|
yKey2="completed"
|
||||||
|
color="#f97316"
|
||||||
|
color2="#3b82f6"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-3 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>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-8">No data</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Tasks by project pie */}
|
||||||
|
<Card className="border rounded-lg hover:bg-muted/20 transition-colors">
|
||||||
|
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="text-sm font-semibold">Tasks by Project</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() =>
|
||||||
|
downloadCSV("tasks-by-project.csv", [
|
||||||
|
["Project", "Total Tasks"],
|
||||||
|
...(projectData?.projects || []).map((p) => [p.name, String(p.totalTasks)]),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-3">
|
||||||
|
{projectData?.projects && projectData.projects.length > 0 ? (
|
||||||
|
<PieChartSimple
|
||||||
|
data={projectData.projects.map((p) => ({ name: p.name, value: p.totalTasks }))}
|
||||||
|
labelKey="name"
|
||||||
|
valueKey="value"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-8">No projects yet</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Best streak highlight */}
|
||||||
|
<Card className="border rounded-lg hover:bg-muted/20 transition-colors">
|
||||||
|
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="text-sm font-semibold">Streak Records</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="text-center p-3 bg-muted/30 rounded-lg">
|
||||||
|
<Flame className="h-5 w-5 mx-auto mb-1 text-orange-500" />
|
||||||
|
<p className="text-xl font-mono font-bold">{bestStreak}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">Best streak</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-muted/30 rounded-lg">
|
||||||
|
<Flame className="h-5 w-5 mx-auto mb-1 text-green-500" />
|
||||||
|
<p className="text-xl font-mono font-bold">{activeStreaks}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">Active streaks</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-center">
|
||||||
|
<p className="text-xs text-muted-foreground font-mono">
|
||||||
|
{habitData?.totalHabits || 0} habit{(habitData?.totalHabits || 0) === 1 ? "" : "s"} tracked · {habitData?.totalLogs || 0} completions in range
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: "/reports",
|
||||||
|
component: ReportsPage,
|
||||||
|
});
|
||||||
@@ -105,7 +105,7 @@ function SearchPage() {
|
|||||||
}, {} as Record<string, SearchResult[]>);
|
}, {} as Record<string, SearchResult[]>);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl mx-auto space-y-6">
|
<div className="max-w-3xl mx-auto space-y-4">
|
||||||
{/* Search bar */}
|
{/* Search bar */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<SearchIcon className="absolute left-3.5 top-3.5 h-5 w-5 text-muted-foreground" />
|
<SearchIcon className="absolute left-3.5 top-3.5 h-5 w-5 text-muted-foreground" />
|
||||||
@@ -153,7 +153,7 @@ function SearchPage() {
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{recentSearches.map((s, i) => (
|
{recentSearches.map((s, i) => (
|
||||||
<Button key={i} variant="ghost" size="sm" onClick={() => handleSearch(s)} className="text-sm">
|
<Button key={i} variant="ghost" size="sm" onClick={() => handleSearch(s)} className="text-sm font-mono">
|
||||||
{s}
|
{s}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
@@ -163,46 +163,51 @@ function SearchPage() {
|
|||||||
|
|
||||||
{/* Results */}
|
{/* Results */}
|
||||||
{debouncedQuery && (
|
{debouncedQuery && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-8 text-muted-foreground">Searching...</div>
|
<div className="text-center py-8 text-muted-foreground">Searching...</div>
|
||||||
) : results.length === 0 ? (
|
) : results.length === 0 ? (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
No results found for "{debouncedQuery}"
|
No results found for "<span className="font-mono">{debouncedQuery}</span>"
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
Object.entries(groupedResults).map(([type, typeResults]) => {
|
<>
|
||||||
const typeDef = SEARCH_TYPES.find((t) => t.id === type);
|
<p className="text-xs text-muted-foreground font-mono">
|
||||||
return (
|
<span className="font-semibold">{results.length}</span> result{results.length === 1 ? "" : "s"} · <span className="font-semibold">{Object.keys(groupedResults).length}</span> type{Object.keys(groupedResults).length === 1 ? "" : "s"}
|
||||||
<div key={type}>
|
</p>
|
||||||
<h3 className="text-sm font-semibold mb-2 flex items-center gap-2">
|
{Object.entries(groupedResults).map(([type, typeResults]) => {
|
||||||
<div className={cn("w-2 h-2 rounded-full", typeDef?.color)} />
|
const typeDef = SEARCH_TYPES.find((t) => t.id === type);
|
||||||
{typeDef?.label || type}
|
return (
|
||||||
<Badge variant="secondary" className="text-[10px]">{typeResults.length}</Badge>
|
<div key={type}>
|
||||||
</h3>
|
<h3 className="text-sm font-semibold mb-1 flex items-center gap-2">
|
||||||
<div className="space-y-1">
|
<div className={cn("w-2 h-2 rounded-full", typeDef?.color)} />
|
||||||
{typeResults.map((result) => (
|
{typeDef?.label || type}
|
||||||
<div
|
<Badge variant="secondary" className="text-[10px] font-mono">{typeResults.length}</Badge>
|
||||||
key={result.id + result.type}
|
</h3>
|
||||||
className="flex items-center justify-between p-3 rounded-lg hover:bg-accent cursor-pointer transition-colors"
|
<div className="space-y-0.5">
|
||||||
onClick={() => navigate({ to: result.type === "domain" ? "/settings" : (result.link as any) })}
|
{typeResults.map((result) => (
|
||||||
>
|
<div
|
||||||
<div className="min-w-0 flex-1">
|
key={result.id + result.type}
|
||||||
<p className="font-medium text-sm truncate">{result.title}</p>
|
className="flex items-center justify-between p-2 rounded-lg hover:bg-muted/20 border border-transparent hover:border-border cursor-pointer transition-colors"
|
||||||
{result.snippet && (
|
onClick={() => navigate({ to: result.type === "domain" ? "/settings" : (result.link as any) })}
|
||||||
<p
|
>
|
||||||
className="text-xs text-muted-foreground mt-0.5 line-clamp-2"
|
<div className="min-w-0 flex-1">
|
||||||
dangerouslySetInnerHTML={{ __html: sanitizeSnippet(result.snippet) }}
|
<p className="font-medium text-sm truncate">{result.title}</p>
|
||||||
/>
|
{result.snippet && (
|
||||||
)}
|
<p
|
||||||
|
className="text-xs text-muted-foreground mt-0.5 line-clamp-2"
|
||||||
|
dangerouslySetInnerHTML={{ __html: sanitizeSnippet(result.snippet) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground ml-2" />
|
||||||
</div>
|
</div>
|
||||||
<ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground ml-2" />
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
})}
|
||||||
})
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ 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 { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useThemeStore, ACCENT_PALETTE, type ThemeMode, type AccentColor } from "@/lib/stores/use-theme-store";
|
import { useThemeStore, type ThemeMode } from "@/lib/stores/use-theme-store";
|
||||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||||
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";
|
||||||
|
|
||||||
@@ -34,21 +34,18 @@ const SETTINGS_TABS = [
|
|||||||
{ id: "error-log", label: "Error Log", icon: AlertCircle },
|
{ id: "error-log", label: "Error Log", icon: AlertCircle },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const ACCENT_COLORS = Object.entries(ACCENT_PALETTE).map(([key, val]) => ({
|
|
||||||
name: val.name,
|
|
||||||
value: key as AccentColor,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const SHORTCUTS_MAP: Record<string, string> = {
|
const SHORTCUTS_MAP: Record<string, string> = {
|
||||||
"Cmd+K": "Command palette",
|
"Cmd+K": "Command palette",
|
||||||
"Cmd+N": "New task",
|
"Cmd+N": "New task",
|
||||||
|
"g+i": "Go to Inbox",
|
||||||
|
"g+d": "Go to Dashboard",
|
||||||
"g+t": "Go to Tasks",
|
"g+t": "Go to Tasks",
|
||||||
"g+h": "Go to Habits",
|
"g+h": "Go to Habits",
|
||||||
"g+p": "Go to Projects",
|
"g+p": "Go to Projects",
|
||||||
"g+n": "Go to Notes",
|
"g+n": "Go to Notes",
|
||||||
"g+c": "Go to Calendar",
|
"g+c": "Go to Calendar",
|
||||||
"g+g": "Go to Graph",
|
"g+g": "Go to Graph",
|
||||||
"g+d": "Go to Dashboard",
|
"g+r": "Go to Reports",
|
||||||
"g+s": "Go to Settings",
|
"g+s": "Go to Settings",
|
||||||
"n+t": "New task",
|
"n+t": "New task",
|
||||||
"n+h": "New habit",
|
"n+h": "New habit",
|
||||||
@@ -60,7 +57,7 @@ const SHORTCUTS_MAP: Record<string, string> = {
|
|||||||
// ─── Appearance Tab ──────────────────────────────────────────────────────
|
// ─── Appearance Tab ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function AppearanceTab() {
|
function AppearanceTab() {
|
||||||
const { mode, setMode, accent, setAccent } = useThemeStore();
|
const { mode, setMode } = useThemeStore();
|
||||||
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");
|
||||||
@@ -103,20 +100,6 @@ function AppearanceTab() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold mb-3">Accent Color</h3>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
{ACCENT_COLORS.map((c) => {
|
|
||||||
const palette = ACCENT_PALETTE[c.value];
|
|
||||||
return (
|
|
||||||
<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: "hsl(" + palette.hsl + ")" }} aria-label={c.name} />
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-3">Font Size</h3>
|
<h3 className="text-lg font-semibold mb-3">Font Size</h3>
|
||||||
<Select value={fontSize} onValueChange={setFontSize}>
|
<Select value={fontSize} onValueChange={setFontSize}>
|
||||||
|
|||||||
@@ -50,30 +50,30 @@ function SortableTaskCard({ task, stateName, stateColor, onClick, onEdit }: { ta
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
||||||
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={onClick}>
|
<Card className="cursor-pointer border rounded-lg transition-colors hover:bg-muted/20" onClick={onClick}>
|
||||||
<CardContent className="p-3">
|
<CardContent className="p-2.5">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<GripVertical className="h-4 w-4 mt-1 shrink-0 text-muted-foreground" />
|
<GripVertical className="h-4 w-4 mt-1 shrink-0 text-muted-foreground" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium truncate">{task.title}</p>
|
<p className="text-sm font-medium truncate">{task.title}</p>
|
||||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||||
{stateName && (
|
{stateName && (
|
||||||
<Badge variant="secondary" className="text-[10px] gap-1" style={stateColor ? { backgroundColor: stateColor + "20", color: stateColor } : undefined}>
|
<Badge variant="secondary" className="font-mono text-[10px] gap-1" style={stateColor ? { backgroundColor: stateColor + "20", color: stateColor } : undefined}>
|
||||||
<span className="h-1.5 w-1.5 rounded-full" style={stateColor ? { backgroundColor: stateColor } : undefined} />
|
<span className="h-1.5 w-1.5 rounded-full" style={stateColor ? { backgroundColor: stateColor } : undefined} />
|
||||||
{stateName}
|
{stateName}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{task.dueDate && (
|
{task.dueDate && (
|
||||||
<Badge variant="outline" className="text-[10px]">
|
<Badge variant="outline" className="font-mono text-[10px]">
|
||||||
<Calendar className="h-3 w-3 mr-1" />
|
<Calendar className="h-3 w-3 mr-1" />
|
||||||
{new Date(task.dueDate).toLocaleDateString()}
|
{new Date(task.dueDate).toLocaleDateString()}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<Badge variant="secondary" className={cn("text-[10px]", PRIORITY[task.priority]?.badge)}>
|
<Badge variant="secondary" className={cn("font-mono text-[10px]", PRIORITY[task.priority]?.badge)}>
|
||||||
{PRIORITY[task.priority]?.label ?? task.priority}
|
{PRIORITY[task.priority]?.label ?? task.priority}
|
||||||
</Badge>
|
</Badge>
|
||||||
{task.tags?.slice(0, 2).map((tag) => (
|
{task.tags?.slice(0, 2).map((tag) => (
|
||||||
<Badge key={tag.id} variant="outline" className="text-[10px]" style={{ borderColor: tag.color || undefined }}>
|
<Badge key={tag.id} variant="outline" className="font-mono text-[10px]" style={{ borderColor: tag.color || undefined }}>
|
||||||
{tag.name}
|
{tag.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
@@ -172,7 +172,7 @@ function TaskForm({ task, onClose, projectId }: { task?: Task; onClose: () => vo
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="title">Title <span className="text-xs text-muted-foreground">— try "Buy milk tomorrow 5pm #groceries p1"</span></Label>
|
<Label htmlFor="title">Title <span className="text-xs text-muted-foreground">— try "Buy milk tomorrow 5pm #groceries p1"</span></Label>
|
||||||
<Input id="title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder='e.g. Report due tomorrow 5pm #work p1' required />
|
<Input id="title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder='e.g. Report due tomorrow 5pm #work p1' required />
|
||||||
@@ -188,7 +188,7 @@ function TaskForm({ task, onClose, projectId }: { task?: Task; onClose: () => vo
|
|||||||
<Label htmlFor="desc">Description</Label>
|
<Label htmlFor="desc">Description</Label>
|
||||||
<Textarea id="desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Description (optional)" rows={3} />
|
<Textarea id="desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Description (optional)" rows={3} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{projectStates.length > 0 && (
|
{projectStates.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="state">State</Label>
|
<Label htmlFor="state">State</Label>
|
||||||
@@ -453,7 +453,7 @@ function TasksPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">Tasks</h1>
|
<h1 className="text-xl font-bold">Tasks</h1>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Tabs value={view} onValueChange={(v) => setView(v as "board" | "list")}>
|
<Tabs value={view} onValueChange={(v) => setView(v as "board" | "list")}>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
@@ -475,7 +475,7 @@ function TasksPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input placeholder="Search tasks..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-8" />
|
<Input placeholder="Search tasks..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-8" />
|
||||||
@@ -513,18 +513,18 @@ function TasksPage() {
|
|||||||
<ErrorState message="Failed to load tasks." onRetry={() => refetch()} />
|
<ErrorState message="Failed to load tasks." onRetry={() => refetch()} />
|
||||||
) : view === "board" ? (
|
) : view === "board" ? (
|
||||||
<DndContext sensors={sensors} collisionDetection={closestCorners} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
<DndContext sensors={sensors} collisionDetection={closestCorners} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3">
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<ColumnDroppable key={col.id} id={col.id} className="bg-muted/50 rounded-lg p-3">
|
<ColumnDroppable key={col.id} id={col.id} className="bg-muted/50 rounded-lg p-3">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className={cn("w-2 h-2 rounded-full", col.colorClass)} />
|
<div className={cn("w-2 h-2 rounded-full", col.colorClass)} />
|
||||||
<h3 className="font-semibold text-sm">{col.label}</h3>
|
<h3 className="font-semibold text-sm">{col.label}</h3>
|
||||||
<Badge variant="secondary" className="text-[10px]">{col.tasks.length}</Badge>
|
<Badge variant="secondary" className="font-mono text-[10px]">{col.tasks.length}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<SortableContext items={col.tasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
|
<SortableContext items={col.tasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
|
||||||
<div className="space-y-2 min-h-[100px]">
|
<div className="space-y-1.5 min-h-[100px]">
|
||||||
{col.tasks.map((task) => {
|
{col.tasks.map((task) => {
|
||||||
const st = task.stateId ? stateById.get(task.stateId) : undefined;
|
const st = task.stateId ? stateById.get(task.stateId) : undefined;
|
||||||
return (
|
return (
|
||||||
@@ -572,11 +572,11 @@ function TasksPage() {
|
|||||||
) : tasks.map((task) => {
|
) : tasks.map((task) => {
|
||||||
const st = task.stateId ? stateById.get(task.stateId) : undefined;
|
const st = task.stateId ? stateById.get(task.stateId) : undefined;
|
||||||
return (
|
return (
|
||||||
<TableRow key={task.id} className="cursor-pointer" onClick={() => openTaskDetail(task)}>
|
<TableRow key={task.id} className="cursor-pointer hover:bg-muted/30 py-2" onClick={() => openTaskDetail(task)}>
|
||||||
<TableCell className="font-medium">{task.title}</TableCell>
|
<TableCell className="font-medium py-2">{task.title}</TableCell>
|
||||||
<TableCell>
|
<TableCell className="py-2">
|
||||||
{st ? (
|
{st ? (
|
||||||
<Badge variant="secondary" className="text-[10px] gap-1" style={st.color ? { backgroundColor: st.color + "20", color: st.color } : undefined}>
|
<Badge variant="secondary" className="font-mono text-[10px] gap-1" style={st.color ? { backgroundColor: st.color + "20", color: st.color } : undefined}>
|
||||||
<span className="h-1.5 w-1.5 rounded-full" style={st.color ? { backgroundColor: st.color } : undefined} />
|
<span className="h-1.5 w-1.5 rounded-full" style={st.color ? { backgroundColor: st.color } : undefined} />
|
||||||
{st.name}
|
{st.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -584,13 +584,13 @@ function TasksPage() {
|
|||||||
<span className="text-xs text-muted-foreground">—</span>
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="py-2">
|
||||||
<Badge variant="outline" className={cn("text-[10px]", PRIORITY[task.priority]?.badge)}>{task.priority}</Badge>
|
<Badge variant="outline" className={cn("font-mono text-[10px]", PRIORITY[task.priority]?.badge)}>{task.priority}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
<TableCell className="font-mono text-[10px] text-muted-foreground py-2">
|
||||||
{task.dueDate ? new Date(task.dueDate).toLocaleDateString() : "-"}
|
{task.dueDate ? new Date(task.dueDate).toLocaleDateString() : "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="py-2">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button>
|
<Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button>
|
||||||
@@ -620,7 +620,7 @@ function TasksPage() {
|
|||||||
|
|
||||||
<EntityDetailPanel open={panelOpen} onOpenChange={setPanelOpen} title={selectedTask?.title || "Task Details"}>
|
<EntityDetailPanel open={panelOpen} onOpenChange={setPanelOpen} title={selectedTask?.title || "Task Details"}>
|
||||||
{selectedTask && (
|
{selectedTask && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<TaskForm task={selectedTask} onClose={() => setPanelOpen(false)} />
|
<TaskForm task={selectedTask} onClose={() => setPanelOpen(false)} />
|
||||||
<div className="pt-4 border-t">
|
<div className="pt-4 border-t">
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
|
|||||||
@@ -300,7 +300,7 @@ function Overview({ task, patch }: { task: Task; patch: PatchFn }) {
|
|||||||
const currentState = task.stateId ? projectStates.find((s) => s.id === task.stateId) : null;
|
const currentState = task.stateId ? projectStates.find((s) => s.id === task.stateId) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1 text-sm font-semibold text-muted-foreground">Description</p>
|
<p className="mb-1 text-sm font-semibold text-muted-foreground">Description</p>
|
||||||
<InlineTextarea
|
<InlineTextarea
|
||||||
@@ -312,7 +312,7 @@ function Overview({ task, patch }: { task: Task; patch: PatchFn }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />
|
<Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
<InlineDate
|
<InlineDate
|
||||||
@@ -327,7 +327,7 @@ function Overview({ task, patch }: { task: Task; patch: PatchFn }) {
|
|||||||
options={ESTIMATE_OPTIONS}
|
options={ESTIMATE_OPTIONS}
|
||||||
displayValue={(v) =>
|
displayValue={(v) =>
|
||||||
v ? (
|
v ? (
|
||||||
<span>{v} min</span>
|
<span className="font-mono text-xs">{v} min</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground/70">No estimate</span>
|
<span className="text-muted-foreground/70">No estimate</span>
|
||||||
)
|
)
|
||||||
@@ -390,25 +390,25 @@ function Overview({ task, patch }: { task: Task; patch: PatchFn }) {
|
|||||||
{task.recurrenceRule ? (
|
{task.recurrenceRule ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<RepeatIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
<RepeatIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
<span className="text-sm">{task.recurrenceRule}</span>
|
<span className="font-mono text-xs">{task.recurrenceRule}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t pt-4">
|
<div className="border-t pt-3">
|
||||||
<TagManager entityType="task" entityId={task.id} tags={task.tags || []} />
|
<TagManager entityType="task" entityId={task.id} tags={task.tags || []} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CustomFieldsDisplay entityType="tasks" values={task.customFields} />
|
<CustomFieldsDisplay entityType="tasks" values={task.customFields} />
|
||||||
|
|
||||||
<div className="flex items-center gap-4 border-t pt-4 text-xs text-muted-foreground">
|
<div className="flex items-center gap-4 border-t pt-3 text-xs text-muted-foreground">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Clock className="h-3 w-3" />
|
<Clock className="h-3 w-3" />
|
||||||
Created {format(parseISO(task.createdAt), "MMM d, yyyy HH:mm")}
|
Created <span className="font-mono">{format(parseISO(task.createdAt), "MMM d, yyyy HH:mm")}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Clock className="h-3 w-3" />
|
<Clock className="h-3 w-3" />
|
||||||
Updated {format(parseISO(task.updatedAt), "MMM d, yyyy HH:mm")}
|
Updated <span className="font-mono">{format(parseISO(task.updatedAt), "MMM d, yyyy HH:mm")}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { createRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { Route as appRoute } from "../_app";
|
||||||
|
import { Plus, Trash2, FileText, Zap } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
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 } 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 {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter as AlertDialogFooterComponent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface Template {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: "task" | "note";
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = "project-e-templates";
|
||||||
|
|
||||||
|
function loadTemplates(): Template[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return raw ? JSON.parse(raw) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveTemplates(templates: Template[]) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(templates));
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplatesPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [templates, setTemplates] = useState<Template[]>(loadTemplates);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
const [newType, setNewType] = useState<"task" | "note">("task");
|
||||||
|
const [newContent, setNewContent] = useState("");
|
||||||
|
|
||||||
|
const handleCreate = useCallback(() => {
|
||||||
|
const trimmedName = newName.trim();
|
||||||
|
if (!trimmedName) {
|
||||||
|
toast.error("Template name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const template: Template = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: trimmedName,
|
||||||
|
type: newType,
|
||||||
|
content: newContent,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const updated = [template, ...templates];
|
||||||
|
setTemplates(updated);
|
||||||
|
saveTemplates(updated);
|
||||||
|
setNewName("");
|
||||||
|
setNewType("task");
|
||||||
|
setNewContent("");
|
||||||
|
setOpen(false);
|
||||||
|
toast.success("Template created");
|
||||||
|
}, [newName, newType, newContent, templates]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
const updated = templates.filter((t) => t.id !== id);
|
||||||
|
setTemplates(updated);
|
||||||
|
saveTemplates(updated);
|
||||||
|
toast.success("Template deleted");
|
||||||
|
},
|
||||||
|
[templates]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUse = useCallback(
|
||||||
|
(template: Template) => {
|
||||||
|
navigator.clipboard.writeText(template.content).catch(() => {});
|
||||||
|
if (template.type === "task") {
|
||||||
|
navigate({ to: "/tasks" });
|
||||||
|
} else {
|
||||||
|
navigate({ to: "/notes" });
|
||||||
|
}
|
||||||
|
toast.success(`Template content copied — paste it when creating your ${template.type}`);
|
||||||
|
},
|
||||||
|
[navigate]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-auto min-h-[calc(100vh-8rem)] md:h-[calc(100vh-8rem)] -m-4 md:-m-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<h1 className="text-xl font-bold">Templates</h1>
|
||||||
|
<Badge variant="secondary" className="font-mono text-[10px]">
|
||||||
|
{templates.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm" className="h-8">
|
||||||
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||||
|
New Template
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>New Template</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-2">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="template-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="template-name"
|
||||||
|
placeholder="e.g. Weekly Review"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Type</Label>
|
||||||
|
<Select value={newType} onValueChange={(v) => setNewType(v as "task" | "note")}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="task">Task</SelectItem>
|
||||||
|
<SelectItem value="note">Note</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="template-content">Content</Label>
|
||||||
|
<Textarea
|
||||||
|
id="template-content"
|
||||||
|
placeholder="Template content..."
|
||||||
|
rows={6}
|
||||||
|
value={newContent}
|
||||||
|
onChange={(e) => setNewContent(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCreate}>Create</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-auto p-4">
|
||||||
|
{templates.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<FileText className="h-10 w-10 mx-auto mb-2 text-indigo-400" />
|
||||||
|
<p className="text-sm text-muted-foreground">No templates yet</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Create reusable task or note templates
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{templates.map((template) => (
|
||||||
|
<Card key={template.id} className="border rounded-lg">
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="font-medium text-sm truncate">{template.name}</span>
|
||||||
|
<Badge
|
||||||
|
variant={template.type === "task" ? "default" : "secondary"}
|
||||||
|
className="font-mono text-[10px] shrink-0"
|
||||||
|
>
|
||||||
|
{template.type === "task" ? "Task" : "Note"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{template.content && (
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-2 mb-1">
|
||||||
|
{template.content.slice(0, 100)}
|
||||||
|
{template.content.length > 100 ? "..." : ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<span className="font-mono text-[10px] text-muted-foreground">
|
||||||
|
{new Date(template.createdAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => handleUse(template)}
|
||||||
|
title="Use template"
|
||||||
|
>
|
||||||
|
<Zap className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
title="Delete template"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Template</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete "{template.name}"?
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooterComponent>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => handleDelete(template.id)}
|
||||||
|
className="bg-destructive text-destructive-foreground"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooterComponent>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: "/templates",
|
||||||
|
component: TemplatesPage,
|
||||||
|
});
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { createRoute, useNavigate } from "@tanstack/react-router";
|
import { createRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { Route as rootRoute } from "./__root";
|
import { Route as rootRoute } from "./__root";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Loader2, Sparkles } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useAuthStore } from "@/lib/stores/use-auth-store";
|
import { useAuthStore } from "@/lib/stores/use-auth-store";
|
||||||
|
|
||||||
function LoginPage() {
|
function LoginPage() {
|
||||||
@@ -42,73 +42,105 @@ function LoginPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-background px-4">
|
<div className="flex min-h-screen bg-background">
|
||||||
<div className="w-full max-w-sm">
|
{/* Left branding side - hidden on mobile */}
|
||||||
<div className="mb-6 flex flex-col items-center gap-3 text-center">
|
<div className="relative hidden w-1/2 flex-col items-center justify-center bg-gradient-to-br from-background via-background to-muted/50 lg:flex">
|
||||||
|
{/* Decorative dots */}
|
||||||
|
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||||
|
<div className="absolute left-1/4 top-1/4 h-2 w-2 rounded-full bg-rose-500/40" />
|
||||||
|
<div className="absolute right-1/3 top-1/3 h-3 w-3 rounded-full bg-amber-500/30" />
|
||||||
|
<div className="absolute bottom-1/4 left-1/3 h-2.5 w-2.5 rounded-full bg-violet-500/35" />
|
||||||
|
<div className="absolute bottom-1/3 right-1/4 h-2 w-2 rounded-full bg-emerald-500/30" />
|
||||||
|
<div className="absolute left-1/2 top-1/2 h-1.5 w-1.5 rounded-full bg-sky-500/25" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Branding content */}
|
||||||
|
<div className="relative z-10 flex flex-col items-center gap-5 text-center">
|
||||||
<span
|
<span
|
||||||
className="flex h-12 w-12 items-center justify-center rounded-xl shadow-md"
|
className="flex h-16 w-16 items-center justify-center rounded-2xl"
|
||||||
style={{ backgroundColor: "hsl(var(--accent-hsl))" }}
|
style={{ backgroundColor: "hsl(var(--accent-hsl))" }}
|
||||||
>
|
>
|
||||||
<Sparkles className="h-6 w-6 text-white" aria-hidden="true" />
|
<span className="text-2xl font-bold text-white">E</span>
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Project E</h1>
|
||||||
|
<p className="text-muted-foreground">Your productivity command center</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right form side */}
|
||||||
|
<div className="flex w-full flex-col items-center justify-center px-4 py-8 sm:px-6 lg:w-1/2">
|
||||||
|
{/* Mobile branding - shown only on mobile */}
|
||||||
|
<div className="mb-8 flex flex-col items-center gap-3 text-center lg:hidden">
|
||||||
|
<span
|
||||||
|
className="flex h-16 w-16 items-center justify-center rounded-2xl"
|
||||||
|
style={{ backgroundColor: "hsl(var(--accent-hsl))" }}
|
||||||
|
>
|
||||||
|
<span className="text-2xl font-bold text-white">E</span>
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Project E</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Project E</h1>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">Your productivity command center</p>
|
||||||
Sign in to your workspace
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="shadow-md">
|
<div className="w-full max-w-sm">
|
||||||
<CardHeader>
|
<Card className="border shadow-none">
|
||||||
<CardTitle>Sign in</CardTitle>
|
<CardHeader className="pb-4">
|
||||||
<CardDescription>
|
<CardTitle className="text-xl">Welcome back</CardTitle>
|
||||||
Enter your credentials to continue
|
<p className="text-sm text-muted-foreground">Sign in to continue</p>
|
||||||
</CardDescription>
|
</CardHeader>
|
||||||
</CardHeader>
|
<CardContent>
|
||||||
<CardContent>
|
{error && (
|
||||||
{error && (
|
<div
|
||||||
<div
|
role="alert"
|
||||||
role="alert"
|
className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive"
|
||||||
className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive"
|
>
|
||||||
>
|
{error}
|
||||||
{error}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<div className="space-y-1.5">
|
||||||
<div className="space-y-1.5">
|
<Label htmlFor="email">Email</Label>
|
||||||
<Label htmlFor="email">Email</Label>
|
<Input
|
||||||
<Input
|
id="email"
|
||||||
id="email"
|
type="email"
|
||||||
type="email"
|
value={email}
|
||||||
value={email}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
placeholder="you@example.com"
|
||||||
placeholder="you@example.com"
|
autoComplete="email"
|
||||||
autoComplete="email"
|
autoFocus
|
||||||
autoFocus
|
required
|
||||||
required
|
className={error ? "border-destructive" : ""}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="password">Password</Label>
|
<Label htmlFor="password">Password</Label>
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
required
|
required
|
||||||
/>
|
className={error ? "border-destructive" : ""}
|
||||||
</div>
|
/>
|
||||||
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
</div>
|
||||||
{isSubmitting && (
|
<Button
|
||||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
type="submit"
|
||||||
)}
|
className="w-full bg-[hsl(var(--accent-hsl))] text-white hover:bg-[hsl(var(--accent-hsl))]/90"
|
||||||
{isSubmitting ? "Signing in..." : "Sign in"}
|
disabled={isSubmitting}
|
||||||
</Button>
|
>
|
||||||
</form>
|
{isSubmitting && (
|
||||||
</CardContent>
|
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||||
</Card>
|
)}
|
||||||
|
{isSubmitting ? "Signing in..." : "Sign in"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export default {
|
|||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ["var(--font-sans)"],
|
sans: ["var(--font-sans)"],
|
||||||
|
mono: ["var(--font-mono)"],
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
border: "hsl(var(--border))",
|
border: "hsl(var(--border))",
|
||||||
@@ -43,6 +44,13 @@ export default {
|
|||||||
DEFAULT: "hsl(var(--card))",
|
DEFAULT: "hsl(var(--card))",
|
||||||
foreground: "hsl(var(--card-foreground))",
|
foreground: "hsl(var(--card-foreground))",
|
||||||
},
|
},
|
||||||
|
sidebar: {
|
||||||
|
DEFAULT: "hsl(var(--sidebar-bg))",
|
||||||
|
border: "hsl(var(--sidebar-border))",
|
||||||
|
},
|
||||||
|
topbar: {
|
||||||
|
DEFAULT: "hsl(var(--topbar-bg))",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: "var(--radius)",
|
lg: "var(--radius)",
|
||||||
|
|||||||
Reference in New Issue
Block a user