feat: add server error logging and tighten workspace isolation
This commit is contained in:
@@ -40,6 +40,9 @@ export function TagManager({ entityType, entityId, tags }: TagManagerProps) {
|
||||
const availableTags = allTags.filter((t) => !assignedIds.has(t.id));
|
||||
|
||||
const refreshEntity = () => {
|
||||
// Refresh the list view (["tasks", ...], ["habits", ...], ["notes", ...])
|
||||
// and the detail view (["task", id], ...) so badges stay in sync in both.
|
||||
queryClient.invalidateQueries({ queryKey: [plural] });
|
||||
queryClient.invalidateQueries({ queryKey: [entityType, entityId] });
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useNavigate, useLocation } from "@tanstack/react-router";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
ListTodo,
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command";
|
||||
import { useThemeStore, type AccentColor, ACCENT_PALETTE } from "@/lib/stores/use-theme-store";
|
||||
import { useCreateDialogStore } from "@/lib/stores/use-create-dialog-store";
|
||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||
|
||||
interface NavItem {
|
||||
label: string;
|
||||
@@ -70,7 +72,9 @@ function addRecentPage(href: string) {
|
||||
|
||||
export function CommandPalette() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { mode, setMode, accent, setAccent } = useThemeStore();
|
||||
const activeDomainId = useApiDomain();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<
|
||||
Array<{ type: string; items: Array<{ id: string; title: string; link?: string }> }>
|
||||
@@ -94,36 +98,50 @@ export function CommandPalette() {
|
||||
|
||||
// Track page navigation for recent items
|
||||
useEffect(() => {
|
||||
const path = window.location.pathname;
|
||||
if (path !== "/login") addRecentPage(path);
|
||||
}, []);
|
||||
if (location.pathname !== "/login") addRecentPage(location.pathname);
|
||||
}, [location.pathname]);
|
||||
|
||||
// Quick actions
|
||||
const quickActions: QuickAction[] = [
|
||||
{
|
||||
label: "New task",
|
||||
icon: ListTodo,
|
||||
action: () => navigate({ to: "/tasks" }),
|
||||
action: () => {
|
||||
useCreateDialogStore.getState().openCreate("task");
|
||||
navigate({ to: "/tasks" });
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "New habit",
|
||||
icon: Flame,
|
||||
action: () => navigate({ to: "/habits" }),
|
||||
action: () => {
|
||||
useCreateDialogStore.getState().openCreate("habit");
|
||||
navigate({ to: "/habits" });
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "New project",
|
||||
icon: FolderKanban,
|
||||
action: () => navigate({ to: "/projects" }),
|
||||
action: () => {
|
||||
useCreateDialogStore.getState().openCreate("project");
|
||||
navigate({ to: "/projects" });
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "New note",
|
||||
icon: NotebookPen,
|
||||
action: () => navigate({ to: "/notes" }),
|
||||
action: () => {
|
||||
useCreateDialogStore.getState().openCreate("note");
|
||||
navigate({ to: "/notes" });
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "New event",
|
||||
icon: CalendarDays,
|
||||
action: () => navigate({ to: "/calendar" }),
|
||||
action: () => {
|
||||
useCreateDialogStore.getState().openCreate("event");
|
||||
navigate({ to: "/calendar" });
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -172,7 +190,7 @@ export function CommandPalette() {
|
||||
const mentionQuery = query.slice(1).trim();
|
||||
if (mentionQuery) {
|
||||
try {
|
||||
const res = await fetch(`/api/agents?q=${encodeURIComponent(mentionQuery)}`);
|
||||
const res = await fetch(`/api/agents?q=${encodeURIComponent(mentionQuery)}` + (activeDomainId ? "&domain=" + activeDomainId : ""));
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSearchResults([
|
||||
@@ -195,16 +213,24 @@ export function CommandPalette() {
|
||||
// Debounced API search
|
||||
searchTimeoutRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=5`);
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=5` + (activeDomainId ? "&domain=" + activeDomainId : ""));
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSearchResults(data.results || []);
|
||||
// The API returns a flat list of SearchResult objects; group them by
|
||||
// entity type for the grouped render below.
|
||||
const flat: Array<{ type: string; id: string; title: string; link?: string }> = data.results || [];
|
||||
const grouped: Record<string, Array<{ id: string; title: string; link?: string }>> = {};
|
||||
for (const r of flat) {
|
||||
const key = r.type.charAt(0).toUpperCase() + r.type.slice(1) + "s";
|
||||
(grouped[key] = grouped[key] || []).push({ id: r.id, title: r.title, link: r.link });
|
||||
}
|
||||
setSearchResults(Object.entries(grouped).map(([type, items]) => ({ type, items })));
|
||||
}
|
||||
} catch {
|
||||
// Ignore search errors
|
||||
}
|
||||
}, 300);
|
||||
}, []);
|
||||
}, [activeDomainId]);
|
||||
|
||||
const runCommand = useCallback(
|
||||
(command: () => void) => {
|
||||
@@ -320,7 +346,7 @@ export function CommandPalette() {
|
||||
runCommand(() => {});
|
||||
return;
|
||||
}
|
||||
const link = group.type === "domain" ? "/" : item.link!;
|
||||
const link = group.type === "Domains" ? "/" : item.link!;
|
||||
runCommand(() => navigate({ to: link }));
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -28,14 +28,15 @@ const shortcutGroups = [
|
||||
{ keys: "n then h", description: "New habit" },
|
||||
{ keys: "n then p", description: "New project" },
|
||||
{ keys: "n then n", description: "New note" },
|
||||
{ keys: "c", description: "Focus create in palette" },
|
||||
{ keys: "⌘N / Ctrl+N", description: "New task" },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "General",
|
||||
shortcuts: [
|
||||
{ keys: "⌘K / Ctrl+K", description: "Open command palette" },
|
||||
{ keys: "/", description: "Focus search" },
|
||||
{ keys: "/", description: "Open command palette" },
|
||||
{ keys: "c", description: "Open command palette" },
|
||||
{ keys: "?", description: "Show this help" },
|
||||
{ keys: "Esc", description: "Close dialogs / panels" },
|
||||
],
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useLocation } from "@tanstack/react-router";
|
||||
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSidebarStore } from "@/lib/stores/use-sidebar-store";
|
||||
import { useAuthStore } from "@/lib/stores/use-auth-store";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
ListTodo,
|
||||
@@ -46,6 +47,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { DomainPicker } from "@/components/shell/domain-picker";
|
||||
|
||||
interface NavItem {
|
||||
href: string;
|
||||
@@ -75,7 +77,11 @@ const bottomItems: NavItem[] = [
|
||||
|
||||
export function Sidebar() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { collapsed, toggle, mobileOpen, setMobileOpen } = useSidebarStore();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const userName = user?.name || user?.email?.split("@")[0] || "User";
|
||||
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
|
||||
@@ -204,12 +210,12 @@ export function Sidebar() {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback className="text-xs">U</AvatarFallback>
|
||||
<AvatarFallback className="text-xs">{userInitials}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side={sidebarPos === "right" ? "left" : "right"} align="start" className="w-48">
|
||||
<DropdownMenuItem onClick={() => {}}>
|
||||
<DropdownMenuItem onClick={() => navigate({ to: "/settings" })}>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Profile
|
||||
</DropdownMenuItem>
|
||||
@@ -229,13 +235,13 @@ export function Sidebar() {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="w-full justify-start gap-3 px-3">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback className="text-xs">U</AvatarFallback>
|
||||
<AvatarFallback className="text-xs">{userInitials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm font-medium">User</span>
|
||||
<span className="text-sm font-medium truncate">{userName}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side={sidebarPos === "right" ? "left" : "right"} align="start" className="w-48">
|
||||
<DropdownMenuItem onClick={() => {}}>
|
||||
<DropdownMenuItem onClick={() => navigate({ to: "/settings" })}>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Profile
|
||||
</DropdownMenuItem>
|
||||
@@ -261,6 +267,9 @@ export function Sidebar() {
|
||||
<SheetTitle>Project E</SheetTitle>
|
||||
<SheetDescription>Navigate your workspace.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="border-b p-4">
|
||||
<DomainPicker />
|
||||
</div>
|
||||
{navigation(false, () => setMobileOpen(false))}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
@@ -2,7 +2,9 @@ import { Search, Bell, Plus, Menu, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useSidebarStore } from "@/lib/stores/use-sidebar-store";
|
||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||
import { useAuthStore } from "@/lib/stores/use-auth-store";
|
||||
import { useApiQuery } from "@/lib/api";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { DomainPicker } from "@/components/shell/domain-picker";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
@@ -38,7 +40,12 @@ function readableEntityType(entityType: string): string {
|
||||
export function Topbar() {
|
||||
const { setMobileOpen } = useSidebarStore();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const domainId = useApiDomain();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const userName = user?.name || "User";
|
||||
const userEmail = user?.email || "";
|
||||
const userInitials = (user?.name || user?.email || "U").slice(0, 2).toUpperCase();
|
||||
|
||||
const openPalette = () => {
|
||||
document.dispatchEvent(new CustomEvent("open-command-palette"));
|
||||
@@ -179,15 +186,15 @@ export function Topbar() {
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 text-sm">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback className="text-xs">U</AvatarFallback>
|
||||
<AvatarFallback className="text-xs">{userInitials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">User</span>
|
||||
<span className="text-xs text-muted-foreground">user@projecte.app</span>
|
||||
<span className="font-medium truncate">{userName}</span>
|
||||
<span className="text-xs text-muted-foreground truncate">{userEmail}</span>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => {}}>
|
||||
<DropdownMenuItem onClick={() => navigate({ to: "/settings" })}>
|
||||
Profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useKeyboardShortcutsStore } from "@/lib/stores/use-keyboard-shortcuts-store";
|
||||
import { useCreateDialogStore } from "@/lib/stores/use-create-dialog-store";
|
||||
import { install, uninstall } from "@github/hotkey";
|
||||
|
||||
export function useKeyboardShortcuts() {
|
||||
@@ -49,16 +50,18 @@ export function useKeyboardShortcuts() {
|
||||
});
|
||||
}
|
||||
|
||||
// n+letter new-entity sequences
|
||||
const newMap: Record<string, string> = {
|
||||
"n t": "/tasks",
|
||||
"n h": "/habits",
|
||||
"n p": "/projects",
|
||||
"n n": "/notes",
|
||||
// n+letter new-entity sequences — navigate AND open the create dialog on
|
||||
// the target page (the page's effect consumes the store request).
|
||||
const newMap: Record<string, { path: string; type: "task" | "habit" | "project" | "note" }> = {
|
||||
"n t": { path: "/tasks", type: "task" },
|
||||
"n h": { path: "/habits", type: "habit" },
|
||||
"n p": { path: "/projects", type: "project" },
|
||||
"n n": { path: "/notes", type: "note" },
|
||||
};
|
||||
|
||||
for (const [seq, path] of Object.entries(newMap)) {
|
||||
for (const [seq, { path, type }] of Object.entries(newMap)) {
|
||||
addHotkey(seq, () => {
|
||||
useCreateDialogStore.getState().openCreate(type);
|
||||
navigate({ to: path });
|
||||
});
|
||||
}
|
||||
@@ -84,6 +87,14 @@ export function useKeyboardShortcuts() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+N / Ctrl+N — new task
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "n") {
|
||||
e.preventDefault();
|
||||
useCreateDialogStore.getState().openCreate("task");
|
||||
navigate({ to: "/tasks" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Single-key shortcuts (no modifiers)
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useEffect } from "react";
|
||||
import { useCreateDialogStore } from "@/lib/stores/use-create-dialog-store";
|
||||
|
||||
/**
|
||||
* Opens a page's local create dialog when the global "new entity" shortcut or
|
||||
* command palette requests it. Pages must call this once with their entity type
|
||||
* and a callback that flips their own create-open state.
|
||||
*
|
||||
* The store is re-read inside the effect (rather than trusting the captured
|
||||
* value) so React StrictMode's double-invoked effects can't fire onOpen twice.
|
||||
*/
|
||||
export function useOpenCreateDialog(type: "task" | "habit" | "project" | "note" | "event", onOpen: () => void) {
|
||||
const open = useCreateDialogStore((s) => s.open);
|
||||
const storeType = useCreateDialogStore((s) => s.type);
|
||||
|
||||
useEffect(() => {
|
||||
const state = useCreateDialogStore.getState();
|
||||
if (state.open && state.type === type) {
|
||||
useCreateDialogStore.getState().closeCreate();
|
||||
onOpen();
|
||||
}
|
||||
}, [open, storeType, type, onOpen]);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||
import type { RealtimeEvent } from "@/lib/types";
|
||||
|
||||
const API_BASE = "/api";
|
||||
@@ -10,7 +11,11 @@ interface UseRealtimeOptions {
|
||||
}
|
||||
|
||||
export function useRealtime(options: UseRealtimeOptions = {}) {
|
||||
const { workspaceId, enabled = true } = options;
|
||||
const { workspaceId: explicitWorkspace, enabled = true } = options;
|
||||
// Default to the user's active domain so clients never receive (or act on)
|
||||
// events from other workspaces. Callers can still pin a specific workspace.
|
||||
const activeDomainId = useApiDomain();
|
||||
const workspaceId = explicitWorkspace || activeDomainId || undefined;
|
||||
const queryClient = useQueryClient();
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -23,13 +28,13 @@ export function useRealtime(options: UseRealtimeOptions = {}) {
|
||||
|
||||
switch (entityType) {
|
||||
case "task":
|
||||
queryKeys.push(["tasks"], ["tasks-due"], ["stats"], ["productivity-chart"]);
|
||||
queryKeys.push(["tasks"], ["tasks-due"], ["stats"], ["productivity-chart"], ["analytics-daily"], ["analytics-projects"]);
|
||||
break;
|
||||
case "habit":
|
||||
queryKeys.push(["habits"], ["habits-today"], ["streaks"]);
|
||||
queryKeys.push(["habits"], ["habits-today"], ["streaks"], ["analytics-habits"]);
|
||||
break;
|
||||
case "project":
|
||||
queryKeys.push(["projects"], ["active-projects"]);
|
||||
queryKeys.push(["projects"], ["active-projects"], ["analytics-projects"]);
|
||||
break;
|
||||
case "note":
|
||||
queryKeys.push(["notes"], ["recent-notes"]);
|
||||
@@ -40,6 +45,29 @@ export function useRealtime(options: UseRealtimeOptions = {}) {
|
||||
case "dashboard_widget":
|
||||
queryKeys.push(["dashboard-widgets"]);
|
||||
break;
|
||||
case "daily_note":
|
||||
queryKeys.push(["daily-notes-list"], ["daily-note"]);
|
||||
break;
|
||||
case "agent":
|
||||
queryKeys.push(["agents"], ["agents-list"], ["agent-activity"]);
|
||||
break;
|
||||
case "canvas":
|
||||
queryKeys.push(["canvas"]);
|
||||
break;
|
||||
case "webhook":
|
||||
queryKeys.push(["webhooks"]);
|
||||
break;
|
||||
case "custom_field":
|
||||
queryKeys.push(["custom-fields"]);
|
||||
break;
|
||||
case "section":
|
||||
case "member":
|
||||
queryKeys.push(["projects"]);
|
||||
break;
|
||||
case "comment":
|
||||
case "attachment":
|
||||
queryKeys.push(["tasks"], ["task"]);
|
||||
break;
|
||||
case "graph_edge":
|
||||
queryKeys.push(["graph"]);
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useAuthStore } from "./stores/use-auth-store";
|
||||
|
||||
/**
|
||||
* Boot-time session check. Runs once before the app renders so the shell never
|
||||
* flashes for unauthenticated users and the logged-in identity is available
|
||||
* immediately. Redirects to /login when unauthenticated and to / when an
|
||||
* authenticated user lands on /login.
|
||||
*/
|
||||
export async function bootstrapSession(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch("/api/auth/session", { credentials: "include" });
|
||||
const data = await res.json().catch(() => ({ authenticated: false, user: null }));
|
||||
const user = data?.authenticated ? data.user : null;
|
||||
useAuthStore.setState({ user: user ?? null, checked: true });
|
||||
const path = window.location.pathname;
|
||||
if (user && path === "/login") {
|
||||
window.location.replace("/");
|
||||
} else if (!user && path !== "/login") {
|
||||
window.location.replace("/login");
|
||||
}
|
||||
} catch {
|
||||
useAuthStore.setState({ user: null, checked: true });
|
||||
if (window.location.pathname !== "/login") window.location.replace("/login");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: AuthUser | null;
|
||||
checked: boolean;
|
||||
setUser: (user: AuthUser | null) => void;
|
||||
setChecked: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()((set) => ({
|
||||
user: null,
|
||||
checked: false,
|
||||
setUser: (user) => set({ user }),
|
||||
setChecked: (checked) => set({ checked }),
|
||||
}));
|
||||
@@ -22,6 +22,9 @@ export const useSidebarStore = create<SidebarState>()(
|
||||
}),
|
||||
{
|
||||
name: "project-e-sidebar",
|
||||
// Never persist the transient mobile drawer state — restoring it on the
|
||||
// next load would reopen the Sheet (and its dark overlay) on desktop.
|
||||
partialize: (state) => ({ collapsed: state.collapsed }) as SidebarState,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -104,8 +104,9 @@ export interface Note {
|
||||
}
|
||||
|
||||
export interface Backlink {
|
||||
noteId: string;
|
||||
noteTitle: string;
|
||||
id: string;
|
||||
title: string;
|
||||
excerpt?: string;
|
||||
}
|
||||
|
||||
export interface OutgoingLink {
|
||||
@@ -246,9 +247,10 @@ export interface WebhookDelivery {
|
||||
export interface ErrorLog {
|
||||
id: string;
|
||||
level: string;
|
||||
source: string;
|
||||
message: string;
|
||||
stack: string | null;
|
||||
context: Record<string, unknown> | null;
|
||||
stackTrace: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -291,6 +293,8 @@ export interface AgentActivity {
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
metadata: Record<string, unknown> | null;
|
||||
details?: Record<string, unknown> | null;
|
||||
errorMessage?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
|
||||
+24
-11
@@ -1,10 +1,12 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { QueryClient, QueryClientProvider, MutationCache } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { toast } from "sonner";
|
||||
import { routeTree } from "./routeTree";
|
||||
import { ThemeProvider } from "@/components/shell/theme-provider";
|
||||
import { bootstrapSession } from "./lib/session";
|
||||
import "./index.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -15,6 +17,15 @@ const queryClient = new QueryClient({
|
||||
refetchOnWindowFocus: true,
|
||||
},
|
||||
},
|
||||
mutationCache: new MutationCache({
|
||||
// Surface failures that individual mutations don't handle themselves so
|
||||
// silent validation/network errors never go unnoticed.
|
||||
onError: (error, _variables, _context, mutation) => {
|
||||
if (!mutation.options.onError) {
|
||||
toast.error((error as Error).message || "Request failed");
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const router = createRouter({ routeTree });
|
||||
@@ -28,13 +39,15 @@ declare module "@tanstack/react-router" {
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("Root element not found");
|
||||
|
||||
ReactDOM.createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ThemeProvider>
|
||||
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
bootstrapSession().finally(() => {
|
||||
ReactDOM.createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ThemeProvider>
|
||||
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -10,16 +10,20 @@ import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
function AppLayout() {
|
||||
useKeyboardShortcuts();
|
||||
|
||||
// Apply persisted appearance preferences (density, reduced motion) right
|
||||
// after the first paint. The settings page updates these live while open;
|
||||
// this covers reloads where the settings page was never visited.
|
||||
// Apply persisted appearance preferences (density, reduced motion, font size)
|
||||
// right after the first paint. The settings page updates these live while
|
||||
// open; this covers reloads where the settings page was never visited.
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("density-compact", "density-spacious");
|
||||
root.classList.remove("density-compact", "density-spacious", "reduce-motion");
|
||||
const density = localStorage.getItem("density");
|
||||
if (density === "compact") root.classList.add("density-compact");
|
||||
if (density === "spacious") root.classList.add("density-spacious");
|
||||
if (localStorage.getItem("reduced-motion") === "true") root.classList.add("reduce-motion");
|
||||
const fontSize = localStorage.getItem("font-size");
|
||||
if (fontSize === "large") root.style.fontSize = "18px";
|
||||
else if (fontSize === "small") root.style.fontSize = "13px";
|
||||
else root.style.fontSize = "16px";
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { createRoute } from "@tanstack/react-router";
|
||||
import { createRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Route as appRoute } from "../_app";
|
||||
|
||||
function AgentsPage() {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh]">
|
||||
<h1 className="text-3xl font-bold mb-2">Agent Activity</h1>
|
||||
<p className="text-muted-foreground">Coming in T7 — agent activity feed.</p>
|
||||
<p className="text-muted-foreground mb-4">The agent activity feed moved to its own page.</p>
|
||||
<button onClick={() => navigate({ to: "/agents/activity" })} className="text-primary underline underline-offset-4">
|
||||
Open Agent Activity
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createRoute } from "@tanstack/react-router";
|
||||
import { Route as appRoute } from "../../_app";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||
import { Bot, Filter, Calendar, RefreshCw, ExternalLink, Clock, Activity } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingState, EmptyState } from "@/components/state";
|
||||
@@ -32,52 +33,76 @@ function AgentActivityPage() {
|
||||
const [dateTo, setDateTo] = useState("");
|
||||
const [liveActivities, setLiveActivities] = useState<AgentActivity[]>([]);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const activeDomainId = useApiDomain();
|
||||
|
||||
// Keep latest filters reachable from the SSE effect without reconnecting.
|
||||
const filtersRef = useRef({ agentFilter, actionFilter, dateFrom, dateTo });
|
||||
filtersRef.current = { agentFilter, actionFilter, dateFrom, dateTo };
|
||||
|
||||
// Live entries are transient and never filtered server-side, so clear them
|
||||
// whenever the user changes any filter.
|
||||
useEffect(() => {
|
||||
setLiveActivities([]);
|
||||
}, [agentFilter, actionFilter, dateFrom, dateTo]);
|
||||
|
||||
// Fetch agents for filter dropdown
|
||||
const { data: agentsData } = useApiQuery<PaginatedResponse<Agent>>(["agents-list"], "/agents");
|
||||
const { data: agentsData } = useApiQuery<PaginatedResponse<Agent>>(["agents-list", activeDomainId], "/agents" + (activeDomainId ? "?domain=" + activeDomainId : ""));
|
||||
const agents = agentsData?.items || [];
|
||||
|
||||
// Build query params
|
||||
const params = new URLSearchParams({ limit: "100" });
|
||||
if (agentFilter) params.set("agentId", agentFilter);
|
||||
if (actionFilter) params.set("action", actionFilter);
|
||||
if (activeDomainId) params.set("domain", activeDomainId);
|
||||
if (agentFilter && agentFilter !== "all") params.set("agentId", agentFilter);
|
||||
if (actionFilter && actionFilter !== "all") params.set("action", actionFilter);
|
||||
if (dateFrom) params.set("from", dateFrom);
|
||||
if (dateTo) params.set("to", dateTo);
|
||||
|
||||
const { data: activityData, isLoading } = useApiQuery<{ items: AgentActivity[]; totalItems: number }>(
|
||||
["agent-activity", agentFilter, actionFilter, dateFrom, dateTo],
|
||||
"/agents/" + (agentFilter || "_all") + "/activity?" + params.toString()
|
||||
"/agents/" + (agentFilter && agentFilter !== "all" ? agentFilter : "_all") + "/activity?" + params.toString()
|
||||
);
|
||||
|
||||
const activities = [...liveActivities, ...(activityData?.items || [])];
|
||||
|
||||
// SSE for live updates
|
||||
useEffect(() => {
|
||||
const es = new EventSource("/api/realtime");
|
||||
const params = new URLSearchParams();
|
||||
if (activeDomainId) params.set("workspace_id", activeDomainId);
|
||||
const es = new EventSource("/api/realtime" + (params.toString() ? "?" + params.toString() : ""));
|
||||
eventSourceRef.current = es;
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
// Realtime events are flat: { type: entityType, action, id, workspace_id }.
|
||||
// Match only agent events so unrelated task/habit/etc. activity doesn't leak in.
|
||||
if (data.type === "agent") {
|
||||
const entry: AgentActivity = {
|
||||
id: `${data.id}-live-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
agentId: data.id,
|
||||
action: data.action,
|
||||
description: `Live update: ${data.action}`,
|
||||
entityType: "agent",
|
||||
entityId: data.id,
|
||||
metadata: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setLiveActivities((prev) => [entry, ...prev].slice(0, 5));
|
||||
}
|
||||
// Match only agent events for the active workspace so unrelated task/habit
|
||||
// etc. activity (or another workspace's events) doesn't leak in.
|
||||
if (data.type !== "agent") return;
|
||||
if (activeDomainId && data.workspace_id !== activeDomainId) return;
|
||||
|
||||
const { agentFilter, actionFilter, dateFrom, dateTo } = filtersRef.current;
|
||||
if (agentFilter && agentFilter !== "all" && data.id !== agentFilter) return;
|
||||
if (actionFilter && actionFilter !== "all" && data.action !== actionFilter) return;
|
||||
const now = new Date();
|
||||
const nowKey = now.toISOString().slice(0, 10);
|
||||
if (dateFrom && nowKey < dateFrom) return;
|
||||
if (dateTo && nowKey > dateTo) return;
|
||||
|
||||
const entry: AgentActivity = {
|
||||
id: `${data.id}-live-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
agentId: data.id,
|
||||
action: data.action,
|
||||
description: `Live update: ${data.action}`,
|
||||
entityType: "agent",
|
||||
entityId: data.id,
|
||||
metadata: null,
|
||||
createdAt: now.toISOString(),
|
||||
};
|
||||
setLiveActivities((prev) => [entry, ...prev].slice(0, 5));
|
||||
} catch {}
|
||||
};
|
||||
es.onerror = () => {};
|
||||
return () => { es.close(); };
|
||||
}, []);
|
||||
}, [activeDomainId]);
|
||||
|
||||
const getActionColor = (action: string) => {
|
||||
const found = ACTION_TYPES.find((a) => a.id === action);
|
||||
@@ -106,7 +131,7 @@ function AgentActivityPage() {
|
||||
<SelectValue placeholder="All agents" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value=" ">All agents</SelectItem>
|
||||
<SelectItem value="all">All agents</SelectItem>
|
||||
{agents.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
|
||||
))}
|
||||
@@ -118,7 +143,7 @@ function AgentActivityPage() {
|
||||
<SelectValue placeholder="All actions" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value=" ">All actions</SelectItem>
|
||||
<SelectItem value="all">All actions</SelectItem>
|
||||
{ACTION_TYPES.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>{a.label}</SelectItem>
|
||||
))}
|
||||
@@ -153,6 +178,10 @@ function AgentActivityPage() {
|
||||
<Badge variant="outline" className="text-[10px]">{a.entityType}</Badge>
|
||||
</div>
|
||||
{a.description && <p className="text-sm text-muted-foreground mt-0.5">{a.description}</p>}
|
||||
{!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>
|
||||
)}
|
||||
{a.errorMessage && <p className="text-sm text-destructive mt-0.5">{a.errorMessage}</p>}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Clock className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">{format(parseISO(a.createdAt), "MMM d, HH:mm:ss")}</span>
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { Download, Calendar } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingState, ErrorState } from "@/components/state";
|
||||
@@ -140,6 +141,8 @@ function AnalyticsPage() {
|
||||
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);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useQueryClient, useMutation } from "@tanstack/react-query";
|
||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||
import { useRealtime } from "@/hooks/use-realtime";
|
||||
import { useOpenCreateDialog } from "@/hooks/use-open-create-dialog";
|
||||
import { Plus, Trash2, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -95,6 +96,7 @@ function CustomToolbar({ date, onNavigate, label }: any) {
|
||||
|
||||
function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const activeDomainId = useApiDomain();
|
||||
const [title, setTitle] = useState(event?.title || "");
|
||||
const [startTime, setStartTime] = useState(event?.startTime ? event.startTime.slice(0, 16) : "");
|
||||
const [endTime, setEndTime] = useState(event?.endTime ? event.endTime.slice(0, 16) : "");
|
||||
@@ -126,7 +128,7 @@ function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => v
|
||||
if (startTime) data.startTime = new Date(startTime).toISOString();
|
||||
if (endTime) data.endTime = new Date(endTime).toISOString();
|
||||
if (event) updateMutation.mutate(data);
|
||||
else createMutation.mutate(data);
|
||||
else createMutation.mutate({ ...data, ...(activeDomainId ? { domain: activeDomainId } : {}) });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -142,7 +144,7 @@ function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => v
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="start">Start</Label>
|
||||
<Input id="start" type={allDay ? "date" : "datetime-local"} value={startTime} onChange={(e) => setStartTime(e.target.value)} />
|
||||
<Input id="start" type={allDay ? "date" : "datetime-local"} value={startTime} onChange={(e) => setStartTime(e.target.value)} required />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="end">End</Label>
|
||||
@@ -172,6 +174,8 @@ function CalendarPage() {
|
||||
|
||||
useRealtime({ enabled: true });
|
||||
|
||||
useOpenCreateDialog("event", () => setCreateOpen(true));
|
||||
|
||||
useEffect(() => {
|
||||
const check = () => setIsMobile(window.innerWidth < 768);
|
||||
check();
|
||||
@@ -183,7 +187,7 @@ function CalendarPage() {
|
||||
const activeDomainId = useApiDomain();
|
||||
|
||||
const { data: eventsData, isLoading: eventsLoading } = useApiQuery<{ items: CalendarEvent[]; totalItems: number }>(
|
||||
["calendar-events", activeDomainId, date.toISOString()],
|
||||
["calendar-events", activeDomainId],
|
||||
`/calendar/events?from=${new Date(0).toISOString()}&to=${new Date("2100-01-01").toISOString()}` + (activeDomainId ? "&domain=" + activeDomainId : "")
|
||||
);
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||
import type { Canvas, CanvasCard, PaginatedResponse } from "@/lib/types";
|
||||
|
||||
const BLOCK_TYPES = [
|
||||
@@ -382,12 +383,13 @@ function CanvasList() {
|
||||
const navigate = useNavigate();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const activeDomainId = useApiDomain();
|
||||
|
||||
const { data, isLoading } = useApiQuery<PaginatedResponse<Canvas>>(["canvas"], "/canvas");
|
||||
const { data, isLoading } = useApiQuery<PaginatedResponse<Canvas>>(["canvas", activeDomainId], "/canvas" + (activeDomainId ? "?domain=" + activeDomainId : ""));
|
||||
const canvases = data?.items || [];
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (name: string) => api.post<Canvas>("/canvas", { name }),
|
||||
mutationFn: (name: string) => api.post<Canvas>("/canvas", { name, ...(activeDomainId ? { domain: activeDomainId } : {}) }),
|
||||
onSuccess: (canvas) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["canvas"] });
|
||||
setCreateOpen(false);
|
||||
@@ -429,9 +431,34 @@ function CanvasList() {
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{canvases.map((c) => (
|
||||
<Card key={c.id} className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => navigate({ to: "/canvas/$id", params: { id: c.id } })}>
|
||||
<Card key={c.id} className="cursor-pointer hover:shadow-md transition-shadow group" onClick={() => navigate({ to: "/canvas/$id", params: { id: c.id } })}>
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-sm font-semibold truncate">{c.name}</CardTitle>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className="text-sm font-semibold truncate">{c.name}</CardTitle>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 opacity-0 group-hover:opacity-100 text-destructive"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={"Delete canvas " + c.name}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Canvas</AlertDialogTitle>
|
||||
<AlertDialogDescription>Are you sure you want to delete "{c.name}"? All blocks in it will be removed. This cannot be undone.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={(e) => e.stopPropagation()}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(c.id); }} className="bg-destructive text-destructive-foreground">Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||
import type { DailyNote } from "@/lib/types";
|
||||
import { format, startOfMonth, endOfMonth, eachDayOfInterval, getDay, isSameDay, isToday, addMonths, subMonths } from "date-fns";
|
||||
|
||||
@@ -26,7 +27,8 @@ function CalendarSidebar({ selectedDate, onSelectDate }: { selectedDate: Date; o
|
||||
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
// Check which dates have notes
|
||||
const { data } = useApiQuery<{ items: DailyNote[]; totalItems: number }>(["daily-notes-list"], "/daily-notes");
|
||||
const activeDomainId = useApiDomain();
|
||||
const { data } = useApiQuery<{ items: DailyNote[]; totalItems: number }>(["daily-notes-list", activeDomainId], "/daily-notes" + (activeDomainId ? "?domain=" + activeDomainId : ""));
|
||||
const notes = data?.items || [];
|
||||
// The API stores daily notes at UTC midnight (YYYY-MM-DDT00:00:00.000Z).
|
||||
// Slicing off the time portion yields the calendar date the note belongs to
|
||||
@@ -88,6 +90,8 @@ function CalendarSidebar({ selectedDate, onSelectDate }: { selectedDate: Date; o
|
||||
|
||||
function DailyNoteEditor({ date }: { date: Date }) {
|
||||
const queryClient = useQueryClient();
|
||||
const activeDomainId = useApiDomain();
|
||||
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
||||
const dateStr = format(date, "yyyy-MM-dd");
|
||||
const [content, setContent] = useState("");
|
||||
const [mood, setMood] = useState<number | null>(null);
|
||||
@@ -106,8 +110,8 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
||||
noteIdRef.current = noteId;
|
||||
|
||||
const { data: note, isLoading } = useApiQuery<DailyNote | null>(
|
||||
["daily-note", dateStr],
|
||||
"/daily-notes?date=" + dateStr
|
||||
["daily-note", dateStr, activeDomainId],
|
||||
"/daily-notes?date=" + dateStr + domainSuffix
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -174,10 +178,10 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
||||
if (id) {
|
||||
updateMutation.mutate({ id, data: { content: newContent, mood: newMood, energy: newEnergy } });
|
||||
} else if (newContent.trim()) {
|
||||
createMutation.mutate({ date: dateStr, content: newContent, mood: newMood, energy: newEnergy });
|
||||
createMutation.mutate({ date: dateStr, content: newContent, mood: newMood, energy: newEnergy, ...(activeDomainId ? { domain: activeDomainId } : {}) });
|
||||
}
|
||||
}, 1500);
|
||||
}, [dateStr, updateMutation, createMutation]);
|
||||
}, [dateStr, activeDomainId, updateMutation, createMutation]);
|
||||
|
||||
const handleContentChange = (value: string) => {
|
||||
setContent(value);
|
||||
@@ -191,7 +195,7 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
||||
} else if (isNew && !createMutation.isPending) {
|
||||
// No note exists for this day yet — create it so the mood is recorded
|
||||
// even before any content is typed.
|
||||
createMutation.mutate({ date: dateStr, content: content, mood: value, energy: energy });
|
||||
createMutation.mutate({ date: dateStr, content: content, mood: value, energy: energy, ...(activeDomainId ? { domain: activeDomainId } : {}) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -202,7 +206,7 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
||||
} else if (isNew && !createMutation.isPending) {
|
||||
// No note exists for this day yet — create it so the energy is recorded
|
||||
// even before any content is typed.
|
||||
createMutation.mutate({ date: dateStr, content: content, mood: mood, energy: value });
|
||||
createMutation.mutate({ date: dateStr, content: content, mood: mood, energy: value, ...(activeDomainId ? { domain: activeDomainId } : {}) });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||
import { useRealtime } from "@/hooks/use-realtime";
|
||||
import { useOpenCreateDialog } from "@/hooks/use-open-create-dialog";
|
||||
import { Plus, Flame, Trash2, Check, Pencil } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -23,6 +24,7 @@ import type { Habit, HabitCompletion, PaginatedResponse } from "@/lib/types";
|
||||
|
||||
function HabitForm({ habit, onClose }: { habit?: Habit; onClose: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const activeDomainId = useApiDomain();
|
||||
const [name, setName] = useState(habit?.name || "");
|
||||
const [description, setDescription] = useState(habit?.description || "");
|
||||
const [frequency, setFrequency] = useState(habit?.frequency || "daily");
|
||||
@@ -44,7 +46,7 @@ function HabitForm({ habit, onClose }: { habit?: Habit; onClose: () => void }) {
|
||||
if (!name.trim()) return;
|
||||
const data = { name: name.trim(), description: description || null, frequency, difficulty, goalPerPeriod };
|
||||
if (habit) updateMutation.mutate(data);
|
||||
else createMutation.mutate(data);
|
||||
else createMutation.mutate({ ...data, ...(activeDomainId ? { domain: activeDomainId } : {}) });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -124,14 +126,35 @@ function HabitsPage() {
|
||||
|
||||
useRealtime({ enabled: true });
|
||||
|
||||
useOpenCreateDialog("habit", () => setCreateOpen(true));
|
||||
|
||||
const activeDomainId = useApiDomain();
|
||||
|
||||
const habitQueryUrl = () => "/habits?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "");
|
||||
|
||||
const { data: habitsData, isLoading, isError, refetch } = useApiQuery<PaginatedResponse<Habit>>(
|
||||
["habits", activeDomainId],
|
||||
"/habits?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "")
|
||||
habitQueryUrl()
|
||||
);
|
||||
|
||||
const habits = habitsData?.items || [];
|
||||
const hasMoreHabits = habits.length < (habitsData?.totalItems || 0);
|
||||
const [loadingMoreHabits, setLoadingMoreHabits] = useState(false);
|
||||
|
||||
const loadMoreHabits = async () => {
|
||||
if (!hasMoreHabits || loadingMoreHabits) return;
|
||||
setLoadingMoreHabits(true);
|
||||
try {
|
||||
const next = await api.get<PaginatedResponse<Habit>>(habitQueryUrl() + "&offset=" + habits.length);
|
||||
queryClient.setQueryData<PaginatedResponse<Habit>>(["habits", activeDomainId], (old) => {
|
||||
if (!old) return old;
|
||||
const seen = new Set(old.items.map((h) => h.id));
|
||||
return { ...old, items: [...old.items, ...next.items.filter((h) => !seen.has(h.id))] };
|
||||
});
|
||||
} finally {
|
||||
setLoadingMoreHabits(false);
|
||||
}
|
||||
};
|
||||
|
||||
const completeMutation = useMutation({
|
||||
mutationFn: (id: string) => api.post("/habits/" + id + "/complete", {}),
|
||||
@@ -203,6 +226,11 @@ function HabitsPage() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={(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();
|
||||
})}
|
||||
onClick={(e) => { e.stopPropagation(); completeMutation.mutate(habit.id); }}
|
||||
aria-label={"Mark " + habit.name + " complete"}
|
||||
>
|
||||
@@ -225,7 +253,15 @@ function HabitsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EntityDetailPanel open={panelOpen} onOpenChange={setPanelOpen} title={selectedHabit?.name || "Habit Details"}>
|
||||
{hasMoreHabits && (
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button variant="outline" size="sm" onClick={loadMoreHabits} disabled={loadingMoreHabits}>
|
||||
{loadingMoreHabits ? "Loading..." : "Load more habits"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EntityDetailPanel open={panelOpen} onOpenChange={(o) => { setPanelOpen(o); if (!o) setDetailTab("overview"); }} title={selectedHabit?.name || "Habit Details"}>
|
||||
{selectedHabit && (
|
||||
<div className="space-y-4">
|
||||
<Tabs value={detailTab} onValueChange={setDetailTab}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Route as appRoute } from "../_app";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||
import { useRealtime } from "@/hooks/use-realtime";
|
||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||
import { Plus, Settings2, Trash2, ListTodo, Flame, FileText, FolderKanban, Calendar, Zap, TrendingUp, Target } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -31,7 +32,9 @@ const WIDGET_TYPES = [
|
||||
] as const;
|
||||
|
||||
function TasksDueWidget() {
|
||||
const { data } = useApiQuery<PaginatedResponse<Task>>(["tasks-due"], "/tasks?limit=10&status=todo,in_progress&sort=due_date");
|
||||
const activeDomainId = useApiDomain();
|
||||
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
||||
const { data } = useApiQuery<PaginatedResponse<Task>>(["tasks-due", activeDomainId], "/tasks?limit=10&status=todo,in_progress&sort=due_date" + domainSuffix);
|
||||
const tasks = data?.items || [];
|
||||
const today = tasks.filter((t) => t.dueDate && isToday(parseISO(t.dueDate)));
|
||||
const overdue = tasks.filter((t) => t.dueDate && isPast(parseISO(t.dueDate)) && t.status !== "done");
|
||||
@@ -70,7 +73,9 @@ function TasksDueWidget() {
|
||||
}
|
||||
|
||||
function HabitsTodayWidget() {
|
||||
const { data } = useApiQuery<PaginatedResponse<Habit>>(["habits-today"], "/habits?limit=20");
|
||||
const activeDomainId = useApiDomain();
|
||||
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
||||
const { data } = useApiQuery<PaginatedResponse<Habit>>(["habits-today", activeDomainId], "/habits?limit=20" + domainSuffix);
|
||||
const habits = data?.items || [];
|
||||
const queryClient = useQueryClient();
|
||||
const completeMutation = useMutation({
|
||||
@@ -87,30 +92,39 @@ function HabitsTodayWidget() {
|
||||
{habits.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No habits yet</p>
|
||||
) : (
|
||||
habits.slice(0, 6).map((h) => (
|
||||
<div key={h.id} className="flex items-center gap-2 py-1">
|
||||
<button
|
||||
onClick={() => completeMutation.mutate(h.id)}
|
||||
className={cn("w-4 h-4 rounded border shrink-0 flex items-center justify-center", h.streakCount > 0 ? "bg-green-500 border-green-500" : "border-muted-foreground/30 hover:border-primary")}
|
||||
aria-label={"Complete " + h.name}
|
||||
>
|
||||
{h.streakCount > 0 && <span className="text-[10px] text-white">\u2713</span>}
|
||||
</button>
|
||||
<span className="text-sm truncate flex-1">{h.name}</span>
|
||||
{h.streakCount > 0 && (
|
||||
<Badge variant="secondary" className="text-[10px] shrink-0">
|
||||
<Flame className="h-2.5 w-2.5 mr-0.5" />{h.streakCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
habits.slice(0, 6).map((h) => {
|
||||
const doneToday = (h.recentCompletions || []).some((c) => {
|
||||
const d = new Date(c.date);
|
||||
const today = new Date();
|
||||
return d.getUTCFullYear() === today.getUTCFullYear() && d.getUTCMonth() === today.getUTCMonth() && d.getUTCDate() === today.getUTCDate();
|
||||
});
|
||||
return (
|
||||
<div key={h.id} className="flex items-center gap-2 py-1">
|
||||
<button
|
||||
onClick={() => completeMutation.mutate(h.id)}
|
||||
className={cn("w-4 h-4 rounded border shrink-0 flex items-center justify-center", doneToday ? "bg-green-500 border-green-500" : "border-muted-foreground/30 hover:border-primary")}
|
||||
aria-label={doneToday ? h.name + " (completed)" : "Complete " + h.name}
|
||||
>
|
||||
{doneToday && <span className="text-[10px] text-white">\u2713</span>}
|
||||
</button>
|
||||
<span className="text-sm truncate flex-1">{h.name}</span>
|
||||
{h.streakCount > 0 && (
|
||||
<Badge variant="secondary" className="text-[10px] shrink-0">
|
||||
<Flame className="h-2.5 w-2.5 mr-0.5" />{h.streakCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentNotesWidget() {
|
||||
const { data } = useApiQuery<PaginatedResponse<Note>>(["recent-notes"], "/notes?limit=5&sort=-updated");
|
||||
const activeDomainId = useApiDomain();
|
||||
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
||||
const { data } = useApiQuery<PaginatedResponse<Note>>(["recent-notes", activeDomainId], "/notes?limit=5&sort=-updated" + domainSuffix);
|
||||
const notes = data?.items || [];
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -129,7 +143,9 @@ function RecentNotesWidget() {
|
||||
}
|
||||
|
||||
function ActiveProjectsWidget() {
|
||||
const { data } = useApiQuery<PaginatedResponse<Project>>(["active-projects"], "/projects?limit=10&status=active");
|
||||
const activeDomainId = useApiDomain();
|
||||
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
||||
const { data } = useApiQuery<PaginatedResponse<Project>>(["active-projects", activeDomainId], "/projects?limit=10&status=active" + domainSuffix);
|
||||
const projects = data?.items || [];
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -151,7 +167,9 @@ function ActiveProjectsWidget() {
|
||||
}
|
||||
|
||||
function UpcomingEventsWidget() {
|
||||
const { data } = useApiQuery<PaginatedResponse<CalendarEvent>>(["upcoming-events"], "/calendar/events?limit=20");
|
||||
const activeDomainId = useApiDomain();
|
||||
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
||||
const { data } = useApiQuery<PaginatedResponse<CalendarEvent>>(["upcoming-events", activeDomainId], "/calendar/events?limit=20" + domainSuffix);
|
||||
const events = data?.items || [];
|
||||
const now = new Date();
|
||||
const weekFromNow = addDays(now, 7);
|
||||
@@ -177,7 +195,9 @@ function UpcomingEventsWidget() {
|
||||
}
|
||||
|
||||
function StreakCounterWidget() {
|
||||
const { data } = useApiQuery<PaginatedResponse<Habit>>(["streaks"], "/habits?limit=50");
|
||||
const activeDomainId = useApiDomain();
|
||||
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
||||
const { data } = useApiQuery<PaginatedResponse<Habit>>(["streaks", activeDomainId], "/habits?limit=50" + domainSuffix);
|
||||
const habits = data?.items || [];
|
||||
const bestStreak = Math.max(...habits.map((h) => h.streakCount || 0), 0);
|
||||
const totalActive = habits.filter((h) => h.streakCount > 0).length;
|
||||
@@ -199,10 +219,11 @@ function StreakCounterWidget() {
|
||||
|
||||
function QuickCaptureWidget() {
|
||||
const queryClient = useQueryClient();
|
||||
const activeDomainId = useApiDomain();
|
||||
const [text, setText] = useState("");
|
||||
const [type, setType] = useState<"task" | "note">("task");
|
||||
const createTask = useMutation({
|
||||
mutationFn: (title: string) => api.post<Task>("/tasks", { title, status: "todo", priority: "medium" }),
|
||||
mutationFn: (title: string) => api.post<Task>("/tasks", { title, status: "todo", priority: "medium", ...(activeDomainId ? { domain: activeDomainId } : {}) }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks-due"] });
|
||||
setText("");
|
||||
@@ -211,7 +232,7 @@ function QuickCaptureWidget() {
|
||||
onError: (err) => toast.error(err.message || "Failed to create task"),
|
||||
});
|
||||
const createNote = useMutation({
|
||||
mutationFn: (title: string) => api.post<Note>("/notes", { title, content: "" }),
|
||||
mutationFn: (title: string) => api.post<Note>("/notes", { title, content: "", ...(activeDomainId ? { domain: activeDomainId } : {}) }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["recent-notes"] });
|
||||
setText("");
|
||||
@@ -244,7 +265,9 @@ function QuickCaptureWidget() {
|
||||
}
|
||||
|
||||
function ProductivityChartWidget() {
|
||||
const { data } = useApiQuery<any>(["productivity-chart"], "/analytics/productivity?range=30");
|
||||
const activeDomainId = useApiDomain();
|
||||
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
||||
const { data } = useApiQuery<any>(["productivity-chart", activeDomainId], "/analytics/productivity?range=30" + domainSuffix);
|
||||
const stats = data;
|
||||
if (!stats) return <p className="text-sm text-muted-foreground">Loading...</p>;
|
||||
return (
|
||||
@@ -269,7 +292,9 @@ function ProductivityChartWidget() {
|
||||
}
|
||||
|
||||
function StatsWidget() {
|
||||
const { data } = useApiQuery<any>(["stats"], "/analytics/productivity?range=30");
|
||||
const activeDomainId = useApiDomain();
|
||||
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
||||
const { data } = useApiQuery<any>(["stats", activeDomainId], "/analytics/productivity?range=30" + domainSuffix);
|
||||
const stats = data;
|
||||
if (!stats) return <p className="text-sm text-muted-foreground">Loading...</p>;
|
||||
return (
|
||||
@@ -379,7 +404,6 @@ function ConfigureWidgetDialog({ widget, open, onOpenChange, onSave }: { widget:
|
||||
<SelectItem value="2">2 columns</SelectItem>
|
||||
<SelectItem value="3">3 columns</SelectItem>
|
||||
<SelectItem value="4">4 columns</SelectItem>
|
||||
<SelectItem value="6">6 columns</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -459,7 +483,13 @@ function DashboardPage() {
|
||||
) : widgets.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground mb-4">Your dashboard is empty. Add some widgets to get started!</p>
|
||||
<Button onClick={() => handleAddWidget("tasks_due")}><Plus className="h-4 w-4 mr-2" />Add Default Widgets</Button>
|
||||
<Button onClick={() => {
|
||||
const defaults = ["tasks_due", "habits_today", "recent_notes", "active_projects", "upcoming_events", "streak_counter", "quick_capture", "productivity_chart"];
|
||||
defaults.forEach((type, i) => {
|
||||
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 } });
|
||||
});
|
||||
}}><Plus className="h-4 w-4 mr-2" />Add Default Widgets</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 auto-rows-[minmax(120px,auto)] sm:grid-cols-2 lg:grid-cols-4">
|
||||
|
||||
@@ -5,13 +5,16 @@ 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 { useOpenCreateDialog } from "@/hooks/use-open-create-dialog";
|
||||
import { Plus, Trash2, Search, Pin, FileText, Link as LinkIcon, History } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Note, PaginatedResponse } from "@/lib/types";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Link from "@tiptap/extension-link";
|
||||
@@ -135,9 +138,10 @@ const NoteTitleInput = memo(function NoteTitleInput({ noteId, initialTitle }: {
|
||||
});
|
||||
|
||||
// Memoized right pane - only re-renders when note changes, not on parent re-renders
|
||||
const NoteEditorPane = memo(function NoteEditorPane({ note, onDelete }: { note: Note; onDelete: (id: string) => 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 [showVersions, setShowVersions] = useState(false);
|
||||
const [versions, setVersions] = useState<{ id: string; createdAt: string; action?: string; changes?: Record<string, unknown> | null }[]>([]);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const updateMutation = useMutation({
|
||||
@@ -157,7 +161,7 @@ const NoteEditorPane = memo(function NoteEditorPane({ note, onDelete }: { note:
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setShowBacklinks(!showBacklinks)} aria-label="Backlinks">
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setShowVersions(!showVersions)} aria-label="Version history">
|
||||
<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">
|
||||
<History className="h-4 w-4" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
@@ -188,8 +192,12 @@ const NoteEditorPane = memo(function NoteEditorPane({ note, onDelete }: { note:
|
||||
<h4 className="text-sm font-semibold mb-2">Linked from</h4>
|
||||
<div className="space-y-1">
|
||||
{note.backlinks.map((bl) => (
|
||||
<div key={bl.noteId} className="text-sm text-muted-foreground hover:text-foreground cursor-pointer">
|
||||
{bl.noteTitle}
|
||||
<div
|
||||
key={bl.id}
|
||||
className="text-sm text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
onClick={() => { const linked: Note = { ...note, id: bl.id, title: bl.title }; onOpenNote(linked); }}
|
||||
>
|
||||
{bl.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -199,7 +207,18 @@ const NoteEditorPane = memo(function NoteEditorPane({ note, onDelete }: { note:
|
||||
{showVersions && (
|
||||
<div className="border-t p-3">
|
||||
<h4 className="text-sm font-semibold mb-2">Version History</h4>
|
||||
<p className="text-xs text-muted-foreground">Version history available via API.</p>
|
||||
{versions.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No versions yet.</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{versions.map((v) => (
|
||||
<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>
|
||||
{v.action && <Badge variant="secondary" className="text-[10px] capitalize">{v.action}</Badge>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -214,17 +233,39 @@ function NotesPage() {
|
||||
|
||||
useRealtime({ enabled: true });
|
||||
|
||||
useOpenCreateDialog("note", () => createMutation.mutate());
|
||||
|
||||
const activeDomainId = useApiDomain();
|
||||
|
||||
const notesQueryUrl = () =>
|
||||
"/notes?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "") + (search ? "&search=" + encodeURIComponent(search) : "");
|
||||
|
||||
const { data: notesData, isLoading } = useApiQuery<PaginatedResponse<Note>>(
|
||||
["notes", activeDomainId, search],
|
||||
"/notes?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "") + (search ? "&search=" + encodeURIComponent(search) : "")
|
||||
notesQueryUrl()
|
||||
);
|
||||
|
||||
const notes = notesData?.items || [];
|
||||
const hasMoreNotes = notes.length < (notesData?.totalItems || 0);
|
||||
const [loadingMoreNotes, setLoadingMoreNotes] = useState(false);
|
||||
|
||||
const loadMoreNotes = async () => {
|
||||
if (!hasMoreNotes || loadingMoreNotes) return;
|
||||
setLoadingMoreNotes(true);
|
||||
try {
|
||||
const next = await api.get<PaginatedResponse<Note>>(notesQueryUrl() + "&offset=" + notes.length);
|
||||
queryClient.setQueryData<PaginatedResponse<Note>>(["notes", activeDomainId, search], (old) => {
|
||||
if (!old) return old;
|
||||
const seen = new Set(old.items.map((n) => n.id));
|
||||
return { ...old, items: [...old.items, ...next.items.filter((n) => !seen.has(n.id))] };
|
||||
});
|
||||
} finally {
|
||||
setLoadingMoreNotes(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => api.post<Note>("/notes", { title: "Untitled", content: "" }),
|
||||
mutationFn: () => api.post<Note>("/notes", { title: "Untitled", content: "", ...(activeDomainId ? { domain: activeDomainId } : {}) }),
|
||||
onSuccess: (note) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["notes"] });
|
||||
selectedNoteRef.current = note;
|
||||
@@ -264,7 +305,7 @@ function NotesPage() {
|
||||
<div className="p-3 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input placeholder="Search notes..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-8" tabIndex={-1} onMouseDown={(e) => e.preventDefault()} />
|
||||
<Input placeholder="Search notes..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-8" aria-label="Search notes" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
@@ -299,13 +340,20 @@ function NotesPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasMoreNotes && (
|
||||
<div className="p-2">
|
||||
<Button variant="outline" size="sm" className="w-full" onClick={loadMoreNotes} disabled={loadingMoreNotes}>
|
||||
{loadingMoreNotes ? "Loading..." : "Load more notes"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Right pane - editor (memoized, won't re-render on parent state changes) */}
|
||||
<div className="flex-1 flex flex-col min-h-64 md:min-h-0">
|
||||
{selectedNote ? (
|
||||
<NoteEditorPane key={selectedNote.id} note={selectedNote} onDelete={handleDeleteNote} />
|
||||
<NoteEditorPane key={selectedNote.id} note={selectedNote} onDelete={handleDeleteNote} onOpenNote={selectNote} />
|
||||
) : (
|
||||
<div className="flex items-center justify-center flex-1 text-muted-foreground">
|
||||
<div className="text-center">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||
import { useRealtime } from "@/hooks/use-realtime";
|
||||
import { useOpenCreateDialog } from "@/hooks/use-open-create-dialog";
|
||||
import { Plus, Pencil, Trash2, FolderKanban, Users, Calendar, ListTodo, GripVertical } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -26,6 +27,7 @@ import type { Project, Section, Task, PaginatedResponse } from "@/lib/types";
|
||||
|
||||
function ProjectForm({ project, onClose }: { project?: Project; onClose: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const activeDomainId = useApiDomain();
|
||||
const [name, setName] = useState(project?.name || "");
|
||||
const [description, setDescription] = useState(project?.description || "");
|
||||
const [status, setStatus] = useState(project?.status || "active");
|
||||
@@ -48,7 +50,7 @@ function ProjectForm({ project, onClose }: { project?: Project; onClose: () => v
|
||||
const data: any = { name: name.trim(), description: description || null, status, color };
|
||||
if (targetDate) data.targetDate = new Date(targetDate).toISOString();
|
||||
if (project) updateMutation.mutate(data);
|
||||
else createMutation.mutate(data);
|
||||
else createMutation.mutate({ ...data, ...(activeDomainId ? { domain: activeDomainId } : {}) });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -103,14 +105,35 @@ function ProjectsPage() {
|
||||
|
||||
useRealtime({ enabled: true });
|
||||
|
||||
useOpenCreateDialog("project", () => setCreateOpen(true));
|
||||
|
||||
const activeDomainId = useApiDomain();
|
||||
|
||||
const projectQueryUrl = () => "/projects?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "");
|
||||
|
||||
const { data: projectsData, isLoading, isError, refetch } = useApiQuery<PaginatedResponse<Project>>(
|
||||
["projects", activeDomainId],
|
||||
"/projects?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "")
|
||||
projectQueryUrl()
|
||||
);
|
||||
|
||||
const projects = projectsData?.items || [];
|
||||
const hasMoreProjects = projects.length < (projectsData?.totalItems || 0);
|
||||
const [loadingMoreProjects, setLoadingMoreProjects] = useState(false);
|
||||
|
||||
const loadMoreProjects = async () => {
|
||||
if (!hasMoreProjects || loadingMoreProjects) return;
|
||||
setLoadingMoreProjects(true);
|
||||
try {
|
||||
const next = await api.get<PaginatedResponse<Project>>(projectQueryUrl() + "&offset=" + projects.length);
|
||||
queryClient.setQueryData<PaginatedResponse<Project>>(["projects", activeDomainId], (old) => {
|
||||
if (!old) return old;
|
||||
const seen = new Set(old.items.map((p) => p.id));
|
||||
return { ...old, items: [...old.items, ...next.items.filter((p) => !seen.has(p.id))] };
|
||||
});
|
||||
} finally {
|
||||
setLoadingMoreProjects(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete("/projects/" + id),
|
||||
@@ -191,6 +214,14 @@ function ProjectsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasMoreProjects && (
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button variant="outline" size="sm" onClick={loadMoreProjects} disabled={loadingMoreProjects}>
|
||||
{loadingMoreProjects ? "Loading..." : "Load more projects"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EntityDetailPanel open={panelOpen} onOpenChange={setPanelOpen} title={selectedProject?.name || "Project Details"}>
|
||||
{selectedProject && (
|
||||
<Tabs value={detailTab} onValueChange={setDetailTab}>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||
import type { SearchResult } from "@/lib/types";
|
||||
|
||||
const SEARCH_TYPES = [
|
||||
@@ -54,9 +55,11 @@ function SearchPage() {
|
||||
if (inputRef.current) inputRef.current.focus();
|
||||
}, []);
|
||||
|
||||
const activeDomainId = useApiDomain();
|
||||
|
||||
const { data: searchData, isLoading } = useApiQuery<{ results: SearchResult[]; totalCount: number }>(
|
||||
["search", debouncedQuery, ...Array.from(selectedTypes)],
|
||||
"/search?q=" + encodeURIComponent(debouncedQuery) + "&types=" + Array.from(selectedTypes).join(",") + "&limit=50"
|
||||
["search", activeDomainId, debouncedQuery, ...Array.from(selectedTypes)],
|
||||
"/search?q=" + encodeURIComponent(debouncedQuery) + "&types=" + Array.from(selectedTypes).join(",") + "&limit=50" + (activeDomainId ? "&domain=" + activeDomainId : "")
|
||||
);
|
||||
|
||||
const results = searchData?.results || [];
|
||||
@@ -166,7 +169,7 @@ function SearchPage() {
|
||||
<div
|
||||
key={result.id + result.type}
|
||||
className="flex items-center justify-between p-3 rounded-lg hover:bg-accent cursor-pointer transition-colors"
|
||||
onClick={() => navigate({ to: result.link as any })}
|
||||
onClick={() => navigate({ to: result.type === "domain" ? "/settings" : (result.link as any) })}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-sm truncate">{result.title}</p>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useThemeStore, ACCENT_PALETTE, type ThemeMode, type AccentColor } from "@/lib/stores/use-theme-store";
|
||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||
import type { Domain, CustomField, Webhook as WebhookType, ErrorLog, Agent, PaginatedResponse } from "@/lib/types";
|
||||
|
||||
const SETTINGS_TABS = [
|
||||
@@ -40,15 +41,19 @@ const ACCENT_COLORS = Object.entries(ACCENT_PALETTE).map(([key, val]) => ({
|
||||
|
||||
const SHORTCUTS_MAP: Record<string, string> = {
|
||||
"Cmd+K": "Command palette",
|
||||
"Cmd+N": "New task",
|
||||
"g+t": "Go to Tasks",
|
||||
"g+h": "Go to Habits",
|
||||
"g+p": "Go to Projects",
|
||||
"g+n": "Go to Notes",
|
||||
"g+c": "Go to Calendar",
|
||||
"g+g": "Go to Graph",
|
||||
"g+d": "Go to Dashboard",
|
||||
"g+s": "Go to Settings",
|
||||
"g+a": "Go to Analytics",
|
||||
"n": "New task / note (context dependent)",
|
||||
"n+t": "New task",
|
||||
"n+h": "New habit",
|
||||
"n+p": "New project",
|
||||
"n+n": "New note",
|
||||
"?": "Show keyboard shortcuts help",
|
||||
};
|
||||
|
||||
@@ -204,7 +209,7 @@ function DomainsTab() {
|
||||
<Button variant="ghost" size="icon" className="text-destructive"><Trash2 className="h-4 w-4" /></Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader><AlertDialogTitle>Delete Domain</AlertDialogTitle><AlertDialogDescription>Are you sure? This cannot be undone.</AlertDialogDescription></AlertDialogHeader>
|
||||
<AlertDialogHeader><AlertDialogTitle>Delete Domain</AlertDialogTitle><AlertDialogDescription>Are you sure? This permanently deletes this workspace and <span className="font-semibold">all</span> of its tasks, habits, projects, notes, calendar events, and settings. This cannot be undone.</AlertDialogDescription></AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => deleteMutation.mutate(d.id)} className="bg-destructive">Delete</AlertDialogAction>
|
||||
@@ -279,8 +284,9 @@ function TagsTab() {
|
||||
|
||||
function CustomFieldsTab() {
|
||||
const queryClient = useQueryClient();
|
||||
const activeDomainId = useApiDomain();
|
||||
const [entityFilter, setEntityFilter] = useState("");
|
||||
const { data } = useApiQuery<PaginatedResponse<CustomField>>(["custom-fields", entityFilter], "/custom-fields" + (entityFilter ? "?entity=" + entityFilter : ""));
|
||||
const { data } = useApiQuery<PaginatedResponse<CustomField>>(["custom-fields", entityFilter, activeDomainId], "/custom-fields" + (activeDomainId ? "?domain=" + activeDomainId : "") + (entityFilter && entityFilter !== "all" ? "&entity=" + entityFilter : ""));
|
||||
const fields = data?.items || [];
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editField, setEditField] = useState<CustomField | null>(null);
|
||||
@@ -303,7 +309,7 @@ function CustomFieldsTab() {
|
||||
const data: any = { name: form.name, type: form.type, entityType: form.entityType, required: form.required };
|
||||
if (form.type === "select" || form.type === "multi_select") data.options = form.options.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
if (editField) updateMutation.mutate({ id: editField.id, data });
|
||||
else createMutation.mutate(data);
|
||||
else createMutation.mutate({ ...data, ...(activeDomainId ? { domain: activeDomainId } : {}) });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -314,7 +320,7 @@ function CustomFieldsTab() {
|
||||
<Select value={entityFilter} onValueChange={setEntityFilter}>
|
||||
<SelectTrigger className="w-36"><SelectValue placeholder="All entities" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value=" ">All entities</SelectItem>
|
||||
<SelectItem value="all">All entities</SelectItem>
|
||||
<SelectItem value="tasks">Tasks</SelectItem>
|
||||
<SelectItem value="habits">Habits</SelectItem>
|
||||
<SelectItem value="projects">Projects</SelectItem>
|
||||
@@ -429,7 +435,8 @@ function ShortcutsTab() {
|
||||
|
||||
function AgentsTab() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data } = useApiQuery<PaginatedResponse<Agent>>(["agents"], "/agents");
|
||||
const activeDomainId = useApiDomain();
|
||||
const { data } = useApiQuery<PaginatedResponse<Agent>>(["agents", activeDomainId], "/agents" + (activeDomainId ? "?domain=" + activeDomainId : ""));
|
||||
const agents = data?.items || [];
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [form, setForm] = useState({ name: "", description: "", permissionTier: "read_only" });
|
||||
@@ -509,7 +516,7 @@ function AgentsTab() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select></div>
|
||||
<Button onClick={() => createMutation.mutate(form)} disabled={!form.name.trim() || createMutation.isPending}>Create</Button>
|
||||
<Button onClick={() => createMutation.mutate({ ...form, ...(activeDomainId ? { domain: activeDomainId } : {}) })} disabled={!form.name.trim() || createMutation.isPending}>Create</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -592,7 +599,8 @@ function AgentsTab() {
|
||||
|
||||
function WebhooksTab() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data } = useApiQuery<PaginatedResponse<WebhookType>>(["webhooks"], "/webhooks");
|
||||
const activeDomainId = useApiDomain();
|
||||
const { data } = useApiQuery<PaginatedResponse<WebhookType>>(["webhooks", activeDomainId], "/webhooks" + (activeDomainId ? "?domain=" + activeDomainId : ""));
|
||||
const webhooks = data?.items || [];
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [form, setForm] = useState({ name: "", url: "", events: "task.created,note.created" });
|
||||
@@ -607,6 +615,8 @@ function WebhooksTab() {
|
||||
});
|
||||
const testMutation = useMutation({
|
||||
mutationFn: (id: string) => api.post("/webhooks/" + id + "/test", {}),
|
||||
onSuccess: () => toast.success("Test webhook queued"),
|
||||
onError: (err) => toast.error(err.message || "Failed to queue test webhook"),
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -621,7 +631,7 @@ function WebhooksTab() {
|
||||
<div><Label>Name</Label><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></div>
|
||||
<div><Label>URL</Label><Input value={form.url} onChange={(e) => setForm({ ...form, url: e.target.value })} placeholder="https://example.com/webhook" /></div>
|
||||
<div><Label>Events (comma-separated)</Label><Input value={form.events} onChange={(e) => setForm({ ...form, events: e.target.value })} /></div>
|
||||
<Button onClick={() => createMutation.mutate({ name: form.name, url: form.url, events: form.events.split(",").map((s) => s.trim()) })} disabled={!form.name.trim() || !form.url.trim() || createMutation.isPending}>Create</Button>
|
||||
<Button onClick={() => createMutation.mutate({ name: form.name, url: form.url, events: form.events.split(",").map((s) => s.trim()), ...(activeDomainId ? { domain: activeDomainId } : {}) })} disabled={!form.name.trim() || !form.url.trim() || createMutation.isPending}>Create</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -699,6 +709,7 @@ function downloadBlob(blob: Blob, filename: string) {
|
||||
|
||||
function ImportExportTab() {
|
||||
const queryClient = useQueryClient();
|
||||
const activeDomainId = useApiDomain();
|
||||
const [importData, setImportData] = useState("");
|
||||
const [importResult, setImportResult] = useState<any>(null);
|
||||
const [exportFormat, setExportFormat] = useState("json");
|
||||
@@ -749,7 +760,7 @@ function ImportExportTab() {
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const data = await api.post<any>("/export", { collections: exportCollections });
|
||||
const data = await api.post<any>("/export", { collections: exportCollections, ...(activeDomainId ? { domain: activeDomainId } : {}) });
|
||||
|
||||
if (exportFormat === "csv") {
|
||||
// One CSV file per selected collection; empty collections are skipped.
|
||||
@@ -837,7 +848,7 @@ function ImportExportTab() {
|
||||
|
||||
function ErrorLogTab() {
|
||||
const [level, setLevel] = useState("");
|
||||
const { data } = useApiQuery<PaginatedResponse<ErrorLog>>(["error-log", level], "/error-log" + (level ? "?level=" + level : ""));
|
||||
const { data } = useApiQuery<PaginatedResponse<ErrorLog>>(["error-log", level], "/error-log" + (level && level !== "all" ? "?level=" + level : ""));
|
||||
const errors = data?.items || [];
|
||||
const queryClient = useQueryClient();
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
@@ -855,7 +866,7 @@ function ErrorLogTab() {
|
||||
<Select value={level} onValueChange={setLevel}>
|
||||
<SelectTrigger className="w-32"><SelectValue placeholder="All levels" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value=" ">All levels</SelectItem>
|
||||
<SelectItem value="all">All levels</SelectItem>
|
||||
<SelectItem value="error">Error</SelectItem>
|
||||
<SelectItem value="warn">Warning</SelectItem>
|
||||
<SelectItem value="info">Info</SelectItem>
|
||||
@@ -882,8 +893,9 @@ function ErrorLogTab() {
|
||||
{expanded === e.id && (
|
||||
<div className="px-3 pb-3 space-y-2">
|
||||
<Separator />
|
||||
{e.stack && <pre className="text-xs text-muted-foreground bg-muted/50 p-2 rounded overflow-auto max-h-40">{e.stack}</pre>}
|
||||
{e.context && <pre className="text-xs text-muted-foreground bg-muted/50 p-2 rounded overflow-auto max-h-40">{JSON.stringify(e.context, null, 2)}</pre>}
|
||||
{e.source && <pre className="text-xs text-muted-foreground bg-muted/50 p-2 rounded overflow-auto max-h-40">{e.source}</pre>}
|
||||
{e.stackTrace && <pre className="text-xs text-muted-foreground bg-muted/50 p-2 rounded overflow-auto max-h-40">{e.stackTrace}</pre>}
|
||||
{e.metadata && <pre className="text-xs text-muted-foreground bg-muted/50 p-2 rounded overflow-auto max-h-40">{JSON.stringify(e.metadata, null, 2)}</pre>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||
import { useRealtime } from "@/hooks/use-realtime";
|
||||
import { useOpenCreateDialog } from "@/hooks/use-open-create-dialog";
|
||||
import { DndContext, DragOverlay, closestCorners, KeyboardSensor, PointerSensor, useSensor, useSensors, useDroppable, type DragEndEvent, type DragStartEvent } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
@@ -100,6 +101,7 @@ function ColumnDroppable({ id, className, children }: { id: string; className?:
|
||||
|
||||
function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const activeDomainId = useApiDomain();
|
||||
const [title, setTitle] = useState(task?.title || "");
|
||||
const [description, setDescription] = useState(task?.description || "");
|
||||
const [status, setStatus] = useState(task?.status || "todo");
|
||||
@@ -133,7 +135,7 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
|
||||
if (task) {
|
||||
updateMutation.mutate(data);
|
||||
} else {
|
||||
createMutation.mutate(data);
|
||||
createMutation.mutate({ ...data, ...(activeDomainId ? { domain: activeDomainId } : {}) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -201,14 +203,43 @@ function TasksPage() {
|
||||
|
||||
useRealtime({ enabled: true });
|
||||
|
||||
useOpenCreateDialog("task", () => setCreateOpen(true));
|
||||
|
||||
const activeDomainId = useApiDomain();
|
||||
|
||||
const taskQueryParams = () =>
|
||||
new URLSearchParams({
|
||||
limit: "200",
|
||||
...(activeDomainId ? { domain: activeDomainId } : {}),
|
||||
...(search ? { search } : {}),
|
||||
...(statusFilter && statusFilter !== "all" ? { status: statusFilter } : {}),
|
||||
}).toString();
|
||||
|
||||
const { data: tasksData, isLoading, isError, refetch } = useApiQuery<PaginatedResponse<Task>>(
|
||||
["tasks", activeDomainId, search, statusFilter],
|
||||
"/tasks?" + new URLSearchParams({ limit: "200", ...(activeDomainId ? { domain: activeDomainId } : {}), ...(search ? { search } : {}), ...(statusFilter ? { status: statusFilter } : {}) }).toString()
|
||||
"/tasks?" + taskQueryParams()
|
||||
);
|
||||
|
||||
const tasks = tasksData?.items || [];
|
||||
const hasMoreTasks = tasks.length < (tasksData?.totalItems || 0);
|
||||
const [loadingMoreTasks, setLoadingMoreTasks] = useState(false);
|
||||
|
||||
const loadMoreTasks = async () => {
|
||||
if (!hasMoreTasks || loadingMoreTasks) return;
|
||||
setLoadingMoreTasks(true);
|
||||
try {
|
||||
const next = await api.get<PaginatedResponse<Task>>(
|
||||
"/tasks?" + new URLSearchParams({ ...Object.fromEntries(new URLSearchParams(taskQueryParams())), offset: String(tasks.length) }).toString()
|
||||
);
|
||||
queryClient.setQueryData<PaginatedResponse<Task>>(["tasks", activeDomainId, search, statusFilter], (old) => {
|
||||
if (!old) return old;
|
||||
const seen = new Set(old.items.map((t) => t.id));
|
||||
return { ...old, items: [...old.items, ...next.items.filter((t) => !seen.has(t.id))] };
|
||||
});
|
||||
} finally {
|
||||
setLoadingMoreTasks(false);
|
||||
}
|
||||
};
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: string }) =>
|
||||
@@ -375,7 +406,7 @@ function TasksPage() {
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-36"><SelectValue placeholder="All statuses" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value=" ">All statuses</SelectItem>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
{STATUS_COLUMNS.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>{c.label}</SelectItem>
|
||||
))}
|
||||
@@ -466,6 +497,14 @@ function TasksPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasMoreTasks && (
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button variant="outline" size="sm" onClick={loadMoreTasks} disabled={loadingMoreTasks}>
|
||||
{loadingMoreTasks ? "Loading..." : "Load more tasks"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EntityDetailPanel open={panelOpen} onOpenChange={setPanelOpen} title={selectedTask?.title || "Task Details"}>
|
||||
{selectedTask && (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Loader2, Sparkles } from "lucide-react";
|
||||
import { useAuthStore } from "@/lib/stores/use-auth-store";
|
||||
|
||||
function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -30,6 +31,8 @@ function LoginPage() {
|
||||
setError(data.error?.message || data.message || "Login failed");
|
||||
return;
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (data?.user) useAuthStore.getState().setUser(data.user);
|
||||
navigate({ to: "/" });
|
||||
} catch {
|
||||
setError("Network error");
|
||||
|
||||
Reference in New Issue
Block a user