ui refactor
This commit is contained in:
parent
9a5f2e24d5
commit
6c04009ac1
13 changed files with 88 additions and 60 deletions
|
|
@ -95,7 +95,8 @@ useEffect(() => {
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex z-50 flex-col-reverse fixed bottom-5 left-5">
|
<div></div>
|
||||||
|
<div className="flex z-40 flex-col-reverse fixed bottom-5 left-5">
|
||||||
{alertsList.map((alert) => (
|
{alertsList.map((alert) => (
|
||||||
<div key={alert.id}>
|
<div key={alert.id}>
|
||||||
{alert.type === "error" ? (
|
{alert.type === "error" ? (
|
||||||
|
|
@ -125,6 +126,7 @@ useEffect(() => {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<a target={"_blank"} href="https://logspace.ai/" className="absolute bottom-1 left-1 text-gray-500 text-xs cursor-pointer font-sans tracking-wide">Created by Logspace</a>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,21 +20,21 @@ export default function AlertDropdown({}: AlertDropdownType) {
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="z-10 py-2 pb-4 rounded-md bg-white ring-1 ring-black ring-opacity-5 shadow-lg focus:outline-none overflow-hidden w-[14rem] h-[28rem] flex flex-col">
|
<div className="z-10 py-3 pb-4 rounded-md bg-white ring-1 ring-black ring-opacity-5 shadow-lg focus:outline-none overflow-hidden w-[16rem] h-[28rem] flex flex-col">
|
||||||
<div className="flex pl-3 flex-row justify-between text-md font-medium text-gray-800">
|
<div className="flex pl-3 flex-row justify-between text-md font-medium text-gray-800">
|
||||||
Notifications
|
Notifications
|
||||||
<div className="flex gap-3 pr-2 ">
|
<div className="flex gap-2 pr-3 ">
|
||||||
<button
|
<button
|
||||||
className="hover:text-red-500"
|
className="hover:text-red-500"
|
||||||
onClick={() => {closePopUp(); setTimeout(clearNotificationList, 100)}}
|
onClick={() => {closePopUp(); setTimeout(clearNotificationList, 100)}}
|
||||||
>
|
>
|
||||||
<TrashIcon className="w-5 h-5" />
|
<TrashIcon className="w-[1.1rem] h-[1.1rem]" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="hover:text-red-500"
|
className="hover:text-red-500"
|
||||||
onClick={closePopUp}
|
onClick={closePopUp}
|
||||||
>
|
>
|
||||||
<XMarkIcon className="h-6 w-6" />
|
<XMarkIcon className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,12 @@ export default function ExtraSidebar() {
|
||||||
<>
|
<>
|
||||||
<aside
|
<aside
|
||||||
className={` ${
|
className={` ${
|
||||||
isStackedOpen ? "w-60" : "w-0 "
|
isStackedOpen ? "w-52" : "w-0 "
|
||||||
} flex-shrink-0 flex overflow-hidden flex-col border-r dark:border-r-gray-700 transition-all duration-500`}
|
} flex-shrink-0 flex overflow-hidden flex-col border-r dark:border-r-gray-700 transition-all duration-500`}
|
||||||
>
|
>
|
||||||
<div className="w-60 dark:bg-gray-800 border dark:border-gray-700 overflow-y-auto scrollbar-hide h-full flex flex-col items-start">
|
<div className="w-52 dark:bg-gray-800 border dark:border-gray-700 overflow-y-auto scrollbar-hide h-full flex flex-col items-start">
|
||||||
<div className="flex pt-1 px-4 justify-between align-middle w-full">
|
<div className="flex pt-1 px-4 justify-between align-middle w-full">
|
||||||
<span className="text-gray-900 dark:text-white text-lg ml-2 font-medium ">
|
<span className="text-gray-900 dark:text-white py-[2px] font-medium ">
|
||||||
{extraNavigation.title}
|
{extraNavigation.title}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { ChatBubbleLeftEllipsisIcon, ChatBubbleOvalLeftEllipsisIcon, PlusSmallIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ChatMessageType } from "../../../types/chat";
|
||||||
|
import { nodeColors } from "../../../utils";
|
||||||
|
|
||||||
|
export default function ChatMessage({ chat }: { chat: ChatMessageType }) {
|
||||||
|
const [hidden, setHidden] = useState(true);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{!chat.isSend ? (
|
||||||
|
<div className="w-full text-start">
|
||||||
|
<div
|
||||||
|
style={{ backgroundColor: nodeColors["chat"] }}
|
||||||
|
className=" relative text-start inline-block text-white rounded-xl overflow-hidden w-fit max-w-[280px] text-sm font-normal rounded-tl-none"
|
||||||
|
>
|
||||||
|
{hidden && chat.thought && chat.thought !== "" && (
|
||||||
|
<div
|
||||||
|
onClick={() => setHidden((prev) => !prev)}
|
||||||
|
className="absolute top-2 right-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<ChatBubbleOvalLeftEllipsisIcon className="w-5 h-5 animate-bounce" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{chat.thought && chat.thought !== "" && !hidden && (
|
||||||
|
<div
|
||||||
|
onClick={() => setHidden((prev) => !prev)}
|
||||||
|
style={{ backgroundColor: nodeColors["thought"] }}
|
||||||
|
className=" text-start inline-block w-full pb-3 pt-3 px-5 cursor-pointer"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: chat.thought.replace(/\n/g, "<br />"),
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
)}
|
||||||
|
{chat.thought && chat.thought !== "" && !hidden && <br></br>}
|
||||||
|
<div className="w-full rounded-b-md px-4 pb-3 pt-3 pr-8" style={{ backgroundColor: nodeColors["chat"] }}>
|
||||||
|
{chat.message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full text-end">
|
||||||
|
<div className="text-start inline-block rounded-xl p-3 overflow-hidden w-fit max-w-[280px] px-5 text-sm text-black dark:text-white dark:bg-gray-700 bg-gray-200 font-normal rounded-tr-none">
|
||||||
|
{chat.message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import { alertContext } from "../../contexts/alertContext";
|
||||||
import { classNames, nodeColors } from "../../utils";
|
import { classNames, nodeColors } from "../../utils";
|
||||||
import { TabsContext } from "../../contexts/tabsContext";
|
import { TabsContext } from "../../contexts/tabsContext";
|
||||||
import { ChatType } from "../../types/chat";
|
import { ChatType } from "../../types/chat";
|
||||||
|
import ChatMessage from "./chatMessage";
|
||||||
|
|
||||||
const _ = require("lodash");
|
const _ = require("lodash");
|
||||||
|
|
||||||
|
|
@ -25,12 +26,12 @@ export default function Chat({ flow, reactFlowInstance }: ChatType) {
|
||||||
const addChatHistory = (
|
const addChatHistory = (
|
||||||
message: string,
|
message: string,
|
||||||
isSend: boolean,
|
isSend: boolean,
|
||||||
thinking?: string
|
thought?: string
|
||||||
) => {
|
) => {
|
||||||
setChatHistory((old) => {
|
setChatHistory((old) => {
|
||||||
let newChat = _.cloneDeep(old);
|
let newChat = _.cloneDeep(old);
|
||||||
if (thinking) {
|
if (thought) {
|
||||||
newChat.push({ message, isSend, thinking });
|
newChat.push({ message, isSend, thought });
|
||||||
} else {
|
} else {
|
||||||
newChat.push({ message, isSend });
|
newChat.push({ message, isSend });
|
||||||
}
|
}
|
||||||
|
|
@ -88,7 +89,7 @@ export default function Chat({ flow, reactFlowInstance }: ChatType) {
|
||||||
sendAll({ ...reactFlowInstance.toObject(), message, chatHistory })
|
sendAll({ ...reactFlowInstance.toObject(), message, chatHistory })
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
console.log(r.data);
|
console.log(r.data);
|
||||||
addChatHistory(r.data.result, false);
|
addChatHistory(r.data.result, false,r.data.thought);
|
||||||
setLockChat(false);
|
setLockChat(false);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
|
@ -121,7 +122,7 @@ export default function Chat({ flow, reactFlowInstance }: ChatType) {
|
||||||
leaveFrom="translate-y-0"
|
leaveFrom="translate-y-0"
|
||||||
leaveTo="translate-y-96"
|
leaveTo="translate-y-96"
|
||||||
>
|
>
|
||||||
<div className="w-[340px] absolute bottom-0 right-6">
|
<div className="w-[340px] absolute bottom-0 right-1">
|
||||||
<div className="border dark:border-gray-700 h-full rounded-xl rounded-b-none bg-white dark:bg-gray-800 shadow">
|
<div className="border dark:border-gray-700 h-full rounded-xl rounded-b-none bg-white dark:bg-gray-800 shadow">
|
||||||
<div
|
<div
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
@ -139,33 +140,7 @@ export default function Chat({ flow, reactFlowInstance }: ChatType) {
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full h-[400px] flex gap-3 mb-auto overflow-y-auto scrollbar-hide flex-col bg-gray-50 dark:bg-gray-900 p-3 py-5">
|
<div className="w-full h-[400px] flex gap-3 mb-auto overflow-y-auto scrollbar-hide flex-col bg-gray-50 dark:bg-gray-900 p-3 py-5">
|
||||||
{chatHistory.map((c, i) => (
|
{chatHistory.map((c, i) => (
|
||||||
<div key={i}>
|
<ChatMessage chat={c} key={i}/>
|
||||||
{!c.isSend ? (
|
|
||||||
<div className="w-full text-start">
|
|
||||||
<div
|
|
||||||
style={{ backgroundColor: nodeColors["chat"] }}
|
|
||||||
className="text-start inline-block text-white rounded-xl p-3 overflow-hidden w-fit max-w-[280px] px-5 text-sm font-normal rounded-tl-none"
|
|
||||||
>
|
|
||||||
{(c.thinking && c.thinking!=="") && (
|
|
||||||
<div
|
|
||||||
style={{ backgroundColor: nodeColors["thought"] }}
|
|
||||||
className="rounded text-start inline-block w-full text-gray-700 mb-1"
|
|
||||||
>
|
|
||||||
teste
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(c.thinking && c.thinking!=="") && <br></br>}
|
|
||||||
{c.message}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="w-full text-end">
|
|
||||||
<div className="text-start inline-block rounded-xl p-3 overflow-hidden w-fit max-w-[280px] px-5 text-sm text-black dark:text-white dark:bg-gray-700 bg-gray-200 font-normal rounded-tr-none">
|
|
||||||
{c.message}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
<div ref={ref}></div>
|
<div ref={ref}></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -179,7 +154,7 @@ export default function Chat({ flow, reactFlowInstance }: ChatType) {
|
||||||
}}
|
}}
|
||||||
type="text"
|
type="text"
|
||||||
disabled={lockChat}
|
disabled={lockChat}
|
||||||
value={chatValue}
|
value={lockChat?"please wait for the response": chatValue}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setChatValue(e.target.value);
|
setChatValue(e.target.value);
|
||||||
}}
|
}}
|
||||||
|
|
@ -187,13 +162,13 @@ export default function Chat({ flow, reactFlowInstance }: ChatType) {
|
||||||
lockChat ? "bg-gray-500 text-white" : "dark:bg-gray-700",
|
lockChat ? "bg-gray-500 text-white" : "dark:bg-gray-700",
|
||||||
"form-input block w-full rounded-md border-gray-300 dark:border-gray-600 dark:text-white pr-10 sm:text-sm"
|
"form-input block w-full rounded-md border-gray-300 dark:border-gray-600 dark:text-white pr-10 sm:text-sm"
|
||||||
)}
|
)}
|
||||||
placeholder={lockChat?"please wait for the response":"Send a message..."}
|
placeholder={"Send a message..."}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
|
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
|
||||||
<button disabled={lockChat} onClick={() => sendMessage()}>
|
<button disabled={lockChat} onClick={() => sendMessage()}>
|
||||||
{lockChat ? (
|
{lockChat ? (
|
||||||
<LockClosedIcon
|
<LockClosedIcon
|
||||||
className="h-5 w-5 text-gray-400 dark:hover:text-gray-300"
|
className="h-5 w-5 text-gray-400 dark:hover:text-gray-300 animate-pulse"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -219,14 +194,14 @@ export default function Chat({ flow, reactFlowInstance }: ChatType) {
|
||||||
leaveFrom="translate-y-0"
|
leaveFrom="translate-y-0"
|
||||||
leaveTo="translate-y-96"
|
leaveTo="translate-y-96"
|
||||||
>
|
>
|
||||||
<div className="absolute bottom-0 right-6">
|
<div className="absolute bottom-0 right-1">
|
||||||
<div className="border flex justify-center align-center py-1 px-3 rounded-xl rounded-b-none bg-white dark:bg-gray-800 dark:border-gray-600 dark:text-white shadow">
|
<div className="border flex justify-center align-center py-1 px-3 rounded-xl rounded-b-none bg-white dark:bg-gray-800 dark:border-gray-600 dark:text-white shadow">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex gap-3 text-lg font-medium items-center">
|
<div className="flex gap-3 items-center">
|
||||||
<Bars3CenterLeftIcon
|
<Bars3CenterLeftIcon
|
||||||
className="h-6 w-6 mt-1"
|
className="h-6 w-6 mt-1"
|
||||||
style={{ color: nodeColors["chat"] }}
|
style={{ color: nodeColors["chat"] }}
|
||||||
|
|
|
||||||
|
|
@ -59,17 +59,17 @@ export default function TextAreaModal({value, setValue}:{setValue:(value:string)
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full w-full flex flex-col justify-center items-center">
|
<div className="h-full w-full flex flex-col justify-center items-center">
|
||||||
<div className="flex w-full pb-6 z-10 justify-center shadow-sm">
|
<div className="flex w-full pb-4 z-10 justify-center shadow-sm">
|
||||||
<div className="mx-auto mt-8 flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 dark:bg-gray-900 sm:mx-0 sm:h-10 sm:w-10">
|
<div className="mx-auto mt-4 flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 dark:bg-gray-900 sm:mx-0 sm:h-10 sm:w-10">
|
||||||
<ClipboardDocumentListIcon
|
<ClipboardDocumentListIcon
|
||||||
className="h-6 w-6 text-blue-600"
|
className="h-6 w-6 text-blue-600"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-10 text-center sm:ml-4 sm:text-left">
|
<div className="mt-4 text-center sm:ml-4 sm:text-left">
|
||||||
<Dialog.Title
|
<Dialog.Title
|
||||||
as="h3"
|
as="h3"
|
||||||
className="text-lg font-medium dark:text-white leading-6 text-gray-900"
|
className="text-lg font-medium dark:text-white leading-10 text-gray-900"
|
||||||
>
|
>
|
||||||
Edit text
|
Edit text
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import {
|
import {
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/solid";
|
||||||
import { Disclosure } from "@headlessui/react";
|
import { Disclosure } from "@headlessui/react";
|
||||||
import { DisclosureComponentType } from "../../../../types/components";
|
import { DisclosureComponentType } from "../../../../types/components";
|
||||||
|
|
||||||
|
|
@ -31,7 +31,7 @@ export default function DisclosureComponent({
|
||||||
<ChevronRightIcon
|
<ChevronRightIcon
|
||||||
className={`${
|
className={`${
|
||||||
open ? "rotate-90 transform" : ""
|
open ? "rotate-90 transform" : ""
|
||||||
} h-5 w-5 text-gray-800 dark:text-white`}
|
} h-4 w-4 text-gray-800 dark:text-white`}
|
||||||
/>
|
/>
|
||||||
</Disclosure.Button>
|
</Disclosure.Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -71,9 +71,9 @@ export default function ExtraSidebar() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex w-full justify-between text-sm px-4 py-3 items-center border-dashed border-gray-400 dark:border-gray-600 border-l-0 rounded-md rounded-l-none border-2">
|
<div className="flex w-full justify-between text-sm px-3 py-1 items-center border-dashed border-gray-400 dark:border-gray-600 border-l-0 rounded-md rounded-l-none border">
|
||||||
<span className="text-black dark:text-white w-36 truncate">{t}</span>
|
<span className="text-black dark:text-white w-36 truncate text-xs">{t}</span>
|
||||||
<Bars2Icon className="w-6 h-6 text-gray-400 dark:text-gray-600" />
|
<Bars2Icon className="w-4 h-6 text-gray-400 dark:text-gray-600" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import { alertContext } from "../../../../contexts/alertContext";
|
||||||
export default function TabsManagerComponent() {
|
export default function TabsManagerComponent() {
|
||||||
const { flows, addFlow, tabIndex, setTabIndex } = useContext(TabsContext);
|
const { flows, addFlow, tabIndex, setTabIndex } = useContext(TabsContext);
|
||||||
const { openPopUp } = useContext(PopUpContext);
|
const { openPopUp } = useContext(PopUpContext);
|
||||||
const AlertWidth = 224
|
const AlertWidth = 256
|
||||||
const { dark, setDark } = useContext(darkContext);
|
const { dark, setDark } = useContext(darkContext);
|
||||||
const {notificationCenter, setNotificationCenter} = useContext(alertContext)
|
const {notificationCenter, setNotificationCenter} = useContext(alertContext)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ export default function FlowPage({ flow }:{flow:FlowType}) {
|
||||||
setNodes(flow?.data?.nodes ?? []);
|
setNodes(flow?.data?.nodes ?? []);
|
||||||
setEdges(flow?.data?.edges ?? []);
|
setEdges(flow?.data?.edges ?? []);
|
||||||
if (reactFlowInstance) {
|
if (reactFlowInstance) {
|
||||||
setViewport(flow?.data?.viewport ?? { x: 1, y: 0, zoom: 1 });
|
setViewport(flow?.data?.viewport ?? { x: 1, y: 0, zoom: 0.5 });
|
||||||
}
|
}
|
||||||
}, [flow, reactFlowInstance, setEdges, setNodes, setViewport]);
|
}, [flow, reactFlowInstance, setEdges, setNodes, setViewport]);
|
||||||
//set extra sidebar
|
//set extra sidebar
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,4 @@ import { ReactFlowInstance } from 'reactflow';
|
||||||
import { FlowType } from "../flow";
|
import { FlowType } from "../flow";
|
||||||
|
|
||||||
export type ChatType = {flow:FlowType,reactFlowInstance:ReactFlowInstance}
|
export type ChatType = {flow:FlowType,reactFlowInstance:ReactFlowInstance}
|
||||||
|
export type ChatMessageType = { message: string; isSend: boolean, thought?:string }
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { ChatMessageType } from './../chat/index';
|
||||||
import { APIClassType } from '../api/index';
|
import { APIClassType } from '../api/index';
|
||||||
import { ReactFlowJsonObject, XYPosition } from "reactflow";
|
import { ReactFlowJsonObject, XYPosition } from "reactflow";
|
||||||
|
|
||||||
|
|
@ -5,7 +6,7 @@ export type FlowType = {
|
||||||
name: string;
|
name: string;
|
||||||
id: string;
|
id: string;
|
||||||
data: ReactFlowJsonObject;
|
data: ReactFlowJsonObject;
|
||||||
chat: Array<{ message: string; isSend: boolean, thinking?:string }>;
|
chat: Array<ChatMessageType>;
|
||||||
};
|
};
|
||||||
export type NodeType = {id:string,type:string,position:XYPosition,data:NodeDataType}
|
export type NodeType = {id:string,type:string,position:XYPosition,data:NodeDataType}
|
||||||
export type NodeDataType = {type:string,node?:APIClassType,id:string,value:any}
|
export type NodeDataType = {type:string,node?:APIClassType,id:string,value:any}
|
||||||
|
|
@ -68,8 +68,8 @@ export const nodeColors: {[char: string]: string} = {
|
||||||
tools: "#FF3434",
|
tools: "#FF3434",
|
||||||
memories: "#FF9135",
|
memories: "#FF9135",
|
||||||
advanced: "#000000",
|
advanced: "#000000",
|
||||||
chat: "#2563EB",
|
chat: "#454173",
|
||||||
thought:"#7EC1FF"
|
thought:"#272541"
|
||||||
};
|
};
|
||||||
|
|
||||||
export const nodeNames:{[char: string]: string} = {
|
export const nodeNames:{[char: string]: string} = {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue