diff --git a/app/GTM.tsx b/app/GTM.tsx new file mode 100644 index 00000000..889963ea --- /dev/null +++ b/app/GTM.tsx @@ -0,0 +1,15 @@ +"use client" + +import { useEffect } from "react"; +import TagManager from "react-gtm-module"; + +export default function GTM() { + useEffect(() => { + const gtmId = process.env.NEXT_PUBLIC_GTM_ID + if (gtmId) { + TagManager.initialize({ gtmId }); + } + }, []); + + return null; +} \ No newline at end of file diff --git a/app/api/graph/[graph]/[node]/route.ts b/app/api/graph/[graph]/[node]/route.ts index 8820a3a5..6d0e7e1f 100644 --- a/app/api/graph/[graph]/[node]/route.ts +++ b/app/api/graph/[graph]/[node]/route.ts @@ -2,15 +2,14 @@ import { NextRequest, NextResponse } from "next/server"; import { getClient } from "@/app/api/auth/[...nextauth]/options"; // eslint-disable-next-line import/prefer-default-export -export async function GET(request: NextRequest, { params }: { params: { graph: string, node: string } }) { +export async function GET(request: NextRequest, { params }: { params: Promise<{ graph: string, node: string }> }) { const client = await getClient() if (client instanceof NextResponse) { return client } - const nodeId = parseInt(params.node, 10); - const graphId = params.graph; + const { graph: graphId, node: nodeId } = await params const graph = client.selectGraph(graphId); @@ -23,6 +22,7 @@ export async function GET(request: NextRequest, { params }: { params: { graph: s const result = await graph.query(query, { params: { nodeId } }); return NextResponse.json({ result }, { status: 200 }) } catch (err: unknown) { + console.error(err) return NextResponse.json({ message: (err as Error).message }, { status: 400 }) } } \ No newline at end of file diff --git a/app/api/graph/[graph]/export/route.ts b/app/api/graph/[graph]/export/route.ts index eda69fc5..59c3c050 100644 --- a/app/api/graph/[graph]/export/route.ts +++ b/app/api/graph/[graph]/export/route.ts @@ -3,23 +3,23 @@ import { NextRequest, NextResponse } from "next/server"; import { commandOptions } from "redis"; // eslint-disable-next-line import/prefer-default-export -export async function GET(request: NextRequest, { params } : { params : { graph: string } }) { +export async function GET(request: NextRequest, { params }: { params: Promise<{ graph: string }> }) { const client = await getClient() if (client instanceof NextResponse) { return client } - const graphId = params.graph + const { graph: graphId } = await params try { - + const result = await (await client.connection).dump( commandOptions({ returnBuffers: true }), graphId ) if (!result) throw new Error(`Failed to retrieve graph data for ID: ${graphId}`) - + return new NextResponse(result, { status: 200, headers: { diff --git a/app/api/graph/[graph]/route.ts b/app/api/graph/[graph]/route.ts index a8d802b5..baf85d6a 100644 --- a/app/api/graph/[graph]/route.ts +++ b/app/api/graph/[graph]/route.ts @@ -3,14 +3,14 @@ import { getClient } from "@/app/api/auth/[...nextauth]/options"; import { prepareArg, securedFetch } from "@/lib/utils"; // eslint-disable-next-line import/prefer-default-export -export async function DELETE(request: NextRequest, { params }: { params: { graph: string } }) { +export async function DELETE(request: NextRequest, { params }: { params: Promise<{ graph: string }> }) { const client = await getClient() if (client instanceof NextResponse) { return client } - const graphId = params.graph; + const { graph: graphId } = await params; try { if (graphId) { @@ -22,58 +22,52 @@ export async function DELETE(request: NextRequest, { params }: { params: { graph return NextResponse.json({ message: `${graphId} graph deleted` }) } } catch (err: unknown) { + console.error(err) return NextResponse.json({ message: (err as Error).message }, { status: 400 }) } } // eslint-disable-next-line import/prefer-default-export -export async function POST(request: NextRequest, { params }: { params: { graph: string } }) { +export async function POST(request: NextRequest, { params }: { params: Promise<{ graph: string }> }) { const client = await getClient() if (client instanceof NextResponse) { return client } - const graphId = params.graph; + const { graph: name } = await params; const sourceName = request.nextUrl.searchParams.get("sourceName") try { if (sourceName) { const graph = client.selectGraph(sourceName); - const success = await graph.copy(graphId) + const success = await graph.copy(name) if (!success) throw new Error("Failed to copy graph") return NextResponse.json({ success }, { status: 200 }) } const type = request.nextUrl.searchParams.get("type") - const key = request.nextUrl.searchParams.get("key") + const openaikey = request.nextUrl.searchParams.get("key") const srcs = await request.json() - if (!key) console.error("Missing parameter 'key'") - - if (!srcs) console.error("Missing parameter 'srcs'") // eslint-disable-next-line @typescript-eslint/no-explicit-any - const socket = (await client.connection).options?.socket as any - - if (!socket) console.error("socket not found") + const { host, port } = (await client.connection).options?.socket as any - const data = { - host: socket.host, - port: socket.port, - name: graphId, - srcs, - openaikey: key, - } - - if (!type) console.error("Missing parameter 'type'") + if (!openaikey || !srcs || !host || !port || !type) throw new Error("Missing parameters") const res = await securedFetch(`http://localhost:5000/${prepareArg(type!)}`, { method: "POST", headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) + body: JSON.stringify({ + host, + port, + name, + srcs, + openaikey, + }) }) const result = await res.json() @@ -82,23 +76,24 @@ export async function POST(request: NextRequest, { params }: { params: { graph: return NextResponse.json({ result }, { status: 200 }) } catch (err: unknown) { + console.error(err) return NextResponse.json({ message: (err as Error).message }, { status: 400 }) } } // eslint-disable-next-line import/prefer-default-export -export async function PATCH(request: NextRequest, { params }: { params: { graph: string } }) { +export async function PATCH(request: NextRequest, { params }: { params: Promise<{ graph: string }> }) { const client = await getClient() if (client instanceof NextResponse) { return client } - const graphId = params.graph; + const { graph: graphId } = await params; const sourceName = request.nextUrl.searchParams.get("sourceName") try { - if (!sourceName) throw (new Error("Missing parameter 'sourceName'")) + if (!sourceName) throw new Error("Missing parameter sourceName") const data = await (await client.connection).renameNX(sourceName, graphId); @@ -106,18 +101,20 @@ export async function PATCH(request: NextRequest, { params }: { params: { graph: return NextResponse.json({ data }) } catch (err: unknown) { + console.error(err) return NextResponse.json({ message: (err as Error).message }, { status: 400 }) } } -export async function GET(request: NextRequest, { params }: { params: { graph: string } }) { +export async function GET(request: NextRequest, { params }: { params: Promise<{ graph: string }> }) { const client = await getClient() if (client instanceof NextResponse) { return client } - const graphId = params.graph + const { graph: graphId } = await params + const ID = request.nextUrl.searchParams.get("ID") try { @@ -134,7 +131,7 @@ export async function GET(request: NextRequest, { params }: { params: { graph: s const create = request.nextUrl.searchParams.get("create") const role = request.nextUrl.searchParams.get("role") - if (!query) throw new Error("Missing parameter 'query'") + if (!query) throw new Error("Missing parameter query") if (create === "false" && !(await client.list()).some((g) => g === graphId)) return NextResponse.json({}, { status: 200 }) @@ -145,10 +142,11 @@ export async function GET(request: NextRequest, { params }: { params: { graph: s ? await graph.roQuery(query) : await graph.query(query) - if (!result) throw new Error("something went wrong") + if (!result) throw new Error("Something went wrong") return NextResponse.json({ result }, { status: 200 }) } catch (err: unknown) { + console.error(err) return NextResponse.json({ message: (err as Error).message }, { status: 400 }) } } \ No newline at end of file diff --git a/app/api/graph/model.ts b/app/api/graph/model.ts index e5e55036..91df1a2e 100644 --- a/app/api/graph/model.ts +++ b/app/api/graph/model.ts @@ -1,14 +1,72 @@ +/* eslint-disable no-param-reassign */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { EdgeDataDefinition, ElementDefinition, NodeDataDefinition } from 'cytoscape'; +import { EdgeDataDefinition, NodeDataDefinition } from 'cytoscape'; +import { LinkObject, NodeObject } from 'react-force-graph-2d'; + +export type Node = NodeObject<{ + id: number, + category: string[], + color: string, + visible: boolean, + expand: boolean, + collapsed: boolean, + data: { + [key: string]: any + } +}> + +export type Link = LinkObject + +export type GraphData = { + nodes: Node[], + links: Link[] +} + +export type NodeCell = { + id: number, + labels: string[], + properties: { + [key: string]: any + } +} + +export type LinkCell = { + id: number, + relationshipType: string, + sourceId: number, + destinationId: number, + properties: { + [key: string]: any + } +} + +export type DataCell = NodeCell | LinkCell | number | string | null + +export type DataRow = { + [key: string]: DataCell +} + +export type Data = DataRow[] export const DEFAULT_COLORS = [ - "#7167F6", - "#ED70B1", - "#EF8759", - "#99E4E5", - "#F2EB47", - "#89D86D" + "#7466FF", + "#FF66B3", + "#FF804D", + "#80E6E6" ] export interface Query { @@ -35,7 +93,7 @@ export class Graph { private columns: string[]; - private data: any[]; + private data: Data; private metadata: any[]; @@ -43,7 +101,7 @@ export class Graph { private labels: Category[]; - private elements: ElementDefinition[]; + private elements: GraphData; private categoriesMap: Map; @@ -53,14 +111,14 @@ export class Graph { private labelsColorIndex: number = 0; - private nodesMap: Map; + private nodesMap: Map; - private edgesMap: Map; + private linksMap: Map; private COLORS_ORDER_VALUE: string[] = [] - private constructor(id: string, categories: Category[], labels: Category[], elements: ElementDefinition[], - categoriesMap: Map, labelsMap: Map, nodesMap: Map, edgesMap: Map, colors?: string[]) { + private constructor(id: string, categories: Category[], labels: Category[], elements: GraphData, + categoriesMap: Map, labelsMap: Map, nodesMap: Map, edgesMap: Map, colors?: string[]) { this.id = id; this.columns = []; this.data = []; @@ -71,15 +129,8 @@ export class Graph { this.categoriesMap = categoriesMap; this.labelsMap = labelsMap; this.nodesMap = nodesMap; - this.edgesMap = edgesMap; - this.COLORS_ORDER_VALUE = colors || [ - "#7167F6", - "#ED70B1", - "#EF8759", - "#99E4E5", - "#F2EB47", - "#89D86D" - ] + this.linksMap = edgesMap; + this.COLORS_ORDER_VALUE = colors || DEFAULT_COLORS } get Id(): string { @@ -110,19 +161,19 @@ export class Graph { return this.labelsMap; } - get NodesMap(): Map { + get NodesMap(): Map { return this.nodesMap; } - get EdgesMap(): Map { - return this.edgesMap; + get EdgesMap(): Map { + return this.linksMap; } - get Elements(): ElementDefinition[] { + get Elements(): GraphData { return this.elements; } - set Elements(elements: ElementDefinition[]) { + set Elements(elements: GraphData) { this.elements = elements } @@ -130,10 +181,14 @@ export class Graph { return this.columns; } - get Data(): any[] { + get Data(): Data { return this.data; } + set Data(data: Data) { + this.data = data; + } + get Metadata(): any[] { return this.metadata; } @@ -146,8 +201,12 @@ export class Graph { this.COLORS_ORDER_VALUE = colors; } + public getElements(): (Node | Link)[] { + return [...this.elements.nodes, ...this.elements.links] + } + public static empty(graphName?: string, colors?: string[]): Graph { - return new Graph(graphName || "", [], [], [], new Map(), new Map(), new Map(), new Map(), colors) + return new Graph(graphName || "", [], [], { nodes: [], links: [] }, new Map(), new Map(), new Map(), new Map(), colors) } public static create(id: string, results: any, colors?: string[]): Graph { @@ -157,126 +216,173 @@ export class Graph { return graph } - public extendNode(cell: any, collapsed = false) { + public extendNode(cell: NodeCell, collapsed = false) { // check if category already exists in categories const categories = this.createCategory(cell.labels.length === 0 ? [""] : cell.labels) // check if node already exists in nodes or fake node was created const currentNode = this.nodesMap.get(cell.id) if (!currentNode) { - const node: NodeDataDefinition = { - id: cell.id.toString(), + const node: Node = { + id: cell.id, category: categories.map(c => c.name), color: this.getCategoryColorValue(categories[0].index), + visible: true, expand: false, collapsed, - data: { - name: cell.id.toString(), - } + data: {} } Object.entries(cell.properties).forEach(([key, value]) => { node.data[key] = value as string; }); this.nodesMap.set(cell.id, node) - this.elements.push({ data: node }) + this.elements.nodes.push(node) return node } - if (currentNode.category === "") { + if (currentNode.category[0] === "") { // set values in a fake node - currentNode.id = cell.id.toString(); + currentNode.id = cell.id; currentNode.category = categories.map(c => c.name); currentNode.color = this.getCategoryColorValue(categories[0].index) currentNode.expand = false currentNode.collapsed = collapsed - currentNode.data = { - name: cell.id.toString() - } Object.entries(cell.properties).forEach(([key, value]) => { currentNode.data[key] = value as string; }); // remove empty category if there are no more empty nodes category - if (this.nodesMap.values().every(n => n.category !== "")) { - this.categories = this.categories.filter(l => l.name !== "") + if (Array.from(this.nodesMap.values()).every(n => n.category.some(c => c !== ""))) { + this.categories = this.categories.filter(l => l.name !== "").map(c => { + // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain + if (this.categoriesMap.get("")?.index! < c.index) { + c.index -= 1 + this.categoriesMap.get(c.name)!.index = c.index + } + return c + }) this.categoriesMap.delete("") + this.categoriesColorIndex -= 1 + this.elements.nodes.forEach(n => { + n.color = this.getCategoryColorValue(this.categoriesMap.get(n.category[0])?.index) + }) } } return currentNode } - public extendEdge(cell: any, collapsed = false) { + public extendEdge(cell: LinkCell, collapsed = false) { const label = this.createLabel(cell.relationshipType) - const currentEdge = this.edgesMap.get(cell.id) + const currentEdge = this.linksMap.get(cell.id) if (!currentEdge) { - const sourceId = cell.sourceId.toString(); - const destinationId = cell.destinationId.toString() - const edge: EdgeDataDefinition = { - id: `_${cell.id}`, - source: sourceId, - target: destinationId, - label: cell.relationshipType, - color: this.getCategoryColorValue(label.index), - expand: false, - collapsed, - data: {} - } + let category + let link: Link - Object.entries(cell.properties).forEach(([key, value]) => { - edge.data[key] = value as string; - }); + if (cell.sourceId === cell.destinationId) { + let source = this.nodesMap.get(cell.sourceId) - this.edgesMap.set(cell.id, edge) - this.elements.push({ data: edge }) + if (!source) { + [category] = this.createCategory([""]) + } - let category - let source = this.nodesMap.get(cell.sourceId) - let destination = this.nodesMap.get(cell.destinationId) + if (!source) { + source = { + id: cell.sourceId, + category: [category!.name], + color: this.getCategoryColorValue(), + expand: false, + collapsed, + visible: true, + data: {}, + } - if (!source || !destination) { - [category] = this.createCategory([""]) - } + this.nodesMap.set(cell.sourceId, source) + this.elements.nodes.push(source) + } - if (!source) { - source = { - id: cell.sourceId.toString(), - name: cell.sourceId.toString(), - category: category!.name, - color: this.getCategoryColorValue(), + link = { + id: cell.id, + source, + target: source, + label: cell.relationshipType, + color: this.getCategoryColorValue(label.index), expand: false, collapsed, + visible: true, + curve: 0, + data: {} } + } else { + let source = this.nodesMap.get(cell.sourceId) + let target = this.nodesMap.get(cell.destinationId) - this.nodesMap.set(cell.sourceId, source) - this.elements.push({ data: source }) - } + if (!source || !target) { + [category] = this.createCategory([""]) + } + if (!source) { + source = { + id: cell.sourceId, + category: [category!.name], + color: this.getCategoryColorValue(), + expand: false, + collapsed, + visible: true, + data: {}, + } + + this.nodesMap.set(cell.sourceId, source) + this.elements.nodes.push(source) + } + + + if (!target) { + target = { + id: cell.destinationId, + category: [category!.name], + color: this.getCategoryColorValue(), + expand: false, + collapsed, + visible: true, + data: {}, + } + } - if (!destination) { - destination = { - id: cell.destinationId.toString(), - name: cell.destinationId.toString(), - category: category!.name, - color: this.getCategoryColorValue(), + this.nodesMap.set(cell.destinationId, target) + this.elements.nodes.push(target) + + link = { + id: cell.id, + source, + target, + label: cell.relationshipType, + color: this.getCategoryColorValue(label.index), expand: false, collapsed, + visible: true, + curve: 0, + data: {} } - - this.nodesMap.set(cell.destinationId, destination) - this.elements.push({ data: destination }) } - return edge + Object.entries(cell.properties).forEach(([key, value]) => { + link.data[key] = value as string; + }); + + this.linksMap.set(cell.id, link) + this.elements.links.push(link) + + return link } return currentEdge } - public extend(results: any, collapsed = false): ElementDefinition[] { - const newElements: ElementDefinition[] = [] + public extend(results: { data: Data, metadata: any[] }, collapsed = false): (Node | Link)[] { + const newElements: (Node | Link)[] = [] const data = results?.data if (data?.length) { @@ -288,37 +394,59 @@ export class Graph { } this.metadata = results.metadata - this.data.forEach((row: any[]) => { + this.data.forEach((row: DataRow) => { Object.values(row).forEach((cell: any) => { if (cell instanceof Object) { if (cell.nodes) { cell.nodes.forEach((node: any) => { - newElements.push({ data: this.extendNode(node, collapsed) }) + newElements.push(this.extendNode(node, collapsed)) }) cell.edges.forEach((edge: any) => { - newElements.push({ data: this.extendEdge(edge, collapsed) }) + newElements.push(this.extendEdge(edge, collapsed)) }) } else if (cell.relationshipType) { - newElements.push({ data: this.extendEdge(cell, collapsed) }) + newElements.push(this.extendEdge(cell, collapsed)) } else if (cell.labels) { - newElements.push({ data: this.extendNode(cell, collapsed) }) + newElements.push(this.extendNode(cell, collapsed)) } } }) }) + newElements.filter((element): element is Link => "source" in element).forEach((link) => { + const start = link.source + const end = link.target + const sameNodesLinks = this.elements.links.filter(l => (l.source.id === start.id && l.target.id === end.id) || (l.target.id === start.id && l.source.id === end.id)) + const index = sameNodesLinks.findIndex(l => l.id === link.id) || 0 + const even = index % 2 === 0 + let curve + + if (start.id === end.id) { + if (even) { + curve = Math.floor(-(index / 2)) - 3 + } else { + curve = Math.floor((index + 1) / 2) + 2 + } + } else if (even) { + curve = Math.floor(-(index / 2)) + } else { + curve = Math.floor((index + 1) / 2) + } + + link.curve = curve * 0.1 + }) return newElements } public updateCategories(category: string, type: boolean) { - if (type && !this.elements.find(e => e.data.category === category)) { + if (type && this.elements.nodes.every(n => n.category.some(c => c !== category))) { const i = this.categories.findIndex(({ name }) => name === category) this.categories.splice(i, 1) this.categoriesMap.delete(category) return true } - if (!type && !this.elements.find(e => e.data.label === category)) { + if (!type && !this.elements.links.every(l => l.data.label !== category)) { const i = this.labels.findIndex(({ name }) => name === category) this.labels.splice(i, 1) this.labelsMap.delete(category) @@ -356,6 +484,42 @@ export class Graph { return l } + public visibleLinks(visible: boolean) { + this.elements.links.forEach(link => { + if (visible && (this.elements.nodes.map(n => n.id).includes(link.source.id) && link.source.visible) && (this.elements.nodes.map(n => n.id).includes(link.target.id) && link.target.visible)) { + // eslint-disable-next-line no-param-reassign + link.visible = true + } + + if (!visible && ((this.elements.nodes.map(n => n.id).includes(link.source.id) && !link.source.visible) || (this.elements.nodes.map(n => n.id).includes(link.target.id) && !link.target.visible))) { + // eslint-disable-next-line no-param-reassign + link.visible = false + } + }) + } + + public removeLinks(ids: number[] = []) { + const elements = this.elements.links.filter(link => ids.includes(link.source.id) || ids.includes(link.target.id)) + + this.elements = { + nodes: this.elements.nodes, + links: this.elements.links.map(link => { + if (ids.length !== 0 && elements.includes(link)) { + this.linksMap.delete(link.id) + + return undefined + } + + if (this.elements.nodes.map(n => n.id).includes(link.source.id) && this.elements.nodes.map(n => n.id).includes(link.target.id)) { + return link + } + this.linksMap.delete(link.id) + + return undefined + }).filter(link => link !== undefined) + } + } + public getCategoryColorValue(index = 0): string { return this.COLORS_ORDER_VALUE[index % this.COLORS_ORDER_VALUE.length] } diff --git a/app/api/graph/route.ts b/app/api/graph/route.ts index 844ee45c..c39388cf 100644 --- a/app/api/graph/route.ts +++ b/app/api/graph/route.ts @@ -22,6 +22,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ result }, { status: 200 }) } catch (err: unknown) { + console.error(err) return NextResponse.json({ message: (err as Error).message }, { status: 400 }) } } @@ -45,6 +46,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ config }, { status: 200 }) } } catch (err: unknown) { + console.error(err) return NextResponse.json({ message: (err as Error).message }, { status: 400 }) } } \ No newline at end of file diff --git a/app/api/user/[user]/route.ts b/app/api/user/[user]/route.ts index 01935d4f..4ac20f50 100644 --- a/app/api/user/[user]/route.ts +++ b/app/api/user/[user]/route.ts @@ -1,23 +1,24 @@ import { NextRequest, NextResponse } from "next/server" import { getClient } from "../../auth/[...nextauth]/options" -import { ROLE } from "../options" +import { ROLE } from "../model" // eslint-disable-next-line import/prefer-default-export -export async function PATCH(req: NextRequest, { params }: { params: { user: string } }) { +export async function PATCH(req: NextRequest, { params }: { params: Promise<{ user: string }> }) { const client = await getClient() if (client instanceof NextResponse) { return client } - const username = params.user + const { user: username } = await params const role = ROLE.get(req.nextUrl.searchParams.get("role") || "") try { if (!role) throw new Error("Role is missing") await (await client.connection).aclSetUser(username, role) - return NextResponse.json({ message: "User created" },{status: 200}) + return NextResponse.json({ message: "User created" }, { status: 200 }) } catch (err: unknown) { + console.error(err) return NextResponse.json({ message: (err as Error).message }, { status: 400 }) } } \ No newline at end of file diff --git a/app/api/user/model.ts b/app/api/user/model.ts index 429c15b0..43e5b280 100644 --- a/app/api/user/model.ts +++ b/app/api/user/model.ts @@ -1,5 +1,18 @@ export interface User{ username: string role: string - selected?: boolean -} \ No newline at end of file +} + +export interface CreateUser { + username: string + password: string + role: string +} + +export const ROLE = new Map( + [ + ["Admin", ["on", "~*", "&*", "+@all"]], + ["Read-Write", ["on", "~*", "resetchannels", "-@all", "+graph.explain", "+graph.list", "+ping", "+graph.ro_query", "+info", "+dump", "+graph.delete", "+graph.query", "+graph.profile"]], + ["Read-Only", ["on", "~*", "resetchannels", "-@all", "+graph.explain", "+graph.list", "+ping", "+graph.ro_query", "+info", "+dump"]] + ] +) \ No newline at end of file diff --git a/app/api/user/options.ts b/app/api/user/options.ts deleted file mode 100644 index 146d2f59..00000000 --- a/app/api/user/options.ts +++ /dev/null @@ -1,13 +0,0 @@ -export interface CreateUser { - username: string - password: string - role: string -} - -export const ROLE = new Map( - [ - ["Admin", ["on", "~*", "&*", "+@all"]], - ["Read-Write", ["on", "~*", "resetchannels", "-@all", "+graph.explain", "+graph.list", "+ping", "+graph.ro_query", "+info", "+dump", "+graph.delete", "+graph.query", "+graph.profile"]], - ["Read-Only", ["on", "~*", "resetchannels", "-@all", "+graph.explain", "+graph.list", "+ping", "+graph.ro_query", "+info", "+dump"]] - ] -) \ No newline at end of file diff --git a/app/api/user/route.ts b/app/api/user/route.ts index aa68835c..5463c89c 100644 --- a/app/api/user/route.ts +++ b/app/api/user/route.ts @@ -1,7 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getClient } from "@/app/api/auth/[...nextauth]/options"; -import { User } from "./model" -import { CreateUser, ROLE } from "./options"; +import { User, CreateUser, ROLE } from "./model"; export async function GET() { @@ -26,12 +25,13 @@ export async function GET() { return { username: userDetails[1], role: role ? role[0] : "Unknown", - checked: false + selected: false, } }) return NextResponse.json({ result }, { status: 200 }) } catch (err: unknown) { + console.error(err) return NextResponse.json({ message: (err as Error).message }, { status: 400 }) } } @@ -44,36 +44,27 @@ export async function POST(req: NextRequest) { } const connection = await client.connection - const isDelete = req.nextUrl.searchParams.get("isDelete") - - if (isDelete === "true") { - const { users } = await req.json() - - await Promise.all(users.map(async (user: CreateUser) => { - await connection.aclDelUser(user.username) - })) - - return NextResponse.json({ message: "Users deleted" }, { status: 200 }) - } - const { username, password, role } = await req.json() as CreateUser + const roleValue = ROLE.get(role) - try { - if (!username || !password || !roleValue) throw (new Error("Missing parameters")) - try { - const user = await connection.aclGetUser(username) + try { + if (!username || !password || !roleValue) throw new Error("Missing parameters") - if (user) { - return NextResponse.json({ message: `User ${username} already exists` }, { status: 409 }) - } + try { + const user = await connection.aclGetUser(username) + + if (user) { + return NextResponse.json({ message: `User ${username} already exists` }, { status: 409 }) + } } catch (err: unknown) { + console.error(err) // Just a workaround for https://github.com/redis/node-redis/issues/2745 } await connection.aclSetUser(username, roleValue.concat(`>${password}`)) return NextResponse.json( - { message: "User created" }, + { message: "Success" }, { status: 201, headers: { @@ -82,6 +73,30 @@ export async function POST(req: NextRequest) { } ) } catch (err: unknown) { + console.error(err) + return NextResponse.json({ message: (err as Error).message }, { status: 400 }) + } +} + +export async function DELETE(req: NextRequest) { + + const client = await getClient() + + if (client instanceof NextResponse) { + return client + } + + const connection = await client.connection + const { users } = await req.json() + + try { + await Promise.all(users.map(async (user: User) => { + await connection.aclDelUser(user.username) + })) + + return NextResponse.json({ message: "Users deleted" }, { status: 200 }) + } catch (err: unknown) { + console.error(err) return NextResponse.json({ message: (err as Error).message }, { status: 400 }) } } \ No newline at end of file diff --git a/app/components/CloseDialog.tsx b/app/components/CloseDialog.tsx index 75fb2cb4..f26a3e70 100644 --- a/app/components/CloseDialog.tsx +++ b/app/components/CloseDialog.tsx @@ -1,28 +1,28 @@ 'use client'; import { DialogClose } from "@/components/ui/dialog"; -import { cn } from "@/lib/utils"; import { X } from "lucide-react"; -import Button from "./ui/Button"; +import Button, { Variant } from "./ui/Button"; /* eslint-disable react/require-default-props */ interface Props extends React.DetailedHTMLProps, HTMLButtonElement> { icon?: JSX.Element label?: string - variant?: "Large" | "Primary" | "Secondary" | "button" + variant?: Variant } -export default function CloseDialog({ className, label, variant, icon, ...props }: Props) { +export default function CloseDialog({ className, label, icon, ...props }: Props) { return ( ) } \ No newline at end of file diff --git a/app/components/CreateGraph.tsx b/app/components/CreateGraph.tsx new file mode 100644 index 00000000..8cb14894 --- /dev/null +++ b/app/components/CreateGraph.tsx @@ -0,0 +1,101 @@ +/* eslint-disable react/require-default-props */ + +"use client" + +import { useState } from "react" +import { AlertCircle, PlusCircle } from "lucide-react" +import { prepareArg, securedFetch } from "@/lib/utils" +import { useToast } from "@/components/ui/use-toast" +import DialogComponent from "./DialogComponent" +import Button from "./ui/Button" +import CloseDialog from "./CloseDialog" +import Input from "./ui/Input" + +interface Props { + onSetGraphName: (name: string) => void + type: string + trigger?: React.ReactNode +} + +export default function CreateGraph({ + onSetGraphName, + type, + trigger = ( + + ), +}: Props) { + + const [graphName, setGraphName] = useState("") + const [open, setOpen] = useState(false) + const { toast } = useToast() + + const handleCreateGraph = async (e: React.FormEvent) => { + e.preventDefault() + if (!graphName) { + toast({ + title: "Error", + description: "Graph name cannot be empty", + variant: "destructive" + }) + return + } + const q = 'RETURN 1' + const result = await securedFetch(`api/graph/${prepareArg(graphName)}/?query=${prepareArg(q)}`, { + method: "GET", + }, toast) + + if (!result.ok) return + + onSetGraphName(graphName) + setGraphName("") + setOpen(false) + } + + return ( + +
{ + e.preventDefault() + handleCreateGraph(e) + }}> +
+ +

