diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index ab5822f..f83cc51 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -28,6 +28,8 @@ import { importExportRoutes } from "./routes/import-export"; import { notificationRoutes } from "./routes/notifications"; import { stateRoutes } from "./routes/states"; import { moduleRoutes } from "./routes/modules"; +import { cycleRoutes } from "./routes/cycles"; +import { linkRoutes } from "./routes/links"; import { healthHandler } from "./routes/health"; const app = new Hono(); @@ -47,6 +49,7 @@ app.get("/api/health", async (c) => { app.route("/api/auth", authRoutes); app.route("/api/domains", domainRoutes); app.route("/api/projects/:projectId/modules", moduleRoutes); +app.route("/api/projects/:projectId/cycles", cycleRoutes); app.route("/api/modules", moduleRoutes); app.route("/api/tasks", taskRoutes); app.route("/api/habits", habitRoutes); @@ -67,6 +70,8 @@ app.route("/api/analytics", analyticsRoutes); app.route("/api/activity", activityRoutes); app.route("/api/notifications", notificationRoutes); app.route("/api/states", stateRoutes); +app.route("/api/cycles", cycleRoutes); +app.route("/api/links", linkRoutes); app.route("/api", importExportRoutes); app.route("/api", realtimeRoutes); app.route("/api/mcp", mcpRoutes); diff --git a/apps/api/src/routes/cycles.ts b/apps/api/src/routes/cycles.ts new file mode 100644 index 0000000..5756359 --- /dev/null +++ b/apps/api/src/routes/cycles.ts @@ -0,0 +1,374 @@ +import { Hono } from "hono"; +import { db, cycles, tasks, projects } from "@project-e/db"; +import { and, asc, eq, isNull, sql } from "drizzle-orm"; +import { requireAuth, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth"; +import { recordActivity } from "../middleware/activity"; +import { enqueueWebhooks } from "../middleware/webhook-queue"; +import { z } from "zod"; + +export const cycleRoutes = new Hono(); + +const createCycleSchema = z.object({ + name: z.string().min(1, "Name is required"), + startDate: z.string().datetime().optional().nullable(), + endDate: z.string().datetime().optional().nullable(), + active: z.boolean().optional().default(false), +}); + +const updateCycleSchema = z.object({ + name: z.string().min(1).optional(), + startDate: z.string().datetime().optional().nullable(), + endDate: z.string().datetime().optional().nullable(), + active: z.boolean().optional(), +}); + +// GET / — List cycles for a project +cycleRoutes.get("/", async (c) => { + try { + const user = await requireAuth(c); + const url = new URL(c.req.url); + const projectId = c.req.param("projectId") || url.searchParams.get("project_id"); + + if (!projectId || !isUuid(projectId)) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "project_id is required" } }, 400); + } + + const [project] = await db.select({ id: projects.id, domainId: projects.domainId }) + .from(projects) + .where(and(eq(projects.id, projectId), isNull(projects.deletedAt))) + .limit(1); + + if (!project) { + return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); + } + + await requireWorkspaceAccess(c, project.domainId); + + const items = await db.select() + .from(cycles) + .where(eq(cycles.projectId, projectId)) + .orderBy(asc(cycles.createdAt)); + + return c.json({ items }); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[cycles] GET error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to list cycles" } }, 500); + } +}); + +// POST / — Create a cycle +cycleRoutes.post("/", async (c) => { + try { + const user = await requireAuth(c); + const body = await c.req.json(); + const data = createCycleSchema.parse(body); + + const projectId = c.req.param("projectId"); + if (!projectId || !isUuid(projectId)) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "project_id is required in URL path" } }, 400); + } + + const [project] = await db.select({ id: projects.id, domainId: projects.domainId }) + .from(projects) + .where(and(eq(projects.id, projectId), isNull(projects.deletedAt))) + .limit(1); + + if (!project) { + return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); + } + + await requireWorkspaceAccess(c, project.domainId); + + const [cycle] = await db.insert(cycles).values({ + name: data.name, + projectId, + startDate: data.startDate ? new Date(data.startDate) : null, + endDate: data.endDate ? new Date(data.endDate) : null, + active: data.active, + }).returning(); + + await recordActivity({ + actor: user.name, + action: "created", + entityType: "cycle", + entityId: cycle.id, + changes: { name: cycle.name, projectId }, + workspaceId: project.domainId, + }); + + await enqueueWebhooks({ workspaceId: project.domainId, event: "cycle.created", entityType: "cycle", entityId: cycle.id, data: { name: cycle.name, projectId } }); + + return c.json(cycle, 201); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + if (error instanceof z.ZodError) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400); + } + console.error("[cycles] POST error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create cycle" } }, 500); + } +}); + +// GET /:id — Get a single cycle with its tasks +cycleRoutes.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 [cycle] = await db.select() + .from(cycles) + .where(eq(cycles.id, id)) + .limit(1); + + if (!cycle) { + return c.json({ error: { code: "NOT_FOUND", message: "Cycle not found" } }, 404); + } + + const [project] = await db.select({ domainId: projects.domainId }) + .from(projects) + .where(eq(projects.id, cycle.projectId)) + .limit(1); + + await requireWorkspaceAccess(c, project?.domainId || ""); + + const cycleTasks = await db.select() + .from(tasks) + .where(and(eq(tasks.cycleId, id), isNull(tasks.deletedAt))) + .orderBy(asc(tasks.order)); + + return c.json({ ...cycle, tasks: cycleTasks }); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[cycles] GET/:id error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get cycle" } }, 500); + } +}); + +// PATCH /:id — Update a cycle +cycleRoutes.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 = updateCycleSchema.parse(body); + + const [existing] = await db.select() + .from(cycles) + .where(eq(cycles.id, id)) + .limit(1); + + if (!existing) { + return c.json({ error: { code: "NOT_FOUND", message: "Cycle not found" } }, 404); + } + + const [project] = await db.select({ domainId: projects.domainId }) + .from(projects) + .where(eq(projects.id, existing.projectId)) + .limit(1); + + await requireWorkspaceAccess(c, project?.domainId || ""); + + const updateValues: Record = {}; + if (data.name !== undefined) updateValues.name = data.name; + if (data.startDate !== undefined) updateValues.startDate = data.startDate ? new Date(data.startDate) : null; + if (data.endDate !== undefined) updateValues.endDate = data.endDate ? new Date(data.endDate) : null; + if (data.active !== undefined) updateValues.active = data.active; + updateValues.updatedAt = new Date(); + + const [updated] = await db.update(cycles) + .set(updateValues) + .where(eq(cycles.id, id)) + .returning(); + + await recordActivity({ + actor: user.name, + action: "updated", + entityType: "cycle", + entityId: id, + changes: { ...data, previousName: existing.name }, + workspaceId: project?.domainId || "", + }); + + await enqueueWebhooks({ workspaceId: project?.domainId || "", event: "cycle.updated", entityType: "cycle", entityId: id, data: { ...data } }); + + return c.json(updated); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + if (error instanceof z.ZodError) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400); + } + console.error("[cycles] PATCH error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update cycle" } }, 500); + } +}); + +// DELETE /:id — Delete a cycle +cycleRoutes.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(cycles) + .where(eq(cycles.id, id)) + .limit(1); + + if (!existing) { + return c.json({ error: { code: "NOT_FOUND", message: "Cycle not found" } }, 404); + } + + const [project] = await db.select({ domainId: projects.domainId }) + .from(projects) + .where(eq(projects.id, existing.projectId)) + .limit(1); + + const workspaceId = project?.domainId || ""; + await requireWorkspaceAccess(c, workspaceId); + + // Clear cycleId on tasks belonging to this cycle + await db.update(tasks) + .set({ cycleId: null, updatedAt: new Date() }) + .where(eq(tasks.cycleId, id)); + + await db.delete(cycles).where(eq(cycles.id, id)); + + await recordActivity({ + actor: user.name, + action: "deleted", + entityType: "cycle", + entityId: id, + changes: { name: existing.name, projectId: existing.projectId }, + workspaceId, + }); + + await enqueueWebhooks({ workspaceId, event: "cycle.deleted", entityType: "cycle", entityId: id, data: { name: existing.name } }); + + return c.body(null, 204); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[cycles] DELETE error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete cycle" } }, 500); + } +}); + +// POST /:id/tasks — Add a task to this cycle +cycleRoutes.post("/:id/tasks", async (c) => { + try { + const user = await requireAuth(c); + const cycleId = c.req.param("id"); + if (!isUuid(cycleId)) { + return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404); + } + const body = await c.req.json(); + const { taskId } = z.object({ taskId: z.string().uuid("Invalid task id") }).parse(body); + + const [cycle] = await db.select() + .from(cycles) + .where(eq(cycles.id, cycleId)) + .limit(1); + + if (!cycle) { + return c.json({ error: { code: "NOT_FOUND", message: "Cycle not found" } }, 404); + } + + const [project] = await db.select({ domainId: projects.domainId }) + .from(projects) + .where(eq(projects.id, cycle.projectId)) + .limit(1); + + await requireWorkspaceAccess(c, project?.domainId || ""); + + await db.update(tasks) + .set({ cycleId, updatedAt: new Date() }) + .where(eq(tasks.id, taskId)); + + await recordActivity({ + actor: user.name, + action: "added_task", + entityType: "cycle", + entityId: cycleId, + changes: { taskId }, + workspaceId: project?.domainId || "", + }); + + return c.json({ success: true }, 201); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + if (error instanceof z.ZodError) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400); + } + console.error("[cycles] POST /:id/tasks error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to add task to cycle" } }, 500); + } +}); + +// DELETE /:id/tasks/:taskId — Remove a task from this cycle +cycleRoutes.delete("/:id/tasks/:taskId", async (c) => { + try { + const user = await requireAuth(c); + const cycleId = c.req.param("id"); + const taskId = c.req.param("taskId"); + if (!isUuid(cycleId) || !isUuid(taskId)) { + return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404); + } + + const [cycle] = await db.select() + .from(cycles) + .where(eq(cycles.id, cycleId)) + .limit(1); + + if (!cycle) { + return c.json({ error: { code: "NOT_FOUND", message: "Cycle not found" } }, 404); + } + + const [project] = await db.select({ domainId: projects.domainId }) + .from(projects) + .where(eq(projects.id, cycle.projectId)) + .limit(1); + + await requireWorkspaceAccess(c, project?.domainId || ""); + + await db.update(tasks) + .set({ cycleId: null, updatedAt: new Date() }) + .where(eq(tasks.id, taskId)); + + await recordActivity({ + actor: user.name, + action: "removed_task", + entityType: "cycle", + entityId: cycleId, + changes: { taskId }, + workspaceId: project?.domainId || "", + }); + + return c.body(null, 204); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[cycles] DELETE /:id/tasks/:taskId error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to remove task from cycle" } }, 500); + } +}); diff --git a/apps/api/src/routes/graph.ts b/apps/api/src/routes/graph.ts index ceba2b5..54af011 100644 --- a/apps/api/src/routes/graph.ts +++ b/apps/api/src/routes/graph.ts @@ -1,6 +1,6 @@ import { Hono } from "hono"; import { db, domains, notes, tasks, habits, projects, sections, tags as tagsTable, links } from "@project-e/db"; -import { and, eq, inArray, isNull } from "drizzle-orm"; +import { and, eq, inArray, isNull, or } from "drizzle-orm"; import { requireAuth, requireWorkspaceAccess, AuthError } from "../middleware/auth"; import { recordActivity } from "../middleware/activity"; import { z } from "zod"; @@ -57,11 +57,11 @@ async function getGraphData(domainId: string): Promise<{ nodes: GraphNode[]; edg for (const s of sectionRows) addNode(s.id, s.name, 'section'); for (const t of tagRows) addNode(t.id, t.name, 'tag'); - // Read links from the canonical links table - const allIds = [...noteRows.map(n => n.id), ...taskRows.map(t => t.id)]; + // Read links from the canonical links table (both directions) + const allIds = [...noteRows.map(n => n.id), ...taskRows.map(t => t.id), ...projectRows.map(p => p.id), ...sectionRows.map(s => s.id)]; if (allIds.length > 0) { const linkRows = await db.select().from(links) - .where(inArray(links.sourceId, allIds)); + .where(or(inArray(links.sourceId, allIds), inArray(links.targetId, allIds))); for (const l of linkRows) addEdge(l.sourceId, l.targetId, l.linkType); } diff --git a/apps/api/src/routes/links.ts b/apps/api/src/routes/links.ts new file mode 100644 index 0000000..fead6b8 --- /dev/null +++ b/apps/api/src/routes/links.ts @@ -0,0 +1,173 @@ +import { Hono } from "hono"; +import { db, links, tasks, notes, projects } from "@project-e/db"; +import { and, eq, inArray, isNull, or } from "drizzle-orm"; +import { requireAuth, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth"; +import { recordActivity } from "../middleware/activity"; +import { z } from "zod"; + +export const linkRoutes = new Hono(); + +const createLinkSchema = z.object({ + sourceType: z.string().min(1), + sourceId: z.string().uuid(), + targetType: z.string().min(1), + targetId: z.string().uuid(), + linkType: z.enum(["relates", "blocks", "parent-child", "created-from"]), + direction: z.string().optional().nullable(), +}); + +async function resolveWorkspaceId(entityType: string, entityId: string): Promise { + if (entityType === "task") { + const [row] = await db.select({ domainId: tasks.domainId }).from(tasks).where(eq(tasks.id, entityId)).limit(1); + return row?.domainId ?? null; + } + if (entityType === "note") { + const [row] = await db.select({ domainId: notes.domainId }).from(notes).where(eq(notes.id, entityId)).limit(1); + return row?.domainId ?? null; + } + return null; +} + +// GET /api/links — List links for an entity (either source OR target) +linkRoutes.get("/", async (c) => { + try { + const user = await requireAuth(c); + const url = new URL(c.req.url); + const entityType = url.searchParams.get("entityType"); + const entityId = url.searchParams.get("entityId"); + + if (!entityType || !entityId) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "entityType and entityId query parameters are required" } }, 400); + } + + const workspaceId = await resolveWorkspaceId(entityType, entityId); + if (workspaceId) { + await requireWorkspaceAccess(c, workspaceId); + } + + const items = await db.select() + .from(links) + .where(or( + and(eq(links.sourceType, entityType), eq(links.sourceId, entityId)), + and(eq(links.targetType, entityType), eq(links.targetId, entityId)), + )); + + return c.json({ items }); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[links] GET error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to list links" } }, 500); + } +}); + +// POST /api/links — Create a link between two entities +linkRoutes.post("/", async (c) => { + try { + const user = await requireAuth(c); + const body = await c.req.json(); + const data = createLinkSchema.parse(body); + + // Prevent self-links + if (data.sourceId === data.targetId) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Cannot link an entity to itself" } }, 400); + } + + // Check for duplicate link + const [existing] = await db.select({ id: links.id }) + .from(links) + .where(and( + eq(links.sourceId, data.sourceId), + eq(links.targetId, data.targetId), + eq(links.linkType, data.linkType), + )) + .limit(1); + + if (existing) { + return c.json({ error: { code: "CONFLICT", message: "Link already exists" } }, 409); + } + + const workspaceId = await resolveWorkspaceId(data.sourceType, data.sourceId); + if (workspaceId) { + await requireWorkspaceAccess(c, workspaceId); + } + + const [link] = await db.insert(links).values({ + sourceType: data.sourceType, + sourceId: data.sourceId, + targetType: data.targetType, + targetId: data.targetId, + linkType: data.linkType, + direction: data.direction ?? null, + }).returning(); + + if (workspaceId) { + await recordActivity({ + actor: user.name, + action: "created", + entityType: "link", + entityId: link.id, + changes: { sourceType: data.sourceType, sourceId: data.sourceId, targetType: data.targetType, targetId: data.targetId, linkType: data.linkType }, + workspaceId, + }); + } + + return c.json(link, 201); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + if (error instanceof z.ZodError) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400); + } + console.error("[links] POST error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create link" } }, 500); + } +}); + +// DELETE /api/links/:id — Remove a link +linkRoutes.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(links) + .where(eq(links.id, id)) + .limit(1); + + if (!existing) { + return c.json({ error: { code: "NOT_FOUND", message: "Link not found" } }, 404); + } + + const workspaceId = await resolveWorkspaceId(existing.sourceType, existing.sourceId); + if (workspaceId) { + await requireWorkspaceAccess(c, workspaceId); + } + + await db.delete(links).where(eq(links.id, id)); + + if (workspaceId) { + await recordActivity({ + actor: user.name, + action: "deleted", + entityType: "link", + entityId: id, + changes: { sourceType: existing.sourceType, sourceId: existing.sourceId, targetType: existing.targetType, targetId: existing.targetId }, + workspaceId, + }); + } + + return c.body(null, 204); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[links] DELETE error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete link" } }, 500); + } +}); diff --git a/apps/web/src/lib/types/index.ts b/apps/web/src/lib/types/index.ts index 545fa2f..12f32f3 100644 --- a/apps/web/src/lib/types/index.ts +++ b/apps/web/src/lib/types/index.ts @@ -9,9 +9,13 @@ export interface Task { domainId: string; projectId: string | null; sectionId: string | null; + stateId: string | null; + moduleId: string | null; + cycleId: string | null; parentId: string | null; dueDate: string | null; estimatedMinutes: number | null; + trackedMinutes: number | null; recurrenceRule: string | null; order: number; completedAt: string | null; @@ -25,6 +29,61 @@ export interface Task { dependents?: { id: string; title: string; status: string }[]; } +export type StateGroup = "backlog" | "unstarted" | "started" | "completed" | "cancelled"; + +export interface State { + id: string; + name: string; + color: string | null; + group: StateGroup; + projectId: string; + sortOrder: number; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export type ModuleStatus = "planned" | "in_progress" | "completed" | "cancelled"; + +export interface Module { + id: string; + name: string; + description: string | null; + projectId: string; + status: ModuleStatus; + startDate: string | null; + targetDate: string | null; + sortOrder: number; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + tasks?: Task[]; +} + +export interface Cycle { + id: string; + name: string; + projectId: string; + startDate: string | null; + endDate: string | null; + active: boolean; + createdAt: string; + updatedAt: string; +} + +export type LinkType = "relates" | "blocks" | "parent-child" | "created-from"; + +export interface Link { + id: string; + sourceType: string; + sourceId: string; + targetType: string; + targetId: string; + linkType: LinkType; + direction: string | null; + createdAt: string; +} + export interface Habit { id: string; name: string; diff --git a/apps/web/src/routes/_app/graph.tsx b/apps/web/src/routes/_app/graph.tsx index 6e0819e..1c7eceb 100644 --- a/apps/web/src/routes/_app/graph.tsx +++ b/apps/web/src/routes/_app/graph.tsx @@ -20,7 +20,7 @@ import type { GraphNode, GraphEdge } from "@/lib/types"; import ForceGraph2D from "react-force-graph-2d"; const ENTITY_TYPES = ["task", "habit", "project", "note", "section", "tag", "domain"]; -const RELATIONSHIP_TYPES = ["depends_on", "related_to", "part_of", "references", "parent_of", "child_of", "connects_to"]; +const RELATIONSHIP_TYPES = ["depends_on", "related_to", "part_of", "references", "parent_of", "child_of", "connects_to", "relates", "blocks", "parent-child", "created-from", "task_project", "task_domain", "habit_domain", "project_domain", "note_domain", "section_project"]; const ENTITY_COLORS: Record = { task: "#3b82f6", @@ -32,6 +32,20 @@ const ENTITY_COLORS: Record = { domain: "#6366f1", }; +const LINK_TYPE_COLORS: Record = { + relates: "#94a3b8", + blocks: "#ef4444", + "parent-child": "#8b5cf6", + "created-from": "#10b981", + depends_on: "#ef4444", + related_to: "#94a3b8", + part_of: "#8b5cf6", + references: "#f59e0b", + parent_of: "#8b5cf6", + child_of: "#10b981", + connects_to: "#3b82f6", +}; + // Graph node types that have a detail page. section/tag/domain nodes appear in // the graph but have no detail route, so they are intentionally absent. const NODE_TYPE_ROUTES: Record = { @@ -265,11 +279,17 @@ function GraphPage() { const isHighlighted = highlightLinks.size === 0 || highlightLinks.has(`${link.source.id}-${link.target.id}`); const width = isHighlighted ? 1.5 / globalScale : 0.5 / globalScale; const opacity = isHighlighted ? 0.6 : 0.1; + const linkType = link.type || "relates"; + const baseColor = LINK_TYPE_COLORS[linkType] || "#94a3b8"; + + const r = parseInt(baseColor.slice(1, 3), 16); + const g = parseInt(baseColor.slice(3, 5), 16); + const b = parseInt(baseColor.slice(5, 7), 16); ctx.beginPath(); ctx.moveTo(link.source.x, link.source.y); ctx.lineTo(link.target.x, link.target.y); - ctx.strokeStyle = `rgba(148, 163, 184, ${opacity})`; + ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${opacity})`; ctx.lineWidth = width; ctx.stroke(); @@ -289,7 +309,7 @@ function GraphPage() { ctx.lineTo(midX - ux * arrowSize + uy * arrowSize * 0.5, midY - uy * arrowSize - ux * arrowSize * 0.5); ctx.lineTo(midX - ux * arrowSize - uy * arrowSize * 0.5, midY - uy * arrowSize + ux * arrowSize * 0.5); ctx.closePath(); - ctx.fillStyle = `rgba(148, 163, 184, ${opacity})`; + ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${opacity})`; ctx.fill(); } } @@ -425,8 +445,9 @@ function GraphPage() { checked={enabledRelationships.has(type)} onCheckedChange={() => toggleRelationship(type)} /> -