feat: context menu on nodes

This commit is contained in:
dextmorgn
2025-06-20 11:37:21 +02:00
parent 43148cdf3d
commit 6cdc01050b
3 changed files with 1035 additions and 0 deletions
@@ -0,0 +1,640 @@
import {
AtSign,
Bike,
Bitcoin,
Building2,
Car,
Facebook,
Ghost,
Github,
Globe,
Instagram,
Linkedin,
Locate,
MapPin,
MessageCircle,
MessageSquare,
Phone,
Plane,
Sailboat,
Send,
Twitch,
Twitter,
User,
UserCheckIcon,
Video,
type LucideIcon,
} from "lucide-react"
export type FieldType = "text" | "date" | "email" | "number" | "select" | "textarea" | "hidden" | "tel" | "url" | "metadata"
export interface SelectOption {
label: string
value: string
}
export interface FormField {
name: string
label: string
type: FieldType
required: boolean
options?: SelectOption[]
placeholder?: string
description?: string
}
export interface ActionItem {
id: number
type: string
table: string
key: string
icon: LucideIcon
label: string
color?: string
fields: FormField[]
size?: string
label_key: string
disabled?: boolean
comingSoon?: boolean
children?: ActionItem[]
}
const countries = [
{ label: "France", value: "france" },
{ label: "United States", value: "usa" },
{ label: "United Kingdom", value: "uk" },
{ label: "Germany", value: "germany" },
{ label: "Japan", value: "japan" },
{ label: "Canada", value: "canada" },
{ label: "Australia", value: "australia" },
]
export const actionItems: ActionItem[] = [
{
id: 100,
type: "person",
table: "",
key: "person",
icon: User,
label: "Person",
label_key: "name",
fields: [],
children: [
{
id: 1,
type: "individual",
table: "individuals",
key: "individual",
label_key: "first_name",
icon: User,
label: "Individual",
fields: [{ name: "first_name", label: "Firstname", type: "text", required: true },
{ name: "last_name", label: "Lastname", type: "text", required: true },
{ name: "birth_date", label: "Birth date", type: "date", required: false },
{
name: "gender",
label: "Gender",
type: "select",
required: false,
options: [
{ label: "Male", value: "male" },
{ label: "Female", value: "female" },
{ label: "Non-binary", value: "non-binary" },
{ label: "Prefer not to say", value: "not_specified" },
],
},
],
size: "h-7 w-7",
},
{
id: 512932,
type: "username",
table: "usernames",
key: "username",
icon: UserCheckIcon,
label: "Username",
label_key: "username",
fields: [{ name: "username", label: "Username", type: "text", required: true }],
size: "h-5 w-5",
},
],
},
{
id: 101,
type: "organization",
table: "",
key: "organization_category",
icon: Building2,
label: "Organization",
label_key: "name",
fields: [],
children: [
{
id: 24,
type: "organization",
table: "organizations",
key: "organization",
icon: Building2,
label: "Organization",
label_key: "name",
fields: [
{ name: "name", label: "Name", type: "text", required: true },
{ name: "founding_date", label: "Founding date", type: "date", required: false },
{
name: "country",
label: "Country",
type: "select",
required: false,
options: countries,
},
{
name: "metadata",
label: "Metadata",
type: "metadata",
required: false,
description: "Add additional information under the format key:value"
}
],
size: "h-8 w-8",
},
],
},
{
id: 102,
type: "contact",
table: "",
key: "contact",
icon: Phone,
label: "Contact",
label_key: "value",
fields: [],
children: [
{
id: 2,
type: "phone",
table: "phones",
key: "phone",
label_key: "number",
icon: Phone,
label: "Phone number",
fields: [{ name: "number", label: "Phone number", type: "tel", required: true },
{
name: "country",
label: "Country",
type: "select",
required: false,
options: countries,
},
{ name: "carrier", label: "Carrier", type: "text", required: false }
],
size: "h-5 w-5",
},
{
id: 4,
type: "email",
table: "emails",
key: "email",
icon: AtSign,
label_key: "email",
label: "Email address",
fields: [{ name: "email", label: "Email", type: "email", required: true }],
size: "h-5 w-5",
},
],
},
{
id: 103,
type: "digital",
table: "",
key: "digital",
icon: Globe,
label: "Digital",
label_key: "value",
fields: [],
children: [
{
id: 25,
type: "website",
table: "websites",
key: "website",
label_key: "url",
icon: Globe,
label: "Website",
fields: [
{ name: "url", label: "URL", type: "url", required: true },
{ name: "registration_date", label: "Registration date", type: "date", required: false },
{ name: "registrar", label: "Registrar", type: "text", required: false },
{ name: "address", label: "IP address", type: "text", required: false },
],
size: "h-5 w-5",
},
{
id: 259,
type: "domain",
table: "domains",
key: "domain",
icon: Locate,
label_key: "domain",
label: "Domain",
fields: [{ name: "domain", label: "Domain", type: "text", required: true }],
size: "h-5 w-5",
},
{
id: 2588,
type: "subdomain",
table: "subdomains",
key: "subdomain",
label_key: "subomain",
icon: Locate,
label: "Subdomain",
fields: [{ name: "subomain", label: "subdomain", type: "text", required: true }, { name: "root", label: "root domain", type: "text", required: true }],
size: "h-5 w-5",
},
{
id: 5,
type: "ip",
table: "ips",
key: "ip",
icon: Locate,
label: "IP address",
label_key: "address",
fields: [{ name: "address", label: "IP address", type: "text", required: true }],
size: "h-5 w-5",
},
],
},
{
id: 104,
type: "finance",
table: "",
key: "finance",
icon: Bitcoin,
label: "Finance",
label_key: "value",
fields: [],
children: [
{
id: 215,
type: "wallet",
table: "wallets",
key: "wallet",
label_key: "wallet",
icon: Bitcoin,
label: "Crypto wallet",
fields: [
{ name: "wallet", label: "Wallet", type: "text", required: true },
{
name: "type",
label: "Wallet type",
type: "select",
required: true,
options: [
{ label: "ETH", value: "eth" },
{ label: "BTC", value: "btc" },
{ label: "TRC", value: "trc" },],
},
],
size: "h-5 w-5",
},
],
},
{
id: 105,
type: "location",
table: "",
key: "location",
icon: MapPin,
label: "Location",
label_key: "address",
fields: [],
children: [
{
id: 3,
type: "address",
table: "addresses",
label_key: "address",
key: "address",
icon: MapPin,
label: "Physical address",
fields: [
{ name: "address", label: "Address", type: "text", required: true },
{ name: "city", label: "City", type: "text", required: true },
{ name: "country", label: "Country", type: "text", required: true },
{ name: "zip", label: "ZIP/Postal code", type: "text", required: false },
{ name: "latitude", label: "Latitude", type: "text", required: false },
{ name: "longitude", label: "Longitude", type: "text", required: false },
],
size: "h-5 w-5",
},
],
},
{
id: 6,
type: "social_profile",
size: "h-5 w-5",
table: "",
key: "social_profile",
icon: Send,
label: "Social account",
label_key: "username",
fields: [],
children: [
{
id: 7,
type: "social_profile",
table: "social_profiles",
key: "social_profiles_facebook",
label_key: "username",
icon: Facebook,
color: "#1d4ed8",
label: "Facebook",
fields: [
{ name: "profile_url", label: "Profile URL", type: "url", required: true },
{ name: "username", label: "Username", type: "text", required: false },
{ name: "platform", label: "Platform", type: "hidden", required: true },
],
size: "h-5 w-5",
},
{
id: 8,
type: "social_profile",
table: "social_profiles",
key: "social_profiles_instagram",
label_key: "username",
icon: Instagram,
color: "#db2777",
label: "Instagram",
fields: [
{ name: "profile_url", label: "Profile URL", type: "url", required: true },
{ name: "username", label: "Username", type: "text", required: false },
{ name: "platform", label: "Platform", type: "hidden", required: true },
],
size: "h-5 w-5",
},
{
id: 9,
type: "social_profile",
table: "social_profiles",
key: "social_profiles_telegram",
label_key: "username",
icon: Send,
color: "#0369a1",
label: "Telegram",
fields: [
{ name: "profile_url", label: "Profile URL", type: "url", required: true },
{ name: "username", label: "Username", type: "text", required: false },
{ name: "platform", label: "Platform", type: "hidden", required: true },
],
size: "h-5 w-5",
},
{
id: 10,
type: "social_profile",
table: "social_profiles",
key: "social_profiles_signal",
label_key: "username",
icon: MessageCircle,
color: "#3B45FC",
label: "Signal",
fields: [
{ name: "profile_url", label: "Profile URL", type: "url", required: true },
{ name: "username", label: "Username", type: "text", required: false },
{ name: "platform", label: "Platform", type: "hidden", required: true },
],
size: "h-5 w-5",
},
{
id: 11,
type: "social_profile",
table: "social_profiles",
key: "social_profiles_snapchat",
label_key: "username",
color: "#FEFC00",
icon: Ghost,
label: "Snapchat",
fields: [
{ name: "profile_url", label: "Profile URL", type: "url", required: true },
{ name: "username", label: "Username", type: "text", required: false },
{ name: "platform", label: "Platform", type: "hidden", required: true },
],
size: "h-5 w-5",
},
{
id: 12,
type: "social_profile",
table: "social_profiles",
key: "social_profiles_github",
label_key: "username",
icon: Github,
label: "Github",
fields: [
{ name: "profile_url", label: "Profile URL", type: "url", required: false },
{ name: "username", label: "Username", type: "text", required: true },
{ name: "platform", label: "Platform", type: "hidden", required: true },
],
size: "h-5 w-5",
},
{
id: 13,
type: "social_profile",
table: "social_profiles",
key: "social_profiles_coco",
label_key: "username",
icon: Send,
label: "Coco",
fields: [
{ name: "profile_url", label: "Profile URL", type: "url", required: true },
{ name: "username", label: "Username", type: "text", required: false },
{ name: "platform", label: "Platform", type: "hidden", required: true },
],
disabled: true,
comingSoon: true,
size: "h-5 w-5",
},
{
id: 18,
type: "social_profile",
table: "social_profiles",
key: "social_profiles_linkedin",
label_key: "username",
color: "#116AC9",
icon: Linkedin,
label: "LinkedIn",
fields: [
{ name: "profile_url", label: "Profile URL", type: "url", required: true },
{ name: "username", label: "Username", type: "text", required: false },
{ name: "platform", label: "Platform", type: "hidden", required: true },
],
size: "h-5 w-5",
},
{
id: 19,
type: "social_profile",
table: "social_profiles",
key: "social_profiles_twitter",
label_key: "username",
icon: Twitter,
label: "Twitter",
fields: [
{ name: "profile_url", label: "Profile URL", type: "url", required: true },
{ name: "username", label: "Username", type: "text", required: false },
{ name: "platform", label: "Platform", type: "hidden", required: true },
],
size: "h-5 w-5",
},
{
id: 20,
type: "social_profile",
table: "social_profiles",
key: "social_profiles_tiktok",
label_key: "username",
icon: Video,
label: "TikTok",
fields: [
{ name: "profile_url", label: "Profile URL", type: "url", required: true },
{ name: "username", label: "Username", type: "text", required: false },
{ name: "platform", label: "Platform", type: "hidden", required: true },
],
size: "h-5 w-5",
},
{
id: 21,
type: "social_profile",
table: "social_profiles",
key: "social_profiles_reddit",
label_key: "username",
icon: MessageSquare,
color: "#FF4B13",
label: "Reddit",
fields: [
{ name: "profile_url", label: "Profile URL", type: "url", required: true },
{ name: "username", label: "Username", type: "text", required: false },
{ name: "platform", label: "Platform", type: "hidden", required: true },
],
size: "h-5 w-5",
},
{
id: 22,
type: "social_profile",
table: "social_profiles",
key: "social_profiles_discord",
label_key: "username",
icon: MessageCircle,
color: "#525FEE",
label: "Discord",
fields: [
{ name: "profile_url", label: "Profile URL", type: "url", required: true },
{ name: "username", label: "Username", type: "text", required: false },
{ name: "platform", label: "Platform", type: "hidden", required: true },
],
size: "h-5 w-5",
},
{
id: 2343,
type: "social_profile",
table: "social_profiles",
key: "social_profiles_twitch",
label_key: "username",
icon: Twitch,
color: "#A96FFF",
label: "Twitch",
fields: [
{ name: "profile_url", label: "Profile URL", type: "url", required: true },
{ name: "username", label: "Username", type: "text", required: false },
{ name: "platform", label: "Platform", type: "hidden", required: true },
],
size: "h-5 w-5",
},
],
},
{
id: 14,
type: "vehicle",
table: "",
icon: Car,
label: "Vehicle",
label_key: "plate",
key: "vehicle",
fields: [],
children: [
{
id: 15,
type: "vehicle",
table: "vehicles",
key: "vehicles_car",
icon: Car,
label: "Car",
label_key: "plate",
fields: [
{ name: "plate", label: "License plate", type: "text", required: true },
{ name: "model", label: "Model", type: "text", required: false },
{ name: "brand", label: "Brand", type: "text", required: false },
{ name: "year", label: "Year", type: "number", required: false },
{ name: "type", label: "Type", type: "hidden", required: true },
],
size: "h-5 w-5",
},
{
id: 16,
type: "vehicle",
table: "vehicles",
key: "vehicles_motorcycle",
label_key: "plate",
icon: Bike,
label: "Motorcycle",
fields: [
{ name: "plate", label: "License plate", type: "text", required: true },
{ name: "model", label: "Model", type: "text", required: false },
{ name: "brand", label: "Brand", type: "text", required: false },
{ name: "year", label: "Year", type: "number", required: false },
{ name: "type", label: "Type", type: "hidden", required: true },
],
size: "h-5 w-5",
},
{
id: 17,
type: "vehicle",
table: "vehicles",
key: "vehicles_boat",
label_key: "plate",
icon: Sailboat,
label: "Boat",
fields: [
{ name: "plate", label: "Registration number", type: "text", required: true },
{ name: "model", label: "Model", type: "text", required: false },
{ name: "brand", label: "Brand", type: "text", required: false },
{ name: "year", label: "Year", type: "number", required: false },
{ name: "type", label: "Type", type: "hidden", required: true },
],
size: "h-5 w-5",
},
{
id: 23,
type: "vehicle",
table: "vehicles",
key: "vehicles_aircraft",
icon: Plane,
label_key: "plate",
label: "Aircraft",
fields: [
{ name: "registration", label: "Registration", type: "text", required: true },
{ name: "model", label: "Model", type: "text", required: false },
{ name: "manufacturer", label: "Manufacturer", type: "text", required: false },
{ name: "year", label: "Year", type: "number", required: false },
{ name: "type", label: "Type", type: "hidden", required: true },
],
size: "h-5 w-5",
},
],
},
]
export function findActionItemByKey(key: string): ActionItem | undefined {
for (const item of actionItems) {
if (item.key === key) return item
if (item.children) {
const found = item.children.find(child => child.key === key)
if (found) return found
}
}
return undefined;
}
@@ -0,0 +1,31 @@
import { useAuthStore } from '@/stores/auth-store'
import { redirect } from '@tanstack/react-router'
import type { User } from '@/stores/auth-store'
export function requireAuth(locationHref: string) {
const isAuth = useAuthStore.getState().isAuthenticated
const token = useAuthStore.getState().token
if (!isAuth || !token) {
throw redirect({
to: '/login',
search: {
redirect: locationHref,
},
})
}
}
export function isAuthenticated(): boolean {
const isAuth = useAuthStore.getState().isAuthenticated
const token = useAuthStore.getState().token
return Boolean(isAuth && token)
}
export function getAuthToken(): string | null {
return useAuthStore.getState().token
}
export function getUser(): User | null {
return useAuthStore.getState().user
}
+364
View File
@@ -0,0 +1,364 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import Dagre from '@dagrejs/dagre';
import { actionItems } from "./action-items"
import type { ActionItem, FormField } from "./action-items"
import { type Edge, Position, type Node } from '@xyflow/react';
import * as d3 from "d3-force"
interface NodePosition {
x: number;
y: number;
}
interface NodeMeasured {
width: number;
height: number;
}
interface NodeInternals {
positionAbsolute: NodePosition;
}
interface FlowNode {
measured: NodeMeasured;
internals: NodeInternals;
}
interface IntersectionPoint {
x: number;
y: number;
}
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export const zoomSelector = (s: { transform: number[]; }) => s.transform[2] >= 0.6;
// this helper function returns the intersection point
// of the line between the center of the intersectionNode and the target node
function getNodeIntersection(
intersectionNode: FlowNode,
targetNode: FlowNode
): IntersectionPoint {
const { width: intersectionNodeWidth, height: intersectionNodeHeight } = intersectionNode.measured;
const intersectionNodePosition = intersectionNode.internals.positionAbsolute;
const targetPosition = targetNode.internals.positionAbsolute;
const w = intersectionNodeWidth / 2;
const h = intersectionNodeHeight / 2;
const x2 = intersectionNodePosition.x + w;
const y2 = intersectionNodePosition.y + h;
const x1 = targetPosition.x + targetNode.measured.width / 2;
const y1 = targetPosition.y + targetNode.measured.height / 2;
const xx1 = (x1 - x2) / (2 * w) - (y1 - y2) / (2 * h);
const yy1 = (x1 - x2) / (2 * w) + (y1 - y2) / (2 * h);
const a = 1 / (Math.abs(xx1) + Math.abs(yy1));
const xx3 = a * xx1;
const yy3 = a * yy1;
const x = w * (xx3 + yy3) + x2;
const y = h * (-xx3 + yy3) + y2;
return { x, y };
}
// returns the position (top,right,bottom or right) passed node compared to the intersection point
function getEdgePosition(node: FlowNode, intersectionPoint: IntersectionPoint): Position {
const n = { ...node.internals.positionAbsolute, ...node };
const nx = Math.round(n.x);
const ny = Math.round(n.y);
const px = Math.round(intersectionPoint.x);
const py = Math.round(intersectionPoint.y);
if (px <= nx + 1) {
return Position.Left;
}
if (px >= nx + n.measured.width - 1) {
return Position.Right;
}
if (py <= ny + 1) {
return Position.Top;
}
if (py >= n.y + n.measured.height - 1) {
return Position.Bottom;
}
return Position.Top;
}
// returns the parameters (sx, sy, tx, ty, sourcePos, targetPos) you need to create an edge
interface EdgeParams {
sx: number;
sy: number;
tx: number;
ty: number;
sourcePos: Position;
targetPos: Position;
}
export function getEdgeParams(source: FlowNode, target: FlowNode): EdgeParams {
const sourceIntersectionPoint = getNodeIntersection(source, target);
const targetIntersectionPoint = getNodeIntersection(target, source);
const sourcePos = getEdgePosition(source, sourceIntersectionPoint);
const targetPos = getEdgePosition(target, targetIntersectionPoint);
return {
sx: sourceIntersectionPoint.x,
sy: sourceIntersectionPoint.y,
tx: targetIntersectionPoint.x,
ty: targetIntersectionPoint.y,
sourcePos,
targetPos,
};
}
interface LayoutOptions {
direction?: "LR" | "TB";
strength?: number;
distance?: number;
iterations?: number;
}
export const getForceLayoutedElements = (
nodes: Node[],
edges: Edge[],
options: LayoutOptions = {
direction: "LR",
strength: -300,
distance: 100,
iterations: 300,
},
) => {
// Create a map of node IDs to indices for the simulation
const nodeMap = new Map(nodes.map((node, i) => [node.id, i]))
// Create a copy of nodes with positions for the simulation
const nodesCopy = nodes.map((node) => ({
...node,
x: node.position?.x || Math.random() * 500,
y: node.position?.y || Math.random() * 500,
width: node.measured?.width || 0,
height: node.measured?.height || 0,
}))
// Create links for the simulation using indices
const links = edges.map((edge) => ({
source: nodeMap.get(edge.source),
target: nodeMap.get(edge.target),
original: edge,
}))
// Create the simulation
const simulation = d3
.forceSimulation(nodesCopy)
.force(
"link",
d3.forceLink(links).id((d: any) => nodeMap.get(d.id)),
)
.force("charge", d3.forceManyBody().strength(options.strength || -300))
.force("center", d3.forceCenter(250, 250))
.force(
"collision",
d3.forceCollide().radius((d: any) => Math.max(d.width, d.height) / 2 + 10),
)
// If direction is horizontal, adjust forces
if (options.direction === "LR") {
simulation.force("x", d3.forceX(250).strength(0.1))
simulation.force("y", d3.forceY(250).strength(0.05))
} else {
simulation.force("x", d3.forceX(250).strength(0.05))
simulation.force("y", d3.forceY(250).strength(0.1))
}
// Run the simulation synchronously
simulation.stop()
for (let i = 0; i < (options.iterations || 300); i++) {
simulation.tick()
}
// Update node positions based on simulation results
const updatedNodes = nodesCopy.map((node) => ({
...node,
position: {
x: node.x - node.width / 2,
y: node.y - node.height / 2,
},
}))
return {
nodes: updatedNodes,
edges,
}
}
export const getDagreLayoutedElements = (nodes: Node[],
edges: Edge[],
options: LayoutOptions = {
direction: "TB",
strength: -300,
distance: 100,
iterations: 300,
},) => {
const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir: options.direction });
edges.forEach((edge) => g.setEdge(edge.source, edge.target));
nodes.forEach((node) =>
g.setNode(node.id, {
...node,
width: node.measured?.width ?? 0,
height: node.measured?.height ?? 0,
}),
);
Dagre.layout(g);
return {
nodes: nodes.map((node) => {
const position = g.node(node.id);
// We are shifting the dagre node position (anchor=center center) to the top left
// so it matches the React Flow node anchor point (top left).
const x = position.x - (node.measured?.width ?? 0) / 2;
const y = position.y - (node.measured?.height ?? 0) / 2;
return { ...node, position: { x, y } };
}),
edges,
};
};
export const sanitize = (name: string) => {
return name
.normalize("NFKD") // Decompose special characters (e.g., accents)
.replace(/[\u0300-\u036f]/g, "") // Remove accent marks
.replace(/[^a-zA-Z0-9.\-_]/g, "_") // Replace invalid characters with underscores
.replace(/_{2,}/g, "_") // Replace multiple underscores with a single one
.replace(/^_|_$/g, "") // Trim leading or trailing underscores
.toLowerCase(); // Convert to lowercase for consistency
};
export const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + " B"
else if (bytes < 1048576) return (bytes / 1024).toFixed(1) + " KB"
else return (bytes / 1048576).toFixed(1) + " MB"
}
function convertFieldToSimpleFormat(field: FormField): string {
if ((field.type === "hidden" && field.name === "platform") || field.name === "type") {
const defaultValue = field.options?.[0]?.value || ""
if (defaultValue) {
return `${field.name}:${defaultValue}`
}
}
return field.name
}
export function generateNodeTypes() {
const nodeTypes: Record<string, { table: string; type: string; fields: string[] }> = {}
function processItems(items: ActionItem[]) {
items.forEach((item) => {
if (item.table && !item.disabled) {
nodeTypes[item.key] = {
table: item.table,
type: item.type,
fields: item.fields.map(convertFieldToSimpleFormat),
}
}
// Traiter les enfants récursivement
if (item.children && item.children.length > 0) {
processItems(item.children)
}
})
}
processItems(actionItems)
return nodeTypes
}
export const nodesTypes = generateNodeTypes()
export const getInitials = (name: string) => {
return name
.split(" ")
.map((part) => part[0])
.join("")
.toUpperCase()
.substring(0, 2)
}
export function getAvatarColor(name: string): string {
const colors = [
"bg-red-500",
"bg-blue-500",
"bg-green-500",
"bg-yellow-500",
"bg-purple-500",
"bg-pink-500",
"bg-indigo-500",
"bg-teal-500",
"bg-orange-500",
"bg-cyan-500",
]
let hash = 0
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash)
}
const index = Math.abs(hash) % colors.length
return colors[index]
}
export const flattenObj = (ob: Record<string, any>) => {
let result: Record<string, any> = {};
for (const i in ob) {
if (ob[i] && typeof ob[i] === 'object' && !Array.isArray(ob[i])) {
const temp = flattenObj(ob[i]);
for (const j in temp) {
result[i + '.' + j] = temp[j];
}
}
else {
result[i] = ob[i];
}
}
return result;
}
interface FlattenableNode {
[key: string]: unknown;
children?: FlattenableNode[];
}
export function flattenArray<T extends FlattenableNode>(nodes: T[], key: string): Omit<T, typeof key>[] {
let result: Omit<T, typeof key>[] = [];
for (const node of nodes) {
const { [key]: children, ...rest } = node;
result.push(rest as Omit<T, typeof key>);
if (children) {
result = result.concat(flattenArray(children as T[], key));
}
}
return result;
}
export function hexToRgba(hex: string, alpha: number) {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}