Name your graph:

+ ref?.focus()} + value={graphName} + onChange={(e) => setGraphName(e.target.value)} + /> +
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/app/components/DialogComponent.tsx b/app/components/DialogComponent.tsx index ca6749aa..f6ae932f 100644 --- a/app/components/DialogComponent.tsx +++ b/app/components/DialogComponent.tsx @@ -1,6 +1,6 @@ 'use client'; -import { DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; import { ReactNode } from "react"; import CloseDialog from "./CloseDialog"; @@ -9,26 +9,40 @@ import CloseDialog from "./CloseDialog"; interface Props { children: React.ReactNode title: string + open?: boolean + onOpenChange?: (open: boolean) => void + trigger: React.ReactNode description?: ReactNode className?: string } -export default function DialogComponent({ children, title, description, className }: Props) { +export default function DialogComponent({ + children, + open, + onOpenChange, + trigger, + title, + description, + className, +}: Props) { return ( - - - {title} - - -
+ + + {trigger} + + + + {title} + + { description && - + {description} } {children} -
-
+ + ) -} \ No newline at end of file +} diff --git a/app/components/EditorComponent.tsx b/app/components/EditorComponent.tsx index 2aa094c5..c8b28db7 100644 --- a/app/components/EditorComponent.tsx +++ b/app/components/EditorComponent.tsx @@ -1,5 +1,8 @@ /* eslint-disable consistent-return */ /* eslint-disable react-hooks/exhaustive-deps */ + +"use client"; + import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; import { Editor, Monaco } from "@monaco-editor/react" import { useEffect, useRef, useState } from "react" @@ -7,6 +10,7 @@ import * as monaco from "monaco-editor"; import { prepareArg, securedFetch } from "@/lib/utils"; import { Maximize2 } from "lucide-react"; import { Session } from "next-auth"; +import { useToast } from "@/components/ui/use-toast"; import { Graph } from "../api/graph/model"; import Button from "./ui/Button"; @@ -52,11 +56,10 @@ const monacoOptions: monaco.editor.IStandaloneEditorConstructionOptions = { loop: false, }, automaticLayout: true, - fontSize: 30, + fontSize: 26, fontWeight: "200", wordWrap: "off", - lineHeight: 38, - lineNumbers: "on", + lineHeight: 37, lineNumbersMinChars: 2, overviewRulerLanes: 0, overviewRulerBorder: false, @@ -198,9 +201,12 @@ const getEmptySuggestions = (): Suggestions => ({ functions: [] }) +const PLACEHOLDER = "Type your query here to start" + export default function EditorComponent({ currentQuery, historyQueries, setCurrentQuery, maximize, runQuery, graph, isCollapsed, data }: Props) { const [query, setQuery] = useState(currentQuery) + const placeholderRef = useRef(null) const [monacoInstance, setMonacoInstance] = useState() const [prevGraphName, setPrevGraphName] = useState("") const [sugProvider, setSugProvider] = useState() @@ -214,6 +220,7 @@ export default function EditorComponent({ currentQuery, historyQueries, setCurre currentQuery, historyCounter: historyQueries.length }) + const { toast } = useToast() useEffect(() => { historyRef.current.historyQueries = historyQueries @@ -241,8 +248,8 @@ export default function EditorComponent({ currentQuery, historyQueries, setCurre { token: 'number', foreground: '#b5cea8' }, ], colors: { - 'editor.background': '#1F1F3D', - 'editor.foreground': 'ffffff', + 'editor.background': '#191919', + 'editor.foreground': '#ffffff', 'editorSuggestWidget.background': '#272745', 'editorSuggestWidget.foreground': '#FFFFFF', 'editorSuggestWidget.selectedBackground': '#57577B', @@ -278,7 +285,7 @@ export default function EditorComponent({ currentQuery, historyQueries, setCurre await securedFetch(`api/graph/${prepareArg(graph.Id)}/?query=${prepareArg(labelsQuery)}&role=${data?.user.role}`, { method: "GET" - }).then((res) => res.json()).then((json) => { + }, toast).then((res) => res.json()).then((json) => { json.result.data.forEach(({ label }: { label: string }) => { sug.push({ label, @@ -296,7 +303,7 @@ export default function EditorComponent({ currentQuery, historyQueries, setCurre await securedFetch(`api/graph/${prepareArg(graph.Id)}/?query=${prepareArg(relationshipTypeQuery)}&role=${data?.user.role}`, { method: "GET" - }).then((res) => res.json()).then((json) => { + }, toast).then((res) => res.json()).then((json) => { json.result.data.forEach(({ relationshipType }: { relationshipType: string }) => { sug.push({ label: relationshipType, @@ -314,7 +321,7 @@ export default function EditorComponent({ currentQuery, historyQueries, setCurre await securedFetch(`api/graph/${prepareArg(graph.Id)}/?query=${prepareArg(propertyKeysQuery)}&role=${data?.user.role}`, { method: "GET" - }).then((res) => res.json()).then((json) => { + }, toast).then((res) => res.json()).then((json) => { json.result.data.forEach(({ propertyKey }: { propertyKey: string }) => { sug.push({ label: propertyKey, @@ -331,7 +338,7 @@ export default function EditorComponent({ currentQuery, historyQueries, setCurre const proceduresQuery = `CALL dbms.procedures() YIELD name` await securedFetch(`api/graph/${prepareArg(graph.Id)}/?query=${prepareArg(proceduresQuery)}&role=${data?.user.role}`, { method: "GET" - }).then((res) => res.json()).then((json) => { + }, toast).then((res) => res.json()).then((json) => { [...json.result.data.map(({ name }: { name: string }) => name), ...FUNCTIONS].forEach((name: string) => { functions.push({ label: name, @@ -435,15 +442,16 @@ export default function EditorComponent({ currentQuery, historyQueries, setCurre const run = async () => { const sug: Suggestions = getEmptySuggestions() - - await Promise.all(graph.Metadata.map(async (meta: string) => { - if (meta.includes("Labels")) await addLabelsSuggestions(sug.labels) - if (meta.includes("RelationshipTypes")) await addRelationshipTypesSuggestions(sug.relationshipTypes) - if (meta.includes("PropertyKeys")) await addPropertyKeysSuggestions(sug.propertyKeys) - })) + if (graph.Metadata.length > 0) { + await Promise.all(graph.Metadata.map(async (meta: string) => { + if (meta.includes("Labels")) await addLabelsSuggestions(sug.labels) + if (meta.includes("RelationshipTypes")) await addRelationshipTypesSuggestions(sug.relationshipTypes) + if (meta.includes("PropertyKeys")) await addPropertyKeysSuggestions(sug.propertyKeys) + })) + } Object.entries(sug).forEach(([key, value]) => { if (value.length === 0) { - sug[key as "labels" || "propertyKeys" || "relationshipTypes"] = suggestions[key as "labels" || "propertyKeys" || "relationshipTypes"] + sug[key as keyof Suggestions] = suggestions[key as keyof Suggestions] } }) @@ -526,12 +534,31 @@ export default function EditorComponent({ currentQuery, historyQueries, setCurre if (graph.Id) { getSuggestions(monacoI) - }else { + } else { addSuggestions(monacoI) } }; const handleEditorDidMount = (e: monaco.editor.IStandaloneCodeEditor, monacoI: Monaco) => { + const updatePlaceholderVisibility = () => { + const hasContent = !!e.getValue(); + if (placeholderRef.current) { + placeholderRef.current.style.display = hasContent ? 'none' : 'block'; + } + }; + + e.onDidFocusEditorText(() => { + if (placeholderRef.current) { + placeholderRef.current.style.display = 'none'; + } + }); + + e.onDidBlurEditorText(() => { + updatePlaceholderVisibility(); + }); + + // Initial check + updatePlaceholderVisibility(); setMonacoInstance(monacoI) @@ -546,7 +573,7 @@ export default function EditorComponent({ currentQuery, historyQueries, setCurre }) const isFirstLine = e.createContextKey('isFirstLine', false as boolean); - + // Update the context key value based on the cursor position e.onDidChangeCursorPosition(() => { const position = e.getPosition(); @@ -613,14 +640,16 @@ export default function EditorComponent({ currentQuery, historyQueries, setCurre runQuery(query) }} > -
+
document.body.clientHeight / 100 * MAX_HEIGHT ? document.body.clientHeight / 100 * MAX_HEIGHT : lineNumber * LINE_HEIGHT} - className="Editor" language="custom-language" - options={monacoOptions} - value={blur ? query.replace(/\s+/g, ' ').trim() : query} + options={{ + ...monacoOptions, + lineNumbers: lineNumber > 1 ? "on" : "off", + }} + value={(blur ? query.replace(/\s+/g, ' ').trim() : query)} onChange={(val) => historyRef.current.historyCounter ? setQuery(val || "") : setCurrentQuery(val || "")} theme="custom-theme" beforeMount={handleEditorWillMount} @@ -630,14 +659,19 @@ export default function EditorComponent({ currentQuery, historyQueries, setCurre +
+ {PLACEHOLDER} +
+ + ) +} \ No newline at end of file diff --git a/app/components/ForceGraph.tsx b/app/components/ForceGraph.tsx new file mode 100644 index 00000000..0380c22c --- /dev/null +++ b/app/components/ForceGraph.tsx @@ -0,0 +1,270 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable react/require-default-props */ +/* eslint-disable no-param-reassign */ + +"use client" + +import { Dispatch, RefObject, SetStateAction, useEffect, useRef, useState } from "react" +import ForceGraph2D from "react-force-graph-2d" +import { securedFetch } from "@/lib/utils" +import { useToast } from "@/components/ui/use-toast" +import { Graph, GraphData, Link, Node } from "../api/graph/model" + +interface Props { + graph: Graph + // eslint-disable-next-line @typescript-eslint/no-explicit-any + chartRef: RefObject + data: GraphData + selectedElement: Node | Link | undefined + setSelectedElement: (element: Node | Link | undefined) => void + selectedElements: (Node | Link)[] + setSelectedElements: Dispatch> + cooldownTicks: number | undefined + handleCooldown: (ticks?: number) => void + type?: "schema" | "graph" + isAddElement?: boolean + setSelectedNodes?: Dispatch> + isCollapsed: boolean +} + +const NODE_SIZE = 6 +const PADDING = 2; + +export default function ForceGraph({ + graph, + chartRef, + data, + selectedElement, + setSelectedElement, + selectedElements, + setSelectedElements, + cooldownTicks, + handleCooldown, + type = "graph", + isAddElement = false, + setSelectedNodes, + isCollapsed +}: Props) { + + const [parentWidth, setParentWidth] = useState(0) + const [parentHeight, setParentHeight] = useState(0) + const [hoverElement, setHoverElement] = useState() + const parentRef = useRef(null) + const toast = useToast() + + useEffect(() => { + if (!chartRef.current || data.nodes.length === 0 || data.links.length === 0) return + chartRef.current.d3Force('link').id((link: any) => link.id).distance(50) + chartRef.current.d3Force('charge').strength(-300) + chartRef.current.d3Force('center').strength(0.05) + }, [chartRef, data.links.length, data.nodes.length]) + + useEffect(() => { + if (!parentRef.current) return + setParentWidth(parentRef.current.clientWidth) + setParentHeight(parentRef.current.clientHeight) + }, [parentRef.current?.clientWidth, parentRef.current?.clientHeight, isCollapsed]) + + const onFetchNode = async (node: Node) => { + const result = await securedFetch(`/api/graph/${graph.Id}/${node.id}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + }, toast); + + if (result.ok) { + const json = await result.json() + return graph.extend(json.result, true) + } + + return [] + } + + const deleteNeighbors = (nodes: Node[]) => { + if (nodes.length === 0) return; + + graph.Elements = { + nodes: graph.Elements.nodes.map(node => { + const isTarget = graph.Elements.links.some(link => link.source.id === node.id && nodes.some(n => n.id === link.target.id)); + + if (!isTarget || !node.collapsed) return node + + if (node.expand) { + node.expand = false + deleteNeighbors([node]) + } + + graph.NodesMap.delete(Number(node.id)) + + return undefined + }).filter(node => node !== undefined), + links: graph.Elements.links + } + + graph.removeLinks() + } + + const handleNodeRightClick = async (node: Node) => { + if (!node.expand) { + await onFetchNode(node) + } else { + deleteNeighbors([node]) + } + } + + const handleHover = (element: Node | Link | null) => { + setHoverElement(element === null ? undefined : element) + } + + const handleClick = (element: Node | Link, evt: MouseEvent) => { + if (!("source" in element) && isAddElement) { + if (setSelectedNodes) { + setSelectedNodes(prev => { + if (prev[0] === undefined) { + return [element, undefined] + } + if (prev[1] === undefined) { + return [prev[0], element] + } + return [element, prev[0]] + }) + return + } + } + + setSelectedElement(element) + + if (evt.ctrlKey) { + if (selectedElements.includes(element)) { + setSelectedElements(selectedElements.filter((el) => el !== element)) + return + } + setSelectedElements([...selectedElements, element]) + } + + setSelectedElement(element) + } + + const handleUnselected = (evt?: MouseEvent) => { + if (evt?.ctrlKey || (!selectedElement && selectedElements.length === 0)) return + setSelectedElement(undefined) + setSelectedElements([]) + } + + return ( +
+ node.data.name || node.id.toString()} + graphData={data} + nodeRelSize={NODE_SIZE} + nodeCanvasObjectMode={() => 'after'} + linkCanvasObjectMode={() => 'after'} + linkWidth={(link) => (selectedElement && ("source" in selectedElement) && selectedElement.id === link.id + || hoverElement && ("source" in hoverElement) && hoverElement.id === link.id) ? 2 : 1} + nodeCanvasObject={(node, ctx) => { + if (!node.x || !node.y) return + + ctx.lineWidth = ((selectedElement && !("source" in selectedElement) && selectedElement.id === node.id) + || (hoverElement && !("source" in hoverElement) && hoverElement.id === node.id) + || (selectedElements.length > 0 && selectedElements.some(el => el.id === node.id && !("source" in el)))) ? 1 : 0.5 + ctx.strokeStyle = 'white'; + + ctx.beginPath(); + ctx.arc(node.x, node.y, NODE_SIZE, 0, 2 * Math.PI, false); + ctx.stroke(); + ctx.fill(); + + + ctx.fillStyle = 'black'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.font = '3px Arial'; + const ellipsis = '...'; + const ellipsisWidth = ctx.measureText(ellipsis).width; + const nodeSize = NODE_SIZE * 2 - PADDING; + + let name + + if (type === "graph") { + name = node.data.name || node.id.toString() + } else { + [name] = node.category + } + + // truncate text if it's too long + if (ctx.measureText(name).width > nodeSize) { + while (name.length > 0 && ctx.measureText(name).width + ellipsisWidth > nodeSize) { + name = name.slice(0, -1); + } + name += ellipsis; + } + + // add label + ctx.fillText(name, node.x, node.y); + }} + linkCanvasObject={(link, ctx) => { + const start = link.source; + const end = link.target; + + if (!start.x || !start.y || !end.x || !end.y) return + + ctx.strokeStyle = link.color; + ctx.globalAlpha = 0.5; + + if (start.id === end.id) { + const radius = NODE_SIZE * link.curve * 6.2; + const angleOffset = -Math.PI / 4; // 45 degrees offset for text alignment + const textX = start.x + radius * Math.cos(angleOffset); + const textY = start.y + radius * Math.sin(angleOffset); + + ctx.save(); + ctx.translate(textX, textY); + ctx.rotate(-angleOffset); + } else { + const midX = (start.x + end.x) / 2 + (end.y - start.y) * (link.curve / 2); + const midY = (start.y + end.y) / 2 + (start.x - end.x) * (link.curve / 2); + + let textAngle = Math.atan2(end.y - start.y, end.x - start.x) + + // maintain label vertical orientation for legibility + if (textAngle > Math.PI / 2) textAngle = -(Math.PI - textAngle); + if (textAngle < -Math.PI / 2) textAngle = -(-Math.PI - textAngle); + + ctx.save(); + ctx.translate(midX, midY); + ctx.rotate(textAngle); + } + + // add label + ctx.globalAlpha = 1; + ctx.fillStyle = 'black'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.font = "2px Arial" + ctx.fillText(link.label, 0, 0); + ctx.restore() + }} + onNodeClick={handleClick} + onLinkClick={handleClick} + onNodeHover={handleHover} + onLinkHover={handleHover} + onNodeRightClick={handleNodeRightClick} + onBackgroundClick={handleUnselected} + onBackgroundRightClick={handleUnselected} + onEngineStop={() => { + handleCooldown(0) + }} + linkCurvature="curve" + nodeVisibility="visible" + linkVisibility="visible" + cooldownTicks={cooldownTicks} + cooldownTime={2000} + /> +
+ ) +} \ No newline at end of file diff --git a/app/components/FormComponent.tsx b/app/components/FormComponent.tsx new file mode 100644 index 00000000..fd7af685 --- /dev/null +++ b/app/components/FormComponent.tsx @@ -0,0 +1,153 @@ +/* eslint-disable react/no-array-index-key */ +/* eslint-disable no-param-reassign */ + +"use client" + +import { useState } from "react" +import { AlertCircle, EyeIcon, EyeOffIcon } from "lucide-react" +import { cn } from "@/lib/utils" +import Button from "./ui/Button" +import Combobox from "./ui/combobox" +import Input from "./ui/Input" + +export type Error = { + message: string + condition: (value: string) => boolean +} + +export type Field = { + label: string + value: string + onChange?: (e: React.ChangeEvent) => void + type: string + info?: string + options?: string[] + onSelectedValue?: (value: string) => void + placeholder?: string + required: boolean + show?: boolean + description?: string + errors?: Error[] +} + +interface Props { + handleSubmit: (e: React.FormEvent) => void + fields: Field[] + error?: { + message: string + show: boolean + } + children?: React.ReactNode + submitButtonLabel?: string +} + +export default function FormComponent({ handleSubmit, fields, error = undefined, children = undefined, submitButtonLabel = "Submit" }: Props) { + const [show, setShow] = useState<{ [key: string]: boolean }>({}); + const [errors, setErrors] = useState<{ [key: string]: boolean }>({}); + + const onHandleSubmit = (e: React.FormEvent) => { + e.preventDefault() + + const newErrors: { [key: string]: boolean } = {} + + fields.forEach(field => { + if (field.errors) { + newErrors[field.label] = field.errors.some(err => err.condition(field.value)) + } + }) + + setErrors(newErrors) + + if (Object.values(newErrors).some(value => value)) return + + handleSubmit(e) + } + + return ( +
+ { + fields.map((field) => { + const passwordType = show[field.label] ? "text" : "password" + return ( +
+
+ + { + field.info && + + } +
+
+ { + field.type === "password" && + + } + { + field.type === "select" ? + + : { + field.onChange!(e) + if (field.errors) { + setErrors(prev => ({ + ...prev, + [field.label]: field.errors!.some(err => err.condition(e.target.value)) + })) + } + }} /> + } +

{field.description}

+ { + field.errors && errors[field.label] ? +

{field.errors.find((err) => err.condition(field.value))?.message}

+ :

+ } +

+
+ ) + }) + } + {children} + {error?.show &&

{error.message}

} +
+
+
+ ) +} + +FormComponent.defaultProps = { + children: undefined, + error: undefined, + submitButtonLabel: "Submit" +} \ No newline at end of file diff --git a/app/components/GoogleAnalytics.tsx b/app/components/GoogleAnalytics.tsx new file mode 100644 index 00000000..955ac32c --- /dev/null +++ b/app/components/GoogleAnalytics.tsx @@ -0,0 +1,24 @@ +import Script from "next/script"; + +function GoogleAnalytics({ ga_id }: { ga_id: string }) { + return <> +