feat: add server error logging and tighten workspace isolation
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { logger } from "hono/logger";
|
||||
import { db, errorLogs } from "@project-e/db";
|
||||
import { authMiddleware } from "./middleware/auth";
|
||||
import { authRoutes } from "./routes/auth";
|
||||
import { mcpRoutes } from "./routes/mcp";
|
||||
@@ -63,6 +64,25 @@ app.route("/api", importExportRoutes);
|
||||
app.route("/api", realtimeRoutes);
|
||||
app.route("/api/mcp", mcpRoutes);
|
||||
|
||||
// Persist uncaught server errors so the Settings → Error Log tab shows real
|
||||
// diagnostics instead of always being empty. Errors already caught by route
|
||||
// handlers (which return 500 JSON themselves) still log to the console.
|
||||
app.onError((err, c) => {
|
||||
console.error("[api] uncaught error:", err);
|
||||
try {
|
||||
void db.insert(errorLogs).values({
|
||||
level: "error",
|
||||
source: c.req.path,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
stackTrace: err instanceof Error ? err.stack ?? null : null,
|
||||
metadata: { method: c.req.method },
|
||||
});
|
||||
} catch {
|
||||
// Logging must never break the error response.
|
||||
}
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Internal server error" } }, 500);
|
||||
});
|
||||
|
||||
const port = parseInt(process.env.PORT || "3001", 10);
|
||||
|
||||
export default {
|
||||
|
||||
@@ -168,6 +168,17 @@ export class AuthError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* True when a value looks like a UUID. Route params that are entity ids should
|
||||
* be validated with this before hitting the DB so a malformed id returns 404
|
||||
* instead of a Postgres "invalid input syntax for type uuid" 500.
|
||||
*/
|
||||
export function isUuid(value: string | undefined | null): boolean {
|
||||
return typeof value === "string" && UUID_REGEX.test(value);
|
||||
}
|
||||
|
||||
export function createErrorResponse(code: string, message: string, status: number = 400, details?: unknown) {
|
||||
return {
|
||||
error: {
|
||||
|
||||
@@ -7,6 +7,25 @@ import { z } from "zod";
|
||||
|
||||
export const agentRoutes = new Hono();
|
||||
|
||||
// Record an entry in the agent_activity feed. The Agent Activity page reads
|
||||
// this table, so every lifecycle event (create/update) is captured here in
|
||||
// addition to the global activity_feed. Delete events can't be persisted
|
||||
// because agent_activity cascades on the owning agent's removal.
|
||||
async function recordAgentActivity(agent: { id: string; name: string }, action: string, entityType = "agent", details: Record<string, unknown> = {}) {
|
||||
try {
|
||||
await db.insert(agentActivity).values({
|
||||
agentId: agent.id,
|
||||
action,
|
||||
entityType,
|
||||
entityId: agent.id,
|
||||
details,
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[agents] recordAgentActivity error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const createAgentSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
description: z.string().optional().nullable(),
|
||||
@@ -123,6 +142,8 @@ agentRoutes.post("/", async (c) => {
|
||||
changes: { name: agent.name }, workspaceId: data.domain,
|
||||
});
|
||||
|
||||
await recordAgentActivity(agent, "created", "agent", { name: agent.name, permissionTier: agent.permissionTier });
|
||||
|
||||
return c.json(agent, 201);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
@@ -219,6 +240,8 @@ agentRoutes.patch("/:id", async (c) => {
|
||||
changes: { name: updated.name }, workspaceId: existing.domainId,
|
||||
});
|
||||
|
||||
await recordAgentActivity(updated, "updated", "agent", { name: updated.name, permissionTier: updated.permissionTier });
|
||||
|
||||
return c.json(updated);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, tasks, habits, habitCompletions, projects } from "@project-e/db";
|
||||
import { and, eq, gte, inArray, isNull, or } from "drizzle-orm";
|
||||
import { requireAuth, resolveActiveDomain, AuthError } from "../middleware/auth";
|
||||
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
|
||||
|
||||
export const analyticsRoutes = new Hono();
|
||||
|
||||
@@ -17,6 +17,8 @@ analyticsRoutes.get("/productivity", async (c) => {
|
||||
domainId = active.id;
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, domainId);
|
||||
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - range);
|
||||
|
||||
@@ -58,6 +60,8 @@ analyticsRoutes.get("/habits", async (c) => {
|
||||
domainId = active.id;
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, domainId);
|
||||
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - range);
|
||||
|
||||
@@ -113,6 +117,8 @@ analyticsRoutes.get("/projects", async (c) => {
|
||||
domainId = active.id;
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, domainId);
|
||||
|
||||
const allProjects = await db.select()
|
||||
.from(projects)
|
||||
.where(and(eq(projects.domainId, domainId), isNull(projects.deletedAt)));
|
||||
@@ -178,6 +184,8 @@ analyticsRoutes.get("/daily", async (c) => {
|
||||
domainId = active.id;
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, domainId);
|
||||
|
||||
// Buckets cover the last `range` days ending today, matching the frontend's expectation.
|
||||
const firstDay = new Date();
|
||||
firstDay.setDate(firstDay.getDate() - (range - 1));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, calendarEvents } from "@project-e/db";
|
||||
import { and, asc, desc, eq, gte, lte, isNull } from "drizzle-orm";
|
||||
import { requireAuth, createErrorResponse, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
|
||||
import { requireAuth, createErrorResponse, resolveActiveDomain, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth";
|
||||
import { recordActivity } from "../middleware/activity";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -52,10 +52,14 @@ calendarRoutes.get("/events", async (c) => {
|
||||
if (from) conditions.push(gte(calendarEvents.startTime, new Date(from)));
|
||||
if (to) conditions.push(lte(calendarEvents.startTime, new Date(to)));
|
||||
|
||||
const items = await db.select()
|
||||
const limitParam = parseInt(url.searchParams.get("limit") || "", 10);
|
||||
const limit = Number.isFinite(limitParam) ? Math.max(1, Math.min(limitParam, 500)) : null;
|
||||
|
||||
const query = db.select()
|
||||
.from(calendarEvents)
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(calendarEvents.startTime));
|
||||
const items = limit !== null ? await query.limit(limit) : await query;
|
||||
|
||||
return c.json({ items, totalItems: items.length });
|
||||
} catch (error) {
|
||||
@@ -120,6 +124,9 @@ calendarRoutes.patch("/events/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const body = await c.req.json();
|
||||
const data = updateEventSchema.parse(body);
|
||||
|
||||
@@ -179,6 +186,9 @@ calendarRoutes.delete("/events/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
|
||||
const [existing] = await db.select()
|
||||
.from(calendarEvents)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, canvases, canvasCards, canvasConnections } from "@project-e/db";
|
||||
import { and, asc, desc, eq, sql } from "drizzle-orm";
|
||||
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
|
||||
import { and, asc, desc, eq, notInArray, or, sql } from "drizzle-orm";
|
||||
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth";
|
||||
import { recordActivity } from "../middleware/activity";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -137,6 +137,9 @@ canvasRoutes.get("/:id", async (c) => {
|
||||
try {
|
||||
await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const [canvas] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1);
|
||||
if (!canvas) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404);
|
||||
|
||||
@@ -160,6 +163,9 @@ canvasRoutes.patch("/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const body = await c.req.json();
|
||||
const data = updateCanvasSchema.parse(body);
|
||||
|
||||
@@ -199,6 +205,9 @@ canvasRoutes.delete("/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const [existing] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1);
|
||||
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404);
|
||||
|
||||
@@ -224,6 +233,9 @@ canvasRoutes.post("/:id/cards", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const body = await c.req.json();
|
||||
const data = createCardSchema.parse(body);
|
||||
|
||||
@@ -272,6 +284,9 @@ canvasRoutes.put("/:id/cards", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const body = await c.req.json();
|
||||
const data = bulkSaveCardsSchema.parse(body);
|
||||
|
||||
@@ -282,8 +297,15 @@ canvasRoutes.put("/:id/cards", async (c) => {
|
||||
|
||||
const cards = await db.transaction(async (tx) => {
|
||||
await tx.delete(canvasCards).where(eq(canvasCards.canvasId, id));
|
||||
if (data.cards.length === 0) return [];
|
||||
return tx.insert(canvasCards).values(
|
||||
if (data.cards.length === 0) {
|
||||
// No cards left — drop every connection on this canvas.
|
||||
await tx.delete(canvasConnections).where(or(
|
||||
eq(canvasConnections.sourceCardId, id),
|
||||
eq(canvasConnections.targetCardId, id),
|
||||
));
|
||||
return [];
|
||||
}
|
||||
const inserted = await tx.insert(canvasCards).values(
|
||||
data.cards.map((card, i) => ({
|
||||
...(card.id ? { id: card.id } : {}),
|
||||
canvasId: id,
|
||||
@@ -299,6 +321,15 @@ canvasRoutes.put("/:id/cards", async (c) => {
|
||||
zIndex: card.zIndex ?? i,
|
||||
}))
|
||||
).returning();
|
||||
// Connections to cards that no longer exist must not linger. Cards that
|
||||
// were re-inserted with their original id keep their connections; any
|
||||
// connection whose endpoint is missing is dropped.
|
||||
const keptIds = inserted.map((c) => c.id);
|
||||
await tx.delete(canvasConnections).where(or(
|
||||
notInArray(canvasConnections.sourceCardId, keptIds),
|
||||
notInArray(canvasConnections.targetCardId, keptIds),
|
||||
));
|
||||
return inserted;
|
||||
});
|
||||
|
||||
await recordActivity({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, customFields } from "@project-e/db";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
|
||||
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth";
|
||||
import { recordActivity } from "../middleware/activity";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -99,6 +99,9 @@ customFieldRoutes.patch("/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const body = await c.req.json();
|
||||
const data = updateFieldSchema.parse(body);
|
||||
|
||||
@@ -137,6 +140,9 @@ customFieldRoutes.delete("/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const [existing] = await db.select().from(customFields).where(eq(customFields.id, id)).limit(1);
|
||||
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Custom field not found" } }, 404);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, dailyNotes } from "@project-e/db";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
|
||||
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth";
|
||||
import { recordActivity } from "../middleware/activity";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -101,6 +101,9 @@ dailyNoteRoutes.patch("/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const body = await c.req.json();
|
||||
const data = updateDailyNoteSchema.parse(body);
|
||||
|
||||
@@ -137,6 +140,9 @@ dailyNoteRoutes.delete("/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
|
||||
const [existing] = await db.select().from(dailyNotes).where(eq(dailyNotes.id, id)).limit(1);
|
||||
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Daily note not found" } }, 404);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, domains as domainsTable } from "@project-e/db";
|
||||
import { and, asc, desc, eq, ilike, or, sql } from "drizzle-orm";
|
||||
import { requireAuth, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth";
|
||||
import { requireAuth, createErrorResponse, resolveActiveDomain, AuthError, isUuid } from "../middleware/auth";
|
||||
|
||||
export const domainRoutes = new Hono();
|
||||
|
||||
@@ -131,6 +131,9 @@ domainRoutes.get("/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
|
||||
const [domain] = await db
|
||||
.select()
|
||||
@@ -157,11 +160,23 @@ domainRoutes.patch("/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const body = await c.req.json();
|
||||
|
||||
// Whitelist editable fields so a client can never overwrite ownership,
|
||||
// the slug, or sort order via an open-ended body spread.
|
||||
const updateValues: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (body.name !== undefined && typeof body.name === "string") updateValues.name = body.name;
|
||||
if (body.color !== undefined) updateValues.color = body.color ?? null;
|
||||
if (body.icon !== undefined) updateValues.icon = body.icon ?? null;
|
||||
if (body.parentId !== undefined) updateValues.parentId = body.parentId ?? null;
|
||||
if (body.sortOrder !== undefined && typeof body.sortOrder === "number") updateValues.sortOrder = body.sortOrder;
|
||||
|
||||
const [domain] = await db
|
||||
.update(domainsTable)
|
||||
.set({ ...body, updatedAt: new Date() })
|
||||
.set(updateValues)
|
||||
.where(and(eq(domainsTable.id, id), eq(domainsTable.ownerId, user.id)))
|
||||
.returning();
|
||||
|
||||
@@ -184,6 +199,9 @@ domainRoutes.delete("/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
|
||||
const [domain] = await db
|
||||
.delete(domainsTable)
|
||||
|
||||
@@ -218,9 +218,15 @@ graphRoutes.delete("/edges/:id", async (c) => {
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
// Try task_dependencies
|
||||
await db.delete(taskDependencies)
|
||||
.where(and(eq(taskDependencies.taskId, sourceId), eq(taskDependencies.dependsOnTaskId, targetId)));
|
||||
// Try note_entity_links (note → entity edges)
|
||||
const entityResult = await db.delete(noteEntityLinks)
|
||||
.where(and(eq(noteEntityLinks.noteId, sourceId), eq(noteEntityLinks.entityId, targetId)))
|
||||
.returning();
|
||||
if (entityResult.length === 0) {
|
||||
// Try task_dependencies
|
||||
await db.delete(taskDependencies)
|
||||
.where(and(eq(taskDependencies.taskId, sourceId), eq(taskDependencies.dependsOnTaskId, targetId)));
|
||||
}
|
||||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, habits, habitCompletions, habitTags, tags as tagsTable } from "@project-e/db";
|
||||
import { and, asc, desc, eq, exists, gte, ilike, inArray, isNull, lte, sql } from "drizzle-orm";
|
||||
import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth";
|
||||
import { and, asc, desc, eq, exists, gte, ilike, inArray, isNull, lt, lte, sql } from "drizzle-orm";
|
||||
import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError, isUuid } from "../middleware/auth";
|
||||
import { recordActivity } from "../middleware/activity";
|
||||
import { enqueueWebhooks } from "../middleware/webhook-queue";
|
||||
import { z } from "zod";
|
||||
@@ -47,6 +47,10 @@ const completeHabitSchema = z.object({
|
||||
|
||||
/**
|
||||
* Calculate the current streak for a habit.
|
||||
*
|
||||
* Day boundaries are resolved in UTC everywhere (grouping, streak walk and the
|
||||
* frontend's "completed today" check) so the server and client agree on what
|
||||
* "today" means even for users outside UTC.
|
||||
*/
|
||||
async function calculateStreak(habitId: string, skipDays: number[]): Promise<number> {
|
||||
const completions = await db.select({ date: habitCompletions.date })
|
||||
@@ -62,21 +66,21 @@ async function calculateStreak(habitId: string, skipDays: number[]): Promise<num
|
||||
|
||||
let streak = 0;
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
today.setUTCHours(0, 0, 0, 0);
|
||||
const checkDate = new Date(today);
|
||||
|
||||
for (let i = 0; i < 365; i++) {
|
||||
const dateStr = checkDate.toISOString().split("T")[0];
|
||||
const dayOfWeek = checkDate.getDay();
|
||||
const dayOfWeek = checkDate.getUTCDay();
|
||||
|
||||
if (skipDays.includes(dayOfWeek)) {
|
||||
checkDate.setDate(checkDate.getDate() - 1);
|
||||
checkDate.setUTCDate(checkDate.getUTCDate() - 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (completionDates.has(dateStr)) {
|
||||
streak++;
|
||||
checkDate.setDate(checkDate.getDate() - 1);
|
||||
checkDate.setUTCDate(checkDate.getUTCDate() - 1);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
@@ -192,8 +196,29 @@ habitRoutes.get("/", async (c) => {
|
||||
tags: habitTagMap.get(h.id) || [],
|
||||
}));
|
||||
|
||||
// Fetch recent completions for every habit in the page so the UI can render
|
||||
// "completed today" / mini-grid state without a second request per row.
|
||||
let habitCompletionMap = new Map<string, typeof habitCompletions.$inferSelect[]>();
|
||||
if (items.length > 0) {
|
||||
const habitIds = items.map(h => h.id);
|
||||
const since = new Date(Date.now() - 21 * 24 * 60 * 60 * 1000);
|
||||
const completionRows = await db.select()
|
||||
.from(habitCompletions)
|
||||
.where(and(inArray(habitCompletions.habitId, habitIds), gte(habitCompletions.date, since)))
|
||||
.orderBy(desc(habitCompletions.date));
|
||||
for (const row of completionRows) {
|
||||
if (!habitCompletionMap.has(row.habitId)) habitCompletionMap.set(row.habitId, []);
|
||||
habitCompletionMap.get(row.habitId)!.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
const itemsWithCompletions = itemsWithTags.map(h => ({
|
||||
...h,
|
||||
recentCompletions: habitCompletionMap.get(h.id) || [],
|
||||
}));
|
||||
|
||||
return c.json({
|
||||
items: itemsWithTags,
|
||||
items: itemsWithCompletions,
|
||||
totalItems,
|
||||
totalPages: Math.ceil(totalItems / (limit || perPage)),
|
||||
page,
|
||||
@@ -271,6 +296,9 @@ habitRoutes.get("/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
|
||||
const [habit] = await db.select()
|
||||
.from(habits)
|
||||
@@ -324,6 +352,9 @@ habitRoutes.patch("/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const body = await c.req.json();
|
||||
const data = updateHabitSchema.parse(body);
|
||||
|
||||
@@ -385,6 +416,9 @@ habitRoutes.delete("/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
|
||||
const [existing] = await db.select()
|
||||
.from(habits)
|
||||
@@ -427,6 +461,9 @@ habitRoutes.post("/:id/tags", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const body = await c.req.json();
|
||||
const { tagId } = z.object({ tagId: z.string().uuid("Invalid tag id") }).parse(body);
|
||||
|
||||
@@ -478,6 +515,9 @@ habitRoutes.delete("/:id/tags/:tagId", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const tagId = c.req.param("tagId");
|
||||
|
||||
const [habit] = await db.select({ id: habits.id, domainId: habits.domainId })
|
||||
@@ -517,6 +557,9 @@ habitRoutes.post("/:id/complete", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const body = await c.req.json();
|
||||
const data = completeHabitSchema.parse(body);
|
||||
|
||||
@@ -531,13 +574,36 @@ habitRoutes.post("/:id/complete", async (c) => {
|
||||
|
||||
await requireWorkspaceAccess(c, habit.domainId);
|
||||
|
||||
const [completion] = await db.insert(habitCompletions).values({
|
||||
habitId: id,
|
||||
date: new Date(),
|
||||
value: data.value,
|
||||
mood: data.mood ?? null,
|
||||
notes: data.notes ?? null,
|
||||
}).returning();
|
||||
// Guard against duplicate completions for the same UTC day: the habit list
|
||||
// disables the button once completed today, but double-fires (or a stale
|
||||
// client) must not inflate history/stats. Update the existing row instead.
|
||||
const todayStart = new Date();
|
||||
todayStart.setUTCHours(0, 0, 0, 0);
|
||||
const tomorrowStart = new Date(todayStart.getTime() + 24 * 60 * 60 * 1000);
|
||||
const [existingCompletion] = await db.select()
|
||||
.from(habitCompletions)
|
||||
.where(and(
|
||||
eq(habitCompletions.habitId, id),
|
||||
gte(habitCompletions.date, todayStart),
|
||||
lt(habitCompletions.date, tomorrowStart),
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
let completion: typeof habitCompletions.$inferSelect;
|
||||
if (existingCompletion) {
|
||||
[completion] = await db.update(habitCompletions)
|
||||
.set({ value: data.value, mood: data.mood ?? null, notes: data.notes ?? null })
|
||||
.where(eq(habitCompletions.id, existingCompletion.id))
|
||||
.returning();
|
||||
} else {
|
||||
[completion] = await db.insert(habitCompletions).values({
|
||||
habitId: id,
|
||||
date: new Date(),
|
||||
value: data.value,
|
||||
mood: data.mood ?? null,
|
||||
notes: data.notes ?? null,
|
||||
}).returning();
|
||||
}
|
||||
|
||||
// Recalculate streak
|
||||
const skipDays = habit.skipDays || [];
|
||||
@@ -587,6 +653,9 @@ habitRoutes.get("/:id/completions", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const url = new URL(c.req.url);
|
||||
|
||||
const [habit] = await db.select({ id: habits.id, domainId: habits.domainId })
|
||||
|
||||
@@ -7,6 +7,13 @@ import { z } from "zod";
|
||||
export const importExportRoutes = new Hono();
|
||||
|
||||
const COLLECTIONS = ['tasks', 'habits', 'projects', 'notes', 'tags', 'agents', 'webhooks'] as const;
|
||||
const JUNCTION_COLLECTIONS = ['task_tags', 'habit_tags', 'project_tags', 'note_tags'] as const;
|
||||
const JUNCTION_PARENT: Record<string, string> = {
|
||||
task_tags: 'tasks',
|
||||
habit_tags: 'habits',
|
||||
project_tags: 'projects',
|
||||
note_tags: 'notes',
|
||||
};
|
||||
|
||||
// POST /api/import — Import data from JSON
|
||||
importExportRoutes.post("/import", async (c) => {
|
||||
@@ -31,38 +38,50 @@ importExportRoutes.post("/import", async (c) => {
|
||||
let totalImported = 0;
|
||||
let totalFailed = 0;
|
||||
|
||||
for (const collection of COLLECTIONS) {
|
||||
const items = body[collection];
|
||||
if (!Array.isArray(items) || items.length === 0) continue;
|
||||
|
||||
const runCollection = async (collection: string, items: any[]) => {
|
||||
const result = { collection, imported: 0, failed: 0, errors: [] as string[] };
|
||||
|
||||
for (const item of items) {
|
||||
try {
|
||||
const { id: _id, created: _created, updated: _updated, ...data } = item;
|
||||
// Map to the right table
|
||||
// Preserve the source id so cross-references (projectId, parentId,
|
||||
// sectionId, junction rows) survive the round-trip. Domain is always
|
||||
// forced to the target workspace.
|
||||
const { id, domain, domainId, workspaceId, created: _created, updated: _updated, ...data } = item;
|
||||
switch (collection) {
|
||||
case 'tasks':
|
||||
await db.insert(tasks).values({ ...data, domainId: targetDomain });
|
||||
await db.insert(tasks).values({ ...data, id, domainId: targetDomain });
|
||||
break;
|
||||
case 'habits':
|
||||
await db.insert(habits).values({ ...data, domainId: targetDomain });
|
||||
await db.insert(habits).values({ ...data, id, domainId: targetDomain });
|
||||
break;
|
||||
case 'projects':
|
||||
await db.insert(projects).values({ ...data, domainId: targetDomain });
|
||||
await db.insert(projects).values({ ...data, id, domainId: targetDomain });
|
||||
break;
|
||||
case 'notes':
|
||||
await db.insert(notes).values({ ...data, domainId: targetDomain });
|
||||
await db.insert(notes).values({ ...data, id, domainId: targetDomain });
|
||||
break;
|
||||
case 'tags':
|
||||
await db.insert(tagsTable).values(data);
|
||||
await db.insert(tagsTable).values({ ...data, id });
|
||||
break;
|
||||
case 'agents':
|
||||
await db.insert(agents).values({ ...data, domainId: targetDomain });
|
||||
await db.insert(agents).values({ ...data, id, domainId: targetDomain });
|
||||
break;
|
||||
case 'webhooks':
|
||||
await db.insert(webhooks).values({ ...data, workspaceId: targetDomain });
|
||||
await db.insert(webhooks).values({ ...data, id, workspaceId: targetDomain });
|
||||
break;
|
||||
case 'task_tags':
|
||||
await db.insert(taskTags).values({ taskId: item.taskId, tagId: item.tagId }).onConflictDoNothing();
|
||||
break;
|
||||
case 'habit_tags':
|
||||
await db.insert(habitTags).values({ habitId: item.habitId, tagId: item.tagId }).onConflictDoNothing();
|
||||
break;
|
||||
case 'project_tags':
|
||||
await db.insert(projectTags).values({ projectId: item.projectId, tagId: item.tagId }).onConflictDoNothing();
|
||||
break;
|
||||
case 'note_tags':
|
||||
await db.insert(noteTags).values({ noteId: item.noteId, tagId: item.tagId }).onConflictDoNothing();
|
||||
break;
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
result.imported++;
|
||||
} catch (error) {
|
||||
@@ -71,10 +90,21 @@ importExportRoutes.post("/import", async (c) => {
|
||||
if (result.errors.length < 5) result.errors.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
results.push(result);
|
||||
totalImported += result.imported;
|
||||
totalFailed += result.failed;
|
||||
};
|
||||
|
||||
// Entities first (tags too), then junctions so the FK targets exist.
|
||||
for (const collection of COLLECTIONS) {
|
||||
const items = body[collection];
|
||||
if (!Array.isArray(items) || items.length === 0) continue;
|
||||
await runCollection(collection, items);
|
||||
}
|
||||
for (const collection of JUNCTION_COLLECTIONS) {
|
||||
const items = body[collection];
|
||||
if (!Array.isArray(items) || items.length === 0) continue;
|
||||
await runCollection(collection, items);
|
||||
}
|
||||
|
||||
return c.json({ success: totalFailed === 0, imported: totalImported, failed: totalFailed, results });
|
||||
@@ -163,6 +193,42 @@ importExportRoutes.post("/export", async (c) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Tag assignments ride along with their parent entity collection so an
|
||||
// export → import round-trip preserves all tag links.
|
||||
for (const junction of JUNCTION_COLLECTIONS) {
|
||||
const parent = JUNCTION_PARENT[junction];
|
||||
if (!requestedCollections.includes(parent as typeof COLLECTIONS[number])) continue;
|
||||
try {
|
||||
let items: any[] = [];
|
||||
switch (junction) {
|
||||
case 'task_tags':
|
||||
items = await db.select({ taskId: taskTags.taskId, tagId: taskTags.tagId }).from(taskTags)
|
||||
.innerJoin(tasks, eq(taskTags.taskId, tasks.id))
|
||||
.where(and(eq(tasks.domainId, domainId), isNull(tasks.deletedAt)));
|
||||
break;
|
||||
case 'habit_tags':
|
||||
items = await db.select({ habitId: habitTags.habitId, tagId: habitTags.tagId }).from(habitTags)
|
||||
.innerJoin(habits, eq(habitTags.habitId, habits.id))
|
||||
.where(and(eq(habits.domainId, domainId), isNull(habits.deletedAt)));
|
||||
break;
|
||||
case 'project_tags':
|
||||
items = await db.select({ projectId: projectTags.projectId, tagId: projectTags.tagId }).from(projectTags)
|
||||
.innerJoin(projects, eq(projectTags.projectId, projects.id))
|
||||
.where(and(eq(projects.domainId, domainId), isNull(projects.deletedAt)));
|
||||
break;
|
||||
case 'note_tags':
|
||||
items = await db.select({ noteId: noteTags.noteId, tagId: noteTags.tagId }).from(noteTags)
|
||||
.innerJoin(notes, eq(noteTags.noteId, notes.id))
|
||||
.where(and(eq(notes.domainId, domainId), isNull(notes.deletedAt)));
|
||||
break;
|
||||
}
|
||||
exportData[junction] = items;
|
||||
} catch (error) {
|
||||
console.error("Failed to export collection " + junction + ":", error);
|
||||
exportData[junction] = [];
|
||||
}
|
||||
}
|
||||
|
||||
return c.json(exportData);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
|
||||
@@ -165,6 +165,10 @@ const tools: ToolDefinition[] = [
|
||||
required: ["task_id"],
|
||||
},
|
||||
handler: async (params, auth) => {
|
||||
const [existing] = await db.select().from(tasks).where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt))).limit(1);
|
||||
if (!existing) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Task not found");
|
||||
await verifyDomainAccess(existing.domainId, auth.userId);
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (params.title !== undefined) updateData.title = params.title;
|
||||
if (params.description !== undefined) updateData.description = params.description;
|
||||
@@ -178,8 +182,6 @@ const tools: ToolDefinition[] = [
|
||||
.where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt)))
|
||||
.returning();
|
||||
|
||||
if (!task) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Task not found");
|
||||
|
||||
await recordActivity({
|
||||
actor: auth.userName,
|
||||
action: "updated",
|
||||
@@ -201,13 +203,15 @@ const tools: ToolDefinition[] = [
|
||||
required: ["task_id"],
|
||||
},
|
||||
handler: async (params, auth) => {
|
||||
const [existing] = await db.select().from(tasks).where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt))).limit(1);
|
||||
if (!existing) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Task not found");
|
||||
await verifyDomainAccess(existing.domainId, auth.userId);
|
||||
|
||||
const [task] = await db.update(tasks)
|
||||
.set({ deletedAt: new Date(), updatedAt: new Date() })
|
||||
.where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt)))
|
||||
.returning();
|
||||
|
||||
if (!task) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Task not found");
|
||||
|
||||
await recordActivity({
|
||||
actor: auth.userName,
|
||||
action: "deleted",
|
||||
@@ -228,13 +232,15 @@ const tools: ToolDefinition[] = [
|
||||
required: ["task_id"],
|
||||
},
|
||||
handler: async (params, auth) => {
|
||||
const [existing] = await db.select().from(tasks).where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt))).limit(1);
|
||||
if (!existing) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Task not found");
|
||||
await verifyDomainAccess(existing.domainId, auth.userId);
|
||||
|
||||
const [task] = await db.update(tasks)
|
||||
.set({ status: "done", completedAt: new Date(), updatedAt: new Date() })
|
||||
.where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt)))
|
||||
.returning();
|
||||
|
||||
if (!task) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Task not found");
|
||||
|
||||
await recordActivity({
|
||||
actor: auth.userName,
|
||||
action: "completed",
|
||||
@@ -313,6 +319,7 @@ const tools: ToolDefinition[] = [
|
||||
handler: async (params, auth) => {
|
||||
const [habit] = await db.select().from(habits).where(and(eq(habits.id, params.habit_id as string), isNull(habits.deletedAt))).limit(1);
|
||||
if (!habit) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Habit not found");
|
||||
await verifyDomainAccess(habit.domainId, auth.userId);
|
||||
|
||||
const [completion] = await db.insert(habitCompletions).values({
|
||||
habitId: params.habit_id as string,
|
||||
@@ -444,6 +451,10 @@ const tools: ToolDefinition[] = [
|
||||
required: ["note_id"],
|
||||
},
|
||||
handler: async (params, auth) => {
|
||||
const [existing] = await db.select().from(notes).where(and(eq(notes.id, params.note_id as string), isNull(notes.deletedAt))).limit(1);
|
||||
if (!existing) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Note not found");
|
||||
await verifyDomainAccess(existing.domainId, auth.userId);
|
||||
|
||||
const updateData: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (params.title !== undefined) updateData.title = params.title;
|
||||
if (params.content !== undefined) updateData.content = params.content;
|
||||
@@ -453,8 +464,6 @@ const tools: ToolDefinition[] = [
|
||||
.where(and(eq(notes.id, params.note_id as string), isNull(notes.deletedAt)))
|
||||
.returning();
|
||||
|
||||
if (!note) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Note not found");
|
||||
|
||||
await recordActivity({
|
||||
actor: auth.userName,
|
||||
action: "updated",
|
||||
|
||||
@@ -12,15 +12,18 @@ import { extractLinkTargets } from "./wikilink-parser";
|
||||
|
||||
/**
|
||||
* Resolve a single link target to its entity ID.
|
||||
*
|
||||
* scoped to the source note's workspace so [[Title]] links never resolve to an
|
||||
* entity in a different domain of the same user.
|
||||
*/
|
||||
async function resolveTarget(entityType: string, title: string): Promise<{ entityId: string; entityType: string } | null> {
|
||||
async function resolveTarget(entityType: string, title: string, domainId: string): Promise<{ entityId: string; entityType: string } | null> {
|
||||
const trimmedTitle = title.trim();
|
||||
|
||||
if (!entityType) {
|
||||
const [note] = await db
|
||||
.select({ id: notes.id })
|
||||
.from(notes)
|
||||
.where(and(eq(notes.title, trimmedTitle), isNull(notes.deletedAt)))
|
||||
.where(and(eq(notes.title, trimmedTitle), eq(notes.domainId, domainId), isNull(notes.deletedAt)))
|
||||
.limit(1);
|
||||
if (note) return { entityId: note.id, entityType: "note" };
|
||||
return null;
|
||||
@@ -31,7 +34,7 @@ async function resolveTarget(entityType: string, title: string): Promise<{ entit
|
||||
const [note] = await db
|
||||
.select({ id: notes.id })
|
||||
.from(notes)
|
||||
.where(and(eq(notes.title, trimmedTitle), isNull(notes.deletedAt)))
|
||||
.where(and(eq(notes.title, trimmedTitle), eq(notes.domainId, domainId), isNull(notes.deletedAt)))
|
||||
.limit(1);
|
||||
if (note) return { entityId: note.id, entityType: "note" };
|
||||
return null;
|
||||
@@ -40,7 +43,7 @@ async function resolveTarget(entityType: string, title: string): Promise<{ entit
|
||||
const [task] = await db
|
||||
.select({ id: tasks.id })
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.title, trimmedTitle), isNull(tasks.deletedAt)))
|
||||
.where(and(eq(tasks.title, trimmedTitle), eq(tasks.domainId, domainId), isNull(tasks.deletedAt)))
|
||||
.limit(1);
|
||||
if (task) return { entityId: task.id, entityType: "task" };
|
||||
return null;
|
||||
@@ -49,7 +52,7 @@ async function resolveTarget(entityType: string, title: string): Promise<{ entit
|
||||
const [habit] = await db
|
||||
.select({ id: habits.id })
|
||||
.from(habits)
|
||||
.where(and(eq(habits.name, trimmedTitle), isNull(habits.deletedAt)))
|
||||
.where(and(eq(habits.name, trimmedTitle), eq(habits.domainId, domainId), isNull(habits.deletedAt)))
|
||||
.limit(1);
|
||||
if (habit) return { entityId: habit.id, entityType: "habit" };
|
||||
return null;
|
||||
@@ -58,7 +61,7 @@ async function resolveTarget(entityType: string, title: string): Promise<{ entit
|
||||
const [project] = await db
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.name, trimmedTitle), isNull(projects.deletedAt)))
|
||||
.where(and(eq(projects.name, trimmedTitle), eq(projects.domainId, domainId), isNull(projects.deletedAt)))
|
||||
.limit(1);
|
||||
if (project) return { entityId: project.id, entityType: "project" };
|
||||
return null;
|
||||
@@ -89,12 +92,12 @@ async function resolveTarget(entityType: string, title: string): Promise<{ entit
|
||||
/**
|
||||
* Sync wikilinks for a note: parse content, resolve targets, diff existing links.
|
||||
*/
|
||||
export async function syncNoteLinks(noteId: string, content: string): Promise<void> {
|
||||
export async function syncNoteLinks(noteId: string, content: string, domainId: string): Promise<void> {
|
||||
const targets = extractLinkTargets(content);
|
||||
|
||||
const resolvedTargets: { entityType: string; entityId: string }[] = [];
|
||||
for (const target of targets) {
|
||||
const resolved = await resolveTarget(target.entityType, target.title);
|
||||
const resolved = await resolveTarget(target.entityType, target.title, domainId);
|
||||
if (resolved) {
|
||||
resolvedTargets.push(resolved);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, notes, noteTags, tags as tagsTable, activityFeed } from "@project-e/db";
|
||||
import { and, asc, desc, eq, exists, ilike, inArray, isNull, sql } from "drizzle-orm";
|
||||
import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth";
|
||||
import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError, isUuid } from "../middleware/auth";
|
||||
import { recordActivity } from "../middleware/activity";
|
||||
import { enqueueWebhooks } from "../middleware/webhook-queue";
|
||||
import { syncNoteLinks, getBacklinks, getOutgoingLinks } from "./note-link-service";
|
||||
@@ -172,7 +172,7 @@ noteRoutes.post("/", async (c) => {
|
||||
|
||||
// Sync wikilinks from content
|
||||
if (data.content) {
|
||||
await syncNoteLinks(note.id, data.content);
|
||||
await syncNoteLinks(note.id, data.content, data.domain);
|
||||
}
|
||||
|
||||
await recordActivity({
|
||||
@@ -204,6 +204,9 @@ noteRoutes.get("/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
|
||||
const [note] = await db.select()
|
||||
.from(notes)
|
||||
@@ -252,6 +255,9 @@ noteRoutes.patch("/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const body = await c.req.json();
|
||||
const data = updateNoteSchema.parse(body);
|
||||
|
||||
@@ -281,7 +287,7 @@ noteRoutes.patch("/:id", async (c) => {
|
||||
// Re-sync wikilinks if content changed
|
||||
const content = data.content ?? existing.content;
|
||||
if (content) {
|
||||
await syncNoteLinks(id, content);
|
||||
await syncNoteLinks(id, content, existing.domainId);
|
||||
}
|
||||
|
||||
await recordActivity({
|
||||
@@ -313,6 +319,9 @@ noteRoutes.delete("/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
|
||||
const [existing] = await db.select()
|
||||
.from(notes)
|
||||
@@ -355,6 +364,9 @@ noteRoutes.post("/:id/tags", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const body = await c.req.json();
|
||||
const { tagId } = z.object({ tagId: z.string().uuid("Invalid tag id") }).parse(body);
|
||||
|
||||
@@ -406,6 +418,9 @@ noteRoutes.delete("/:id/tags/:tagId", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const tagId = c.req.param("tagId");
|
||||
|
||||
const [note] = await db.select({ id: notes.id, domainId: notes.domainId })
|
||||
@@ -445,6 +460,9 @@ noteRoutes.get("/:id/backlinks", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
|
||||
const [note] = await db.select({ id: notes.id, domainId: notes.domainId })
|
||||
.from(notes)
|
||||
@@ -477,6 +495,9 @@ noteRoutes.get("/:id/versions", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
|
||||
const [note] = await db.select({ id: notes.id, domainId: notes.domainId })
|
||||
.from(notes)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, projects, tasks, sections, projectTags, tags as tagsTable, activityFeed } from "@project-e/db";
|
||||
import { and, asc, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm";
|
||||
import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth";
|
||||
import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError, isUuid } from "../middleware/auth";
|
||||
import { recordActivity } from "../middleware/activity";
|
||||
import { enqueueWebhooks } from "../middleware/webhook-queue";
|
||||
import { z } from "zod";
|
||||
@@ -239,6 +239,9 @@ projectRoutes.get("/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
|
||||
const [project] = await db.select()
|
||||
.from(projects)
|
||||
@@ -300,6 +303,9 @@ projectRoutes.patch("/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const body = await c.req.json();
|
||||
const data = updateProjectSchema.parse(body);
|
||||
|
||||
@@ -357,6 +363,9 @@ projectRoutes.delete("/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
|
||||
const [existing] = await db.select()
|
||||
.from(projects)
|
||||
@@ -651,6 +660,9 @@ projectRoutes.get("/:id/members", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
|
||||
const [project] = await db.select({ id: projects.id, domainId: projects.domainId })
|
||||
.from(projects)
|
||||
@@ -687,6 +699,9 @@ projectRoutes.post("/:id/members", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const body = await c.req.json();
|
||||
const { userId, role } = z.object({
|
||||
userId: z.string().uuid(),
|
||||
@@ -731,6 +746,9 @@ projectRoutes.delete("/:id/members/:uid", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const userId = c.req.param("uid");
|
||||
|
||||
const [project] = await db.select({ id: projects.id, name: projects.name, domainId: projects.domainId })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, sql } from "@project-e/db";
|
||||
import { requireAuth, resolveActiveDomain, AuthError } from "../middleware/auth";
|
||||
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
|
||||
|
||||
export const searchRoutes = new Hono();
|
||||
|
||||
@@ -32,8 +32,13 @@ searchRoutes.get("/", async (c) => {
|
||||
}
|
||||
|
||||
// Scope all searches to the user's active domain so users can never see
|
||||
// another workspace's data.
|
||||
const userDomain = await resolveActiveDomain(user);
|
||||
// another workspace's data. The frontend passes the selected domain; the
|
||||
// param is validated so a foreign workspace id is rejected.
|
||||
let userDomain = await resolveActiveDomain(user);
|
||||
if (url.searchParams.get("domain")) {
|
||||
await requireWorkspaceAccess(c, url.searchParams.get("domain")!);
|
||||
userDomain = { ...userDomain, id: url.searchParams.get("domain")! };
|
||||
}
|
||||
const userDomainId = userDomain.id;
|
||||
|
||||
const results: Array<{ id: string; type: string; title: string; snippet: string; score: number; workspaceId: string; link: string }> = [];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, tags as tagsTable } from "@project-e/db";
|
||||
import { and, asc, desc, eq, sql } from "drizzle-orm";
|
||||
import { requireAuth, AuthError } from "../middleware/auth";
|
||||
import { requireAuth, AuthError, isUuid } from "../middleware/auth";
|
||||
import { recordActivity } from "../middleware/activity";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -79,6 +79,9 @@ tagRoutes.get("/:id", async (c) => {
|
||||
try {
|
||||
await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const [tag] = await db.select().from(tagsTable).where(eq(tagsTable.id, id)).limit(1);
|
||||
if (!tag) return c.json({ error: { code: "NOT_FOUND", message: "Tag not found" } }, 404);
|
||||
return c.json(tag);
|
||||
@@ -94,6 +97,9 @@ tagRoutes.patch("/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const body = await c.req.json();
|
||||
const data = updateTagSchema.parse(body);
|
||||
|
||||
@@ -121,6 +127,9 @@ tagRoutes.delete("/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const [existing] = await db.select().from(tagsTable).where(eq(tagsTable.id, id)).limit(1);
|
||||
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Tag not found" } }, 404);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, tasks, taskTags, tags as tagsTable, taskDependencies, activityFeed, scheduledJobs } from "@project-e/db";
|
||||
import { db, tasks, taskTags, tags as tagsTable, taskDependencies, activityFeed, scheduledJobs, projects, sections } from "@project-e/db";
|
||||
import { and, asc, desc, eq, exists, ilike, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth";
|
||||
import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError, isUuid } from "../middleware/auth";
|
||||
import { recordActivity } from "../middleware/activity";
|
||||
import { enqueueWebhooks } from "../middleware/webhook-queue";
|
||||
import { z } from "zod";
|
||||
@@ -246,13 +246,51 @@ taskRoutes.post("/", async (c) => {
|
||||
|
||||
// Cycle detection for parentId (subtask)
|
||||
if (data.parentId) {
|
||||
const [parent] = await db.select({ id: tasks.id })
|
||||
const [parent] = await db.select({ id: tasks.id, domainId: tasks.domainId })
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.id, data.parentId), isNull(tasks.deletedAt)))
|
||||
.limit(1);
|
||||
if (!parent) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Parent task not found" } }, 404);
|
||||
}
|
||||
if (parent.domainId !== data.domain) {
|
||||
return c.json({ error: { code: "FORBIDDEN", message: "Parent task does not belong to this workspace" } }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
// A task's project/section must belong to the same workspace; otherwise a
|
||||
// task can be linked into another domain's project and leak across workspaces.
|
||||
if (data.projectId) {
|
||||
const [project] = await db.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, data.projectId), isNull(projects.deletedAt)))
|
||||
.limit(1);
|
||||
if (!project) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
|
||||
}
|
||||
const [projectDomain] = await db.select({ domainId: projects.domainId }).from(projects).where(eq(projects.id, data.projectId)).limit(1);
|
||||
if (!projectDomain || projectDomain.domainId !== data.domain) {
|
||||
return c.json({ error: { code: "FORBIDDEN", message: "Project does not belong to this workspace" } }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.sectionId) {
|
||||
const [section] = await db.select({ id: sections.id, projectId: sections.projectId })
|
||||
.from(sections)
|
||||
.where(eq(sections.id, data.sectionId))
|
||||
.limit(1);
|
||||
if (!section) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Section not found" } }, 404);
|
||||
}
|
||||
if (data.projectId && section.projectId !== data.projectId) {
|
||||
return c.json({ error: { code: "VALIDATION_ERROR", message: "Section does not belong to the selected project" } }, 400);
|
||||
}
|
||||
if (section.projectId) {
|
||||
const [sectionProject] = await db.select({ domainId: projects.domainId }).from(projects).where(eq(projects.id, section.projectId)).limit(1);
|
||||
if (!sectionProject || sectionProject.domainId !== data.domain) {
|
||||
return c.json({ error: { code: "FORBIDDEN", message: "Section does not belong to this workspace" } }, 403);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [task] = await db.insert(tasks).values({
|
||||
@@ -375,6 +413,9 @@ taskRoutes.get("/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
|
||||
const [task] = await db.select()
|
||||
.from(tasks)
|
||||
@@ -444,6 +485,9 @@ taskRoutes.patch("/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const body = await c.req.json();
|
||||
const data = updateTaskSchema.parse(body);
|
||||
|
||||
@@ -531,6 +575,9 @@ taskRoutes.delete("/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
|
||||
const [existing] = await db.select()
|
||||
.from(tasks)
|
||||
@@ -576,6 +623,9 @@ taskRoutes.post("/:id/tags", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const body = await c.req.json();
|
||||
const { tagId } = z.object({ tagId: z.string().uuid("Invalid tag id") }).parse(body);
|
||||
|
||||
@@ -627,6 +677,9 @@ taskRoutes.delete("/:id/tags/:tagId", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const tagId = c.req.param("tagId");
|
||||
|
||||
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
|
||||
@@ -666,6 +719,9 @@ taskRoutes.post("/:id/status", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const body = await c.req.json();
|
||||
const { status: newStatus } = z.object({
|
||||
status: taskStatusEnum,
|
||||
@@ -722,6 +778,9 @@ taskRoutes.get("/:id/history", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
|
||||
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
|
||||
.from(tasks)
|
||||
@@ -759,6 +818,9 @@ taskRoutes.get("/:id/comments", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
|
||||
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
|
||||
.from(tasks)
|
||||
@@ -795,6 +857,9 @@ taskRoutes.post("/:id/comments", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const body = await c.req.json();
|
||||
const { content } = z.object({
|
||||
content: z.string().min(1, "Content is required"),
|
||||
@@ -839,6 +904,9 @@ taskRoutes.get("/:id/attachments", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
|
||||
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
|
||||
.from(tasks)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, webhooks } from "@project-e/db";
|
||||
import { and, asc, desc, eq, sql } from "drizzle-orm";
|
||||
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
|
||||
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth";
|
||||
import { recordActivity } from "../middleware/activity";
|
||||
import { enqueueWebhookDelivery } from "../middleware/webhook-queue";
|
||||
import { z } from "zod";
|
||||
@@ -105,6 +105,9 @@ webhookRoutes.patch("/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const body = await c.req.json();
|
||||
const data = updateWebhookSchema.parse(body);
|
||||
|
||||
@@ -142,6 +145,9 @@ webhookRoutes.delete("/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const [existing] = await db.select().from(webhooks).where(eq(webhooks.id, id)).limit(1);
|
||||
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Webhook not found" } }, 404);
|
||||
|
||||
@@ -167,6 +173,9 @@ webhookRoutes.post("/:id/test", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const [webhook] = await db.select().from(webhooks).where(eq(webhooks.id, id)).limit(1);
|
||||
if (!webhook) return c.json({ error: { code: "NOT_FOUND", message: "Webhook not found" } }, 404);
|
||||
|
||||
|
||||
@@ -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