mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fae360a9f7 | ||
|
|
5b3c147eb1 | ||
|
|
15a5f6bf3d | ||
|
|
9701e6503b | ||
|
|
0841caecbb | ||
|
|
c7846760d1 | ||
|
|
8c283b6ef9 | ||
|
|
34ae3b4da6 | ||
|
|
98f6615074 | ||
|
|
aff2365ef7 | ||
|
|
45058a24f8 | ||
|
|
7141c8434d | ||
|
|
7e15008760 | ||
|
|
bad057d415 | ||
|
|
e7d470e3fc | ||
|
|
4d846e2c94 | ||
|
|
b705485981 | ||
|
|
f4acdba1ef | ||
|
|
15fb6e0b05 | ||
|
|
55c5525626 | ||
|
|
c0c1f4688e | ||
|
|
b5a8f751ba | ||
|
|
10a8e7b745 | ||
|
|
60e8394010 | ||
|
|
9420214059 | ||
|
|
b949f60afe | ||
|
|
d498e4cc25 | ||
|
|
130dc0c32c | ||
|
|
f5824d6ddb | ||
|
|
829395f908 |
No files matched your search
@@ -0,0 +1,12 @@
|
||||
## Issue ticket number and link
|
||||
|
||||
## Documentation
|
||||
Select exactly one:
|
||||
|
||||
- [ ] I added/updated documentation for this change
|
||||
- [ ] Documentation is **not needed** for this change (explain why)
|
||||
|
||||
### Docs PR URL (required if "docs added" is checked)
|
||||
Paste the PR link from https://github.com/netbirdio/docs here:
|
||||
|
||||
https://github.com/netbirdio/docs/pull/__
|
||||
@@ -0,0 +1,105 @@
|
||||
name: Docs Acknowledgement
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
docs-ack:
|
||||
name: Require docs PR URL or explicit "not needed"
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Read PR body
|
||||
id: body
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BODY_B64=$(jq -r '.pull_request.body // "" | @base64' "$GITHUB_EVENT_PATH")
|
||||
{
|
||||
echo "body_b64=$BODY_B64"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate checkbox selection
|
||||
id: validate
|
||||
shell: bash
|
||||
env:
|
||||
BODY_B64: ${{ steps.body.outputs.body_b64 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! body="$(printf '%s' "$BODY_B64" | base64 -d)"; then
|
||||
echo "::error::Failed to decode PR body from base64. Data may be corrupted or missing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
added_checked=$(printf '%s' "$body" | grep -Ei '^[[:space:]]*-\s*\[x\]\s*I added/updated documentation' | wc -l | tr -d '[:space:]' || true)
|
||||
noneed_checked=$(printf '%s' "$body" | grep -Ei '^[[:space:]]*-\s*\[x\]\s*Documentation is \*\*not needed\*\*' | wc -l | tr -d '[:space:]' || true)
|
||||
|
||||
total=$((added_checked + noneed_checked))
|
||||
if [ "$total" -ne 1 ]; then
|
||||
echo "::error::You must check exactly one docs option in the PR template (either 'docs added' OR 'not needed')."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$added_checked" -eq 1 ]; then
|
||||
echo "mode=added" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "mode=noneed" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Extract docs PR URL (when 'docs added')
|
||||
if: steps.validate.outputs.mode == 'added'
|
||||
id: extract
|
||||
shell: bash
|
||||
env:
|
||||
BODY_B64: ${{ steps.body.outputs.body_b64 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
body="$(printf '%s' "$BODY_B64" | base64 -d)"
|
||||
|
||||
# Strictly require HTTPS and that it's a PR in netbirdio/docs
|
||||
# e.g., https://github.com/netbirdio/docs/pull/1234
|
||||
url="$(printf '%s' "$body" | grep -Eo 'https://github\.com/netbirdio/docs/pull/[0-9]+' | head -n1 || true)"
|
||||
|
||||
if [ -z "${url:-}" ]; then
|
||||
echo "::error::You checked 'docs added' but didn't include a valid HTTPS PR link to netbirdio/docs (e.g., https://github.com/netbirdio/docs/pull/1234)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pr_number="$(printf '%s' "$url" | sed -E 's#.*/pull/([0-9]+)$#\1#')"
|
||||
{
|
||||
echo "url=$url"
|
||||
echo "pr_number=$pr_number"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify docs PR exists (and is open or merged)
|
||||
if: steps.validate.outputs.mode == 'added'
|
||||
uses: actions/github-script@v7
|
||||
id: verify
|
||||
env:
|
||||
PR_NUMBER: ${{ steps.extract.outputs.pr_number }}
|
||||
with:
|
||||
script: |
|
||||
const prNumber = parseInt(process.env.PR_NUMBER, 10);
|
||||
const { data } = await github.rest.pulls.get({
|
||||
owner: 'netbirdio',
|
||||
repo: 'docs',
|
||||
pull_number: prNumber
|
||||
});
|
||||
|
||||
// Allow open or merged PRs
|
||||
const ok = data.state === 'open' || data.merged === true;
|
||||
core.setOutput('state', data.state);
|
||||
core.setOutput('merged', String(!!data.merged));
|
||||
if (!ok) {
|
||||
core.setFailed(`Docs PR #${prNumber} exists but is neither open nor merged (state=${data.state}, merged=${data.merged}).`);
|
||||
}
|
||||
result-encoding: string
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: All good
|
||||
run: echo "Documentation requirement satisfied âś…"
|
||||
+4
-1
@@ -5,6 +5,9 @@ const nextConfig = {
|
||||
unoptimized: true,
|
||||
},
|
||||
reactStrictMode: false,
|
||||
experimental: {
|
||||
externalDir: true,
|
||||
},
|
||||
env: {
|
||||
APP_ENV: process.env.APP_ENV || "production",
|
||||
NEXT_PUBLIC_DASHBOARD_VERSION:
|
||||
@@ -12,4 +15,4 @@ const nextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
module.exports = nextConfig;
|
||||
Generated
+2381
-98
File diff suppressed because it is too large.
Load diff
+7
-3
@@ -18,6 +18,7 @@
|
||||
"dependencies": {
|
||||
"@axa-fr/react-oidc": "^7.26.3",
|
||||
"@dagrejs/dagre": "^1.1.5",
|
||||
"@netbirdio/explain": "^0.1.5",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
@@ -49,7 +50,7 @@
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@xyflow/react": "^12.8.4",
|
||||
"autoprefixer": "^10",
|
||||
"chart.js": "^4.4.8",
|
||||
"commander": "^14.0.3","chart.js": "^4.4.8",
|
||||
"chroma-js": "^3.1.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"classnames": "^2.5.1",
|
||||
@@ -67,8 +68,8 @@
|
||||
"ip-cidr": "^3.1.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash": "^4.17.23",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "^16.1.6",
|
||||
"lucide-react": "^0.566.0",
|
||||
"next": "^16.1.7",
|
||||
"next-themes": "^0.2.1",
|
||||
"punycode": "^2.3.1",
|
||||
"react": "^19.2.4",
|
||||
@@ -80,8 +81,11 @@
|
||||
"react-icons": "^5.5.0",
|
||||
"react-jwt": "^1.2.0",
|
||||
"react-loading-skeleton": "^3.3.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-responsive": "^9.0.2",
|
||||
"react-virtuoso": "^4.9.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"swr": "^2.2.4",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
|
||||
@@ -25,6 +25,8 @@ export default function ProxyEventsPage() {
|
||||
() => ({
|
||||
start_date: dayjs().subtract(7, "day").startOf("day").toISOString(),
|
||||
end_date: dayjs().endOf("day").toISOString(),
|
||||
sort_by: "timestamp",
|
||||
sort_order: "desc",
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import Button from "@components/Button";
|
||||
import ExplainButton from "@components/ExplainButton";
|
||||
import Card from "@components/Card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
} from "@components/DropdownMenu";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import useRedirect from "@hooks/useRedirect";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { cn, singularize } from "@utils/helpers";
|
||||
@@ -35,6 +35,7 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Network, NetworkResource, NetworkRouter } from "@/interfaces/Network";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { NetworkInformationSquare } from "@/modules/networks/misc/NetworkInformationSquare";
|
||||
import { NetworkAccessControlProvider } from "@/modules/networks/NetworkAccessControlProvider";
|
||||
import {
|
||||
NetworkProvider,
|
||||
useNetworksContext,
|
||||
@@ -49,6 +50,7 @@ import ReverseProxiesProvider, {
|
||||
flattenReverseProxies,
|
||||
useReverseProxies,
|
||||
} from "@/contexts/ReverseProxiesProvider";
|
||||
import { SkeletonNetwork } from "@components/skeletons/SkeletonNetwork";
|
||||
|
||||
export default function NetworkDetailPage() {
|
||||
const queryParameter = useSearchParams();
|
||||
@@ -65,7 +67,7 @@ export default function NetworkDetailPage() {
|
||||
<NetworkOverview network={network} />
|
||||
</ReverseProxiesProvider>
|
||||
) : (
|
||||
<FullScreenLoading />
|
||||
<SkeletonNetwork />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,103 +98,108 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NetworkProvider network={network}>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/networks"}
|
||||
label={"Networks"}
|
||||
disabled={!permission.networks.read}
|
||||
icon={<NetworkRoutesIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/network"}
|
||||
label={network.name}
|
||||
active={true}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<NetworkAccessControlProvider>
|
||||
<NetworkProvider network={network}>
|
||||
<div>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/networks"}
|
||||
label={"Networks"}
|
||||
disabled={!permission.networks.read}
|
||||
icon={<NetworkRoutesIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/network"}
|
||||
label={network.name}
|
||||
active={true}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
|
||||
<div className={"flex justify-between max-w-6xl"}>
|
||||
<div
|
||||
className={"w-full lg:w-1/2 flex justify-between items-center"}
|
||||
>
|
||||
<div className={"flex justify-between max-w-6xl"}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center w-full",
|
||||
!network.description && "gap-2",
|
||||
)}
|
||||
className={"w-full lg:w-1/2 flex justify-between items-center"}
|
||||
>
|
||||
<NetworkInformationSquare
|
||||
name={network.name}
|
||||
active={isActive}
|
||||
size={"lg"}
|
||||
description={network.description}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center w-full",
|
||||
!network.description && "gap-2",
|
||||
)}
|
||||
>
|
||||
<NetworkInformationSquare
|
||||
name={network.name}
|
||||
active={isActive}
|
||||
size={"lg"}
|
||||
description={network.description}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ExplainButton />
|
||||
<NetworkActions />
|
||||
</div>
|
||||
</div>
|
||||
<NetworkProvider network={network}>
|
||||
<NetworkActions />
|
||||
</NetworkProvider>
|
||||
</div>
|
||||
|
||||
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
|
||||
<NetworkInformationCard network={network} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
|
||||
<NetworkInformationCard network={network} />
|
||||
<Tabs
|
||||
defaultValue={tab}
|
||||
onValueChange={setTab}
|
||||
value={tab}
|
||||
className={"pb-0 mb-0"}
|
||||
>
|
||||
<TabsList justify={"start"} className={"px-8"}>
|
||||
<TabsTrigger value={"resources"}>
|
||||
<Layers3Icon size={14} />
|
||||
{singularize("Resources", network?.resources?.length)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={"routing-peers"}>
|
||||
<PeerIcon
|
||||
size={12}
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
{singularize("Routing Peers", network?.routing_peers_count)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={"services"}>
|
||||
<ReverseProxyIcon
|
||||
size={16}
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
{singularize("Services", services.length)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={"resources"} className={"pb-8"}>
|
||||
<ResourcesTabContent
|
||||
data={resources}
|
||||
isLoading={isResourcesLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"routing-peers"} className={"pb-8"}>
|
||||
<NetworkRoutingPeersTabContent
|
||||
routers={routers}
|
||||
isLoading={isRoutersLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"services"} className={"pb-8"}>
|
||||
<ReverseProxyFlatTargetsTabContent
|
||||
targets={services}
|
||||
isLoading={isServicesLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
defaultValue={tab}
|
||||
onValueChange={setTab}
|
||||
value={tab}
|
||||
className={"pb-0 mb-0"}
|
||||
>
|
||||
<TabsList justify={"start"} className={"px-8"}>
|
||||
<TabsTrigger value={"resources"}>
|
||||
<Layers3Icon size={14} />
|
||||
{singularize("Resources", network?.resources?.length)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={"routing-peers"}>
|
||||
<PeerIcon
|
||||
size={12}
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
{singularize("Routing Peers", network?.routing_peers_count)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={"services"}>
|
||||
<ReverseProxyIcon
|
||||
size={16}
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
{singularize("Services", services.length)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={"resources"} className={"pb-8"}>
|
||||
<ResourcesTabContent
|
||||
data={resources}
|
||||
isLoading={isResourcesLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"routing-peers"} className={"pb-8"}>
|
||||
<NetworkRoutingPeersTabContent
|
||||
routers={routers}
|
||||
isLoading={isRoutersLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"services"} className={"pb-8"}>
|
||||
<ReverseProxyFlatTargetsTabContent
|
||||
targets={services}
|
||||
isLoading={isServicesLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</NetworkProvider>
|
||||
</NetworkProvider>
|
||||
</NetworkAccessControlProvider>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -281,6 +288,7 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
|
||||
<Card.List>
|
||||
<Card.ListItem
|
||||
tooltip={false}
|
||||
data-nb-explain="High Availability"
|
||||
label={
|
||||
<>
|
||||
<ServerIcon size={16} />
|
||||
@@ -326,6 +334,7 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
|
||||
/>
|
||||
<Card.ListItem
|
||||
tooltip={false}
|
||||
data-nb-explain="Active Policies"
|
||||
label={
|
||||
policyCount > 0 ? (
|
||||
<>
|
||||
|
||||
@@ -200,3 +200,198 @@ p {
|
||||
.react-flow__node-groupNode .selected{
|
||||
@apply border-netbird;
|
||||
}
|
||||
|
||||
/* @netbirdio/explain theme overrides — match dashboard palette */
|
||||
html:root {
|
||||
--nb-explain-bg: #1c1e21;
|
||||
--nb-explain-bg-subtle: #25282d;
|
||||
--nb-explain-bg-hover: #2e3238;
|
||||
--nb-explain-border: #363b40;
|
||||
--nb-explain-text: #e4e7e9;
|
||||
--nb-explain-text-muted: #a3adb5;
|
||||
--nb-explain-text-dim: #7c8994;
|
||||
--nb-explain-accent: #f68330;
|
||||
--nb-explain-accent-hover: #f46d1b;
|
||||
--nb-explain-accent-glow: rgba(246, 131, 48, 0.15);
|
||||
--nb-explain-user-bg: #f68330;
|
||||
--nb-explain-user-text: #ffffff;
|
||||
--nb-explain-user-glow: rgba(246, 131, 48, 0.25);
|
||||
--nb-explain-radius: 8px;
|
||||
--nb-explain-radius-sm: 6px;
|
||||
--nb-explain-radius-xs: 4px;
|
||||
--nb-explain-font: inherit;
|
||||
--nb-explain-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.6), 0 0 0 1px #363b40;
|
||||
--nb-explain-banner-bg: rgba(246, 131, 48, 0.92);
|
||||
--nb-explain-banner-text: #ffffff;
|
||||
--nb-explain-error-text: #F98080;
|
||||
}
|
||||
|
||||
/* Override explain highlight outline to match netbird orange */
|
||||
[data-nb-explain-highlight] {
|
||||
outline: 1px solid rgba(246, 131, 48, 0.7) !important;
|
||||
}
|
||||
|
||||
/* Markdown body — Dify-style rendering for AI chat messages */
|
||||
.nb-markdown-body {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
color: var(--nb-explain-text, #e4e7e9);
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.nb-markdown-body > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.nb-markdown-body > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.nb-markdown-body p {
|
||||
margin: 0.4em 0;
|
||||
font-weight: 400;
|
||||
letter-spacing: normal;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.nb-markdown-body strong {
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.nb-markdown-body em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Headings */
|
||||
.nb-markdown-body h1 {
|
||||
font-size: 1.25em;
|
||||
font-weight: 600;
|
||||
margin: 0.6em 0 0.3em;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.nb-markdown-body h2 {
|
||||
font-size: 1.15em;
|
||||
font-weight: 600;
|
||||
margin: 0.5em 0 0.3em;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.nb-markdown-body h3 {
|
||||
font-size: 1.05em;
|
||||
font-weight: 600;
|
||||
margin: 0.4em 0 0.2em;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
.nb-markdown-body ul {
|
||||
margin: 0.4em 0;
|
||||
padding-left: 1.4em;
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.nb-markdown-body ol {
|
||||
margin: 0.4em 0;
|
||||
padding-left: 1.4em;
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.nb-markdown-body li {
|
||||
margin: 0.15em 0;
|
||||
}
|
||||
|
||||
.nb-markdown-body li > p {
|
||||
margin: 0.1em 0;
|
||||
}
|
||||
|
||||
/* Task lists (GFM) */
|
||||
.nb-markdown-body li input[type="checkbox"] {
|
||||
margin-right: 0.4em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
.nb-markdown-body code {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-radius: 4px;
|
||||
padding: 0.15em 0.4em;
|
||||
font-size: 0.88em;
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
.nb-markdown-body pre {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 6px;
|
||||
padding: 0.7em 0.9em;
|
||||
margin: 0.5em 0;
|
||||
overflow-x: auto;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.nb-markdown-body pre code {
|
||||
background: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
/* Blockquotes */
|
||||
.nb-markdown-body blockquote {
|
||||
margin: 0.5em 0;
|
||||
padding: 0.3em 0.8em;
|
||||
border-left: 3px solid var(--nb-explain-accent, #f68330);
|
||||
color: var(--nb-explain-text-muted, #a3adb5);
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.nb-markdown-body blockquote p {
|
||||
margin: 0.2em 0;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.nb-markdown-body a {
|
||||
color: var(--nb-explain-accent, #f68330);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.nb-markdown-body a:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Horizontal rules */
|
||||
.nb-markdown-body hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--nb-explain-border, #363b40);
|
||||
margin: 0.8em 0;
|
||||
}
|
||||
|
||||
/* Tables (GFM) */
|
||||
.nb-markdown-body table {
|
||||
border-collapse: collapse;
|
||||
margin: 0.5em 0;
|
||||
width: 100%;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
|
||||
.nb-markdown-body th,
|
||||
.nb-markdown-body td {
|
||||
border: 1px solid var(--nb-explain-border, #363b40);
|
||||
padding: 0.35em 0.6em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.nb-markdown-body th {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nb-markdown-body tr:nth-child(even) {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
@@ -8,8 +8,12 @@ export default function ReverseProxyIcon(props: IconProps) {
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...iconProperties(props)}
|
||||
fill={"currentColor"}
|
||||
>
|
||||
<path d="M11.4488 2.1499C11.7903 1.95003 12.2097 1.95003 12.5513 2.1499L16.5018 4.46123L12 7.03523L7.49823 4.46123L11.4488 2.1499ZM6.44447 6.46472L6.44444 10.2784L2.93531 12.3315L7.53662 14.8399L10.8889 12.8787V9.00593L6.44447 6.46472ZM2 14.3992V18.7395C2 19.1477 2.21366 19.5247 2.55984 19.7272L6.44446 22V16.8223L2 14.3992ZM8.66668 22L12 20.0497L15.3333 22V16.7994L12 14.8492L8.66668 16.7993V22ZM17.5556 22L21.4401 19.7272C21.7863 19.5247 22 19.1477 22 18.7395V14.3992L17.5556 16.8223V22ZM21.0647 12.3315L17.5556 10.2784V6.46474L13.1111 9.00593V12.8787L16.4634 14.8399L21.0647 12.3315Z" />
|
||||
<path
|
||||
fill={"currentColor"}
|
||||
d="M11.4488 2.1499C11.7903 1.95003 12.2097 1.95003 12.5513 2.1499L16.5018 4.46123L12 7.03523L7.49823 4.46123L11.4488 2.1499ZM6.44447 6.46472L6.44444 10.2784L2.93531 12.3315L7.53662 14.8399L10.8889 12.8787V9.00593L6.44447 6.46472ZM2 14.3992V18.7395C2 19.1477 2.21366 19.5247 2.55984 19.7272L6.44446 22V16.8223L2 14.3992ZM8.66668 22L12 20.0497L15.3333 22V16.7994L12 14.8492L8.66668 16.7993V22ZM17.5556 22L21.4401 19.7272C21.7863 19.5247 22 19.1477 22 18.7395V14.3992L17.5556 16.8223V22ZM21.0647 12.3315L17.5556 10.2784V6.46474L13.1111 9.00593V12.8787L16.4634 14.8399L21.0647 12.3315Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { motion } from "framer-motion";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
@@ -23,7 +24,7 @@ const AccordionTrigger = React.forwardRef<
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center gap-4 font-medium transition-all [&[data-state=open]>svg.chevron]:rotate-180 hover:opacity-80 my-2",
|
||||
"flex flex-1 items-center gap-4 font-medium [&[data-state=open]>svg.chevron]:rotate-180 hover:opacity-80 my-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -36,20 +37,41 @@ const AccordionTrigger = React.forwardRef<
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
HTMLDivElement,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className=" pt-0">{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
));
|
||||
>(({ className, children }, ref) => {
|
||||
const wrapperRef = React.useRef<HTMLDivElement>(null);
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = wrapperRef.current?.closest("[data-state]");
|
||||
if (!el) return;
|
||||
|
||||
const update = () => setIsOpen(el.getAttribute("data-state") === "open");
|
||||
update();
|
||||
|
||||
const observer = new MutationObserver(update);
|
||||
observer.observe(el, { attributes: true, attributeFilter: ["data-state"] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef}>
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={false}
|
||||
animate={{
|
||||
height: isOpen ? "auto" : 0,
|
||||
opacity: isOpen ? 1 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
className={cn("overflow-hidden text-sm", className)}
|
||||
>
|
||||
<div className="pt-0">{children}</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||
|
||||
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
|
||||
@@ -74,7 +74,7 @@ export const buttonVariants = cva(
|
||||
"",
|
||||
],
|
||||
"danger-text": [
|
||||
"dark:bg-transparent dark:text-red-500 dark:hover:text-red-600 dark:border-transparent !px-0 !shadow-none !py-0 focus:ring-red-500/30 dark:ring-offset-neutral-950/50",
|
||||
"dark:bg-transparent dark:text-red-500 dark:hover:text-red-600 dark:border-transparent !px-0 !shadow-none !py-0 focus:ring-red-500/30 dark:ring-offset-neutral-950/50 rounded-sm",
|
||||
],
|
||||
"default-outline": [
|
||||
"dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",
|
||||
|
||||
@@ -80,13 +80,15 @@ export const DeviceCard = ({
|
||||
hideTooltip={true}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
"text-sm font-normal text-nb-gray-400 relative whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
<TruncatedText text={descriptionText} maxWidth={"160px"} />
|
||||
</span>
|
||||
{descriptionText && (
|
||||
<span
|
||||
className={
|
||||
"text-sm font-normal text-nb-gray-400 relative whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
<TruncatedText text={descriptionText} maxWidth={"160px"} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,29 +1,68 @@
|
||||
import * as React from "react";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { TooltipVariants } from "@components/Tooltip";
|
||||
|
||||
type Props = {
|
||||
content: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
interactive?: boolean;
|
||||
};
|
||||
className?: string;
|
||||
triggerClassName?: string;
|
||||
align?: "start" | "center" | "end";
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
alignOffset?: number;
|
||||
sideOffset?: number;
|
||||
iconSize?: number;
|
||||
delayDuration?: number;
|
||||
} & TooltipVariants;
|
||||
export const HelpTooltip = ({
|
||||
content,
|
||||
children,
|
||||
interactive = true,
|
||||
interactive = false,
|
||||
className,
|
||||
variant = "default",
|
||||
triggerClassName,
|
||||
align = "start",
|
||||
side = "top",
|
||||
alignOffset = 0,
|
||||
sideOffset,
|
||||
iconSize = 12,
|
||||
delayDuration = 300,
|
||||
}: Props) => {
|
||||
return (
|
||||
<>
|
||||
<FullTooltip
|
||||
interactive={interactive}
|
||||
side={"top"}
|
||||
align={"start"}
|
||||
alignOffset={0}
|
||||
side={side}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
delayDuration={delayDuration}
|
||||
variant={variant}
|
||||
className={
|
||||
"inline underline decoration-dashed underline-offset-[3px] decoration-nb-gray-300 cursor-help transition-all hover:decoration-white"
|
||||
}
|
||||
content={content}
|
||||
content={
|
||||
<div className={cn("max-w-xs text-xs", className)}>{content}</div>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
{children ? (
|
||||
children
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"p-2 -m-2 inline-flex items-center justify-center relative top-[1px] group/help",
|
||||
triggerClassName,
|
||||
)}
|
||||
>
|
||||
<HelpCircle
|
||||
size={iconSize}
|
||||
className={"text-nb-gray-300 group-hover/help:text-nb-gray-100"}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</FullTooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -6,24 +6,26 @@ export const ListItem = ({
|
||||
label,
|
||||
value,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
icon?: React.ReactNode;
|
||||
label: string;
|
||||
value: string | React.ReactNode;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex justify-between gap-12 border-b border-nb-gray-920 py-2 px-4 last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
className={cn(" border-b border-nb-gray-920 last:border-b-0", className)}
|
||||
>
|
||||
<div className={"flex items-center gap-2 text-nb-gray-100 font-medium"}>
|
||||
{icon}
|
||||
{label}
|
||||
<div className={cn("flex justify-between gap-12 py-2 px-4")}>
|
||||
<div className={"flex items-center gap-2 text-nb-gray-100 font-medium"}>
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<div className={"text-nb-gray-300"}>{value}</div>
|
||||
</div>
|
||||
<div className={"text-nb-gray-300"}>{value}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -18,6 +18,7 @@ export interface NotifyProps<T> {
|
||||
icon?: React.ReactNode;
|
||||
backgroundColor?: string;
|
||||
preventSuccessToast?: boolean;
|
||||
showOnlyError?: boolean;
|
||||
errorMessages?: ErrorResponse[];
|
||||
}
|
||||
|
||||
@@ -36,6 +37,7 @@ export default function Notification<T>({
|
||||
loadingMessage,
|
||||
duration = 3500,
|
||||
preventSuccessToast = false,
|
||||
showOnlyError = false,
|
||||
errorMessages,
|
||||
}: NotificationProps<T>) {
|
||||
const [error, setError] = useState("");
|
||||
@@ -49,10 +51,13 @@ export default function Notification<T>({
|
||||
const startTimer = useCallback(() => {
|
||||
if (timerRef.current) return;
|
||||
startTimeRef.current = Date.now();
|
||||
timerRef.current = setTimeout(() => {
|
||||
timerRef.current = null;
|
||||
toast.dismiss(toastId);
|
||||
}, Math.max(0, remainingRef.current));
|
||||
timerRef.current = setTimeout(
|
||||
() => {
|
||||
timerRef.current = null;
|
||||
toast.dismiss(toastId);
|
||||
},
|
||||
Math.max(0, remainingRef.current),
|
||||
);
|
||||
}, [toastId]);
|
||||
|
||||
const pauseTimer = useCallback(() => {
|
||||
@@ -88,7 +93,10 @@ export default function Notification<T>({
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(toastEl, { attributes: true, attributeFilter: ["data-expanded"] });
|
||||
observer.observe(toastEl, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-expanded"],
|
||||
});
|
||||
|
||||
// Start immediately if not expanded
|
||||
const expanded = toastEl.getAttribute("data-expanded") === "true";
|
||||
@@ -106,7 +114,7 @@ export default function Notification<T>({
|
||||
promise
|
||||
.then(() => {
|
||||
setLoading(false);
|
||||
if (preventSuccessToast) {
|
||||
if (showOnlyError || preventSuccessToast) {
|
||||
toast.dismiss(toastId);
|
||||
} else {
|
||||
setReadyToDismiss(true);
|
||||
@@ -136,6 +144,9 @@ export default function Notification<T>({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const hideUntilError = showOnlyError && loading && !error;
|
||||
if (hideUntilError) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={notificationRef}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
MonitorSmartphoneIcon,
|
||||
NetworkIcon,
|
||||
SearchIcon,
|
||||
ShieldCheck,
|
||||
WorkflowIcon,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
@@ -40,7 +41,7 @@ import { useElementSize } from "@/hooks/useElementSize";
|
||||
import type { Group, GroupPeer, GroupResource } from "@/interfaces/Group";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
import { PolicyRuleResource } from "@/interfaces/Policy";
|
||||
import { Policy, PolicyRuleResource } from "@/interfaces/Policy";
|
||||
import { User } from "@/interfaces/User";
|
||||
import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack";
|
||||
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
|
||||
@@ -71,18 +72,21 @@ interface MultiSelectProps {
|
||||
showResourceCounter?: boolean;
|
||||
showResources?: boolean;
|
||||
showPeers?: boolean;
|
||||
showPeerCounter?: boolean;
|
||||
hideGroupsTab?: boolean;
|
||||
tabOrder?: ("groups" | "peers" | "resources")[];
|
||||
closeOnSelect?: boolean;
|
||||
resource?: PolicyRuleResource;
|
||||
onResourceChange?: (resource?: PolicyRuleResource) => void;
|
||||
placeholder?: string;
|
||||
placeholder?: React.ReactNode | string;
|
||||
customTrigger?: React.ReactNode;
|
||||
align?: "start" | "end";
|
||||
side?: "top" | "bottom";
|
||||
users?: User[];
|
||||
placeholderForSearch?: string;
|
||||
resourceIds?: string[];
|
||||
additionalResources?: NetworkResource[];
|
||||
policies?: Policy[];
|
||||
}
|
||||
export function PeerGroupSelector({
|
||||
onChange,
|
||||
@@ -101,6 +105,7 @@ export function PeerGroupSelector({
|
||||
showResourceCounter = true,
|
||||
showResources = false,
|
||||
showPeers = false,
|
||||
showPeerCounter = true,
|
||||
hideGroupsTab = false,
|
||||
tabOrder,
|
||||
closeOnSelect = false,
|
||||
@@ -113,11 +118,21 @@ export function PeerGroupSelector({
|
||||
users,
|
||||
placeholderForSearch = 'Search groups or add new group by pressing "Enter"...',
|
||||
resourceIds,
|
||||
additionalResources,
|
||||
policies,
|
||||
}: Readonly<MultiSelectProps>) {
|
||||
const { data: resources, isLoading: isResourcesLoading } = useFetchApi<
|
||||
const { data: fetchedResources, isLoading: isResourcesLoading } = useFetchApi<
|
||||
NetworkResource[]
|
||||
>("/networks/resources");
|
||||
|
||||
const resources = useMemo(() => {
|
||||
if (!additionalResources?.length) return fetchedResources;
|
||||
const additional = additionalResources.filter(
|
||||
(ar) => !fetchedResources?.some((r) => r.id === ar.id),
|
||||
);
|
||||
return [...(fetchedResources || []), ...additional];
|
||||
}, [fetchedResources, additionalResources]);
|
||||
|
||||
const { data: peers, isLoading: isPeersLoading } =
|
||||
useFetchApi<Peer[]>("/peers");
|
||||
|
||||
@@ -329,7 +344,7 @@ export function PeerGroupSelector({
|
||||
"min-h-[46px] w-full relative items-center group",
|
||||
"border border-neutral-200 dark:border-nb-gray-700 justify-between py-2 px-3",
|
||||
"rounded-md bg-white text-sm dark:bg-nb-gray-900/40 flex dark:text-neutral-400/70 text-neutral-500 cursor-pointer hover:dark:bg-nb-gray-900/50",
|
||||
"disabled:pointer-events-none disabled:opacity-30 transition-all",
|
||||
"disabled:pointer-events-none disabled:opacity-60 transition-all",
|
||||
)}
|
||||
disabled={disabled}
|
||||
data-cy={dataCy}
|
||||
@@ -343,7 +358,14 @@ export function PeerGroupSelector({
|
||||
{resource && (
|
||||
<ResourceBadge
|
||||
className={"py-[3px]"}
|
||||
resource={resources?.find((r) => r.id === resource.id)}
|
||||
resource={
|
||||
resources?.find((r) => r.id === resource.id) ??
|
||||
({
|
||||
id: resource.id,
|
||||
name: resource.id,
|
||||
type: resource.type,
|
||||
} as NetworkResource)
|
||||
}
|
||||
peer={peers?.find((p) => p.id === resource.id)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -397,7 +419,9 @@ export function PeerGroupSelector({
|
||||
})}
|
||||
|
||||
{values.length == 0 && !resource && (
|
||||
<span className={"pl-1"}>{placeholder}</span>
|
||||
<span className={cn(typeof placeholder === "string" && "pl-1")}>
|
||||
{placeholder}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -567,12 +591,21 @@ export function PeerGroupSelector({
|
||||
<ResourcesCounter group={option} />
|
||||
)}
|
||||
|
||||
{policies && (
|
||||
<PolicyCounter
|
||||
group={option}
|
||||
policies={policies}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={"flex gap-4 items-center"}>
|
||||
{!users ? (
|
||||
<PeerCounter
|
||||
group={option}
|
||||
showResourceCounter={showResourceCounter}
|
||||
/>
|
||||
showPeerCounter && (
|
||||
<PeerCounter
|
||||
group={option}
|
||||
showResourceCounter={showResourceCounter}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<UsersCounter
|
||||
group={option}
|
||||
@@ -788,6 +821,39 @@ const ResourcesCounter = ({ group }: { group: Group }) => {
|
||||
) : null;
|
||||
};
|
||||
|
||||
const PolicyCounter = ({
|
||||
group,
|
||||
policies,
|
||||
}: {
|
||||
group: Group;
|
||||
policies: Policy[];
|
||||
}) => {
|
||||
const count = useMemo(() => {
|
||||
if (!group.id) return 0;
|
||||
return policies.filter((policy) => {
|
||||
const destinations = policy.rules?.[0]?.destinations as
|
||||
| (Group | string)[]
|
||||
| undefined;
|
||||
return destinations?.some((d) =>
|
||||
typeof d === "string" ? d === group.id : d.id === group.id,
|
||||
);
|
||||
}).length;
|
||||
}, [group.id, policies]);
|
||||
|
||||
if (count === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"text-nb-gray-300 font-medium flex items-center gap-2 transition-all"
|
||||
}
|
||||
>
|
||||
<ShieldCheck size={14} className={"shrink-0"} />
|
||||
{count} {count === 1 ? "Policy" : "Policies"}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const resourcesSearchPredicate = (item: NetworkResource, query: string) => {
|
||||
const lowerCaseQuery = query.toLowerCase();
|
||||
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
|
||||
|
||||
@@ -8,6 +8,7 @@ type Props = {
|
||||
description: ReactNode;
|
||||
icon?: ReactNode;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const RadioCard = ({
|
||||
@@ -16,15 +17,18 @@ export const RadioCard = ({
|
||||
description,
|
||||
className,
|
||||
icon,
|
||||
disabled,
|
||||
}: Props) => {
|
||||
return (
|
||||
<RadioGroup.Item
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"peer relative block cursor-pointer rounded-lg border border-nb-gray-900 bg-nb-gray-930/60 px-5 py-3 transition-all focus:outline-none",
|
||||
"data-[state=checked]:border-nb-gray-400 data-[state=checked]:bg-nb-gray-920",
|
||||
"outline-none focus:ring-0 focus:bg-nb-gray-930 focus:border-nb-gray-920",
|
||||
"hover:bg-nb-gray-930",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-nb-gray-930/60",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
+46
-10
@@ -75,23 +75,59 @@ SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
|
||||
extra?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
}
|
||||
>(({ className, children, extra, icon, description, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-gray-100 focus:text-neutral-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-nb-gray-900 dark:focus:text-neutral-50 dark:text-gray-400 cursor-pointer",
|
||||
"relative flex w-full select-none items-center rounded-md py-1.5 text-sm outline-none focus:bg-gray-100 focus:text-neutral-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-nb-gray-900 dark:focus:text-neutral-50 dark:text-gray-400 cursor-pointer",
|
||||
icon ? "pl-2 pr-8" : "pl-8 pr-2",
|
||||
description && "py-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
{icon ? (
|
||||
<>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex-shrink-0">{icon}</span>
|
||||
<div className="flex flex-col">
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
{description && (
|
||||
<span className="text-xs text-nb-gray-300 font-normal">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
{description && (
|
||||
<span className="text-xs text-nb-gray-300 font-normal">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{extra}
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import AIChatBot from "./AIChatBot";
|
||||
import AIFloatingButton from "./AIFloatingButton";
|
||||
|
||||
type ExplainContext = {
|
||||
modalName: string;
|
||||
pageName: string;
|
||||
docsUrl: string;
|
||||
};
|
||||
|
||||
type AIAssistantContextType = {
|
||||
openChat: (selectedText?: string) => void;
|
||||
closeChat: () => void;
|
||||
isChatOpen: boolean;
|
||||
explainMode: boolean;
|
||||
enterExplainMode: () => void;
|
||||
exitExplainMode: () => void;
|
||||
setExplainContext: (ctx: ExplainContext) => void;
|
||||
clearExplainContext: () => void;
|
||||
};
|
||||
|
||||
const AIAssistantContext = createContext<AIAssistantContextType>({
|
||||
openChat: () => {},
|
||||
closeChat: () => {},
|
||||
isChatOpen: false,
|
||||
explainMode: false,
|
||||
enterExplainMode: () => {},
|
||||
exitExplainMode: () => {},
|
||||
setExplainContext: () => {},
|
||||
clearExplainContext: () => {},
|
||||
});
|
||||
|
||||
export const useAIAssistant = () => useContext(AIAssistantContext);
|
||||
|
||||
/**
|
||||
* Find the closest ancestor (or self) with a data-explain attribute.
|
||||
* Returns null if nothing is explainable.
|
||||
*/
|
||||
function findExplainable(el: HTMLElement): HTMLElement | null {
|
||||
return el.closest("[data-explain]") as HTMLElement | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a short label from an explainable element by looking for
|
||||
* a label, heading, or first bit of text content.
|
||||
*/
|
||||
function extractLabel(el: HTMLElement): string {
|
||||
const label = el.querySelector("label") as HTMLElement | null;
|
||||
if (label?.innerText?.trim()) return label.innerText.trim();
|
||||
|
||||
const heading = el.querySelector("h1, h2, h3, h4") as HTMLElement | null;
|
||||
if (heading?.innerText?.trim()) return heading.innerText.trim();
|
||||
|
||||
const text = el.innerText?.trim();
|
||||
if (text && text.length <= 80) return text;
|
||||
if (text) return text.slice(0, 80) + "...";
|
||||
|
||||
return "this element";
|
||||
}
|
||||
|
||||
function buildQuery(label: string, ctx: ExplainContext | null): string {
|
||||
let userMessage = `Explain "${label}"`;
|
||||
if (ctx) {
|
||||
userMessage += ` on ${ctx.modalName} modal`;
|
||||
if (ctx.pageName) userMessage += ` in ${ctx.pageName}`;
|
||||
}
|
||||
|
||||
const parts = [userMessage];
|
||||
if (ctx?.docsUrl) {
|
||||
parts.push(`Docs: ${ctx.docsUrl}`);
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
export default function AIAssistantProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [isChatOpen, setIsChatOpen] = useState(false);
|
||||
const [initialQuery, setInitialQuery] = useState("");
|
||||
const [explainMode, setExplainMode] = useState(false);
|
||||
const [hoveredEl, setHoveredEl] = useState<HTMLElement | null>(null);
|
||||
const [explainCtx, setExplainCtx] = useState<ExplainContext | null>(null);
|
||||
|
||||
const openChat = useCallback((selectedText?: string) => {
|
||||
setInitialQuery(selectedText || "");
|
||||
setIsChatOpen(true);
|
||||
setExplainMode(false);
|
||||
setHoveredEl(null);
|
||||
}, []);
|
||||
|
||||
const closeChat = useCallback(() => {
|
||||
setIsChatOpen(false);
|
||||
setInitialQuery("");
|
||||
}, []);
|
||||
|
||||
const enterExplainMode = useCallback(() => {
|
||||
setExplainMode(true);
|
||||
}, []);
|
||||
|
||||
const exitExplainMode = useCallback(() => {
|
||||
setExplainMode(false);
|
||||
setHoveredEl(null);
|
||||
}, []);
|
||||
|
||||
const setExplainContext = useCallback((ctx: ExplainContext) => {
|
||||
setExplainCtx(ctx);
|
||||
}, []);
|
||||
|
||||
const clearExplainContext = useCallback(() => {
|
||||
setExplainCtx(null);
|
||||
}, []);
|
||||
|
||||
// Explain mode: highlight explainable elements on hover, open chat on click
|
||||
useEffect(() => {
|
||||
if (!explainMode) return;
|
||||
|
||||
const handleMouseOver = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest("[data-explain-ignore]") || target.closest("[data-ai-banner]"))
|
||||
return;
|
||||
const explainable = findExplainable(target);
|
||||
setHoveredEl(explainable);
|
||||
};
|
||||
|
||||
const handleMouseOut = () => {
|
||||
setHoveredEl(null);
|
||||
};
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest("[data-explain-ignore]") || target.closest("[data-ai-banner]"))
|
||||
return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const explainable = findExplainable(target);
|
||||
if (!explainable) return;
|
||||
|
||||
const attrValue = explainable.getAttribute("data-explain") || "";
|
||||
const label = (attrValue && attrValue !== "true") ? attrValue : extractLabel(explainable);
|
||||
const query = buildQuery(label, explainCtx);
|
||||
openChat(query);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
exitExplainMode();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mouseover", handleMouseOver, true);
|
||||
document.addEventListener("mouseout", handleMouseOut, true);
|
||||
document.addEventListener("click", handleClick, true);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mouseover", handleMouseOver, true);
|
||||
document.removeEventListener("mouseout", handleMouseOut, true);
|
||||
document.removeEventListener("click", handleClick, true);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [explainMode, explainCtx, openChat, exitExplainMode]);
|
||||
|
||||
// Apply/remove highlight on hovered explainable element via CSS class
|
||||
useEffect(() => {
|
||||
if (!hoveredEl) return;
|
||||
hoveredEl.classList.add("ai-explain-highlight");
|
||||
return () => {
|
||||
hoveredEl.classList.remove("ai-explain-highlight");
|
||||
};
|
||||
}, [hoveredEl]);
|
||||
|
||||
// Also clean up all highlights when leaving explain mode
|
||||
useEffect(() => {
|
||||
if (!explainMode) {
|
||||
document.querySelectorAll(".ai-explain-highlight").forEach((el) => {
|
||||
el.classList.remove("ai-explain-highlight");
|
||||
});
|
||||
}
|
||||
}, [explainMode]);
|
||||
|
||||
return (
|
||||
<AIAssistantContext.Provider
|
||||
value={{
|
||||
openChat,
|
||||
closeChat,
|
||||
isChatOpen,
|
||||
explainMode,
|
||||
enterExplainMode,
|
||||
exitExplainMode,
|
||||
setExplainContext,
|
||||
clearExplainContext,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
{/* Explain mode styles + banner */}
|
||||
{explainMode && (
|
||||
<style>{`
|
||||
.ai-explain-highlight {
|
||||
outline: 2px solid rgba(234, 179, 8, 0.7) !important;
|
||||
|
||||
border-radius: 6px;
|
||||
cursor: help !important;
|
||||
transition: outline 0.1s ease;
|
||||
}
|
||||
`}</style>
|
||||
)}
|
||||
{explainMode && (
|
||||
<div
|
||||
data-ai-banner
|
||||
className="fixed top-3 left-1/2 -translate-x-1/2 z-[9996] bg-yellow-500/90 text-black text-sm font-medium px-4 py-1.5 rounded-full shadow-lg animate-in fade-in-0 slide-in-from-top-2 duration-200 flex items-center gap-2"
|
||||
>
|
||||
<span>Click on a highlighted element to explain it</span>
|
||||
<button
|
||||
onClick={() => exitExplainMode()}
|
||||
className="ml-1 text-black/60 hover:text-black underline cursor-pointer text-xs"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AIFloatingButton
|
||||
isOpen={isChatOpen}
|
||||
onClick={() => {
|
||||
if (isChatOpen) {
|
||||
closeChat();
|
||||
} else {
|
||||
openChat();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<AIChatBot
|
||||
open={isChatOpen}
|
||||
onClose={closeChat}
|
||||
initialQuery={initialQuery}
|
||||
/>
|
||||
</AIAssistantContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@utils/helpers";
|
||||
import {
|
||||
Bot,
|
||||
MessageCircleQuestion,
|
||||
Send,
|
||||
Sparkles,
|
||||
User,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
type Message = {
|
||||
id: string;
|
||||
role: "user" | "assistant" | "context";
|
||||
content: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
initialQuery: string;
|
||||
};
|
||||
|
||||
const AI_SERVER_URL =
|
||||
process.env.NEXT_PUBLIC_AI_SERVER_URL || "http://localhost:3080";
|
||||
const AI_API_KEY =
|
||||
process.env.NEXT_PUBLIC_AI_API_KEY || "nb-ai-dev-key-change-me";
|
||||
|
||||
async function fetchAIResponse(messages: Message[]): Promise<string> {
|
||||
const response = await fetch(`${AI_SERVER_URL}/api/ai/chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${AI_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messages: messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`${response.status}: ${text}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.reply;
|
||||
}
|
||||
|
||||
export default function AIChatBot({ open, onClose, initialQuery }: Props) {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const hasProcessedInitialQuery = useRef(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, isTyping, scrollToBottom]);
|
||||
|
||||
const getAIResponse = useCallback(async (allMessages: Message[]) => {
|
||||
setIsTyping(true);
|
||||
try {
|
||||
const reply = await fetchAIResponse(allMessages);
|
||||
const response: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: "assistant",
|
||||
content: reply,
|
||||
};
|
||||
setMessages((prev) => [...prev, response]);
|
||||
} catch (err) {
|
||||
const errorMsg =
|
||||
err instanceof Error ? err.message : "Unknown error";
|
||||
const response: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: "assistant",
|
||||
content: `Sorry, I couldn't get a response. Error: ${errorMsg}`,
|
||||
};
|
||||
setMessages((prev) => [...prev, response]);
|
||||
} finally {
|
||||
setIsTyping(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle initial query from explain mode
|
||||
useEffect(() => {
|
||||
if (open && initialQuery && !hasProcessedInitialQuery.current) {
|
||||
hasProcessedInitialQuery.current = true;
|
||||
|
||||
// Parse structured query: "Explain "X" on Y modal in Z\nDocs: ..."
|
||||
const lines = initialQuery.split("\n");
|
||||
const userMessage = lines[0];
|
||||
const docsLine = lines.find((l) => l.startsWith("Docs: "));
|
||||
|
||||
const msgs: Message[] = [];
|
||||
|
||||
if (docsLine) {
|
||||
msgs.push({
|
||||
id: Date.now().toString() + "-ctx",
|
||||
role: "context",
|
||||
content: docsLine,
|
||||
});
|
||||
}
|
||||
|
||||
msgs.push({
|
||||
id: Date.now().toString(),
|
||||
role: "user",
|
||||
content: userMessage,
|
||||
});
|
||||
|
||||
setMessages(msgs);
|
||||
getAIResponse(msgs);
|
||||
}
|
||||
}, [open, initialQuery, getAIResponse]);
|
||||
|
||||
// Reset when closed
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
hasProcessedInitialQuery.current = false;
|
||||
setMessages([]);
|
||||
setInput("");
|
||||
setIsTyping(false);
|
||||
abortControllerRef.current?.abort();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Focus input when opened
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTimeout(() => inputRef.current?.focus(), 200);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const sendMessage = useCallback(() => {
|
||||
const text = input.trim();
|
||||
if (!text || isTyping) return;
|
||||
|
||||
const userMsg: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: "user",
|
||||
content: text,
|
||||
};
|
||||
const updatedMessages = [...messages, userMsg];
|
||||
setMessages(updatedMessages);
|
||||
setInput("");
|
||||
getAIResponse(updatedMessages);
|
||||
}, [input, isTyping, messages, getAIResponse]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed bottom-20 right-5 z-[9998] w-[420px] h-[600px]",
|
||||
"flex flex-col rounded-xl border border-nb-gray-900 bg-nb-gray-950 shadow-2xl",
|
||||
"animate-in slide-in-from-bottom-4 fade-in-0 duration-200",
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-nb-gray-900">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex items-center justify-center w-7 h-7 rounded-lg bg-gradient-to-br from-yellow-400/20 to-orange-400/20">
|
||||
<Sparkles size={15} className="text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white leading-none">
|
||||
NetBird AI Assistant
|
||||
</h3>
|
||||
<span className="text-[11px] text-nb-gray-500">
|
||||
Ask anything about NetBird
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-md text-nb-gray-500 hover:text-white hover:bg-nb-gray-900 transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
|
||||
{messages.length === 0 && !isTyping && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center gap-3 opacity-60">
|
||||
<MessageCircleQuestion
|
||||
size={40}
|
||||
className="text-nb-gray-500"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm text-nb-gray-400 font-medium">
|
||||
How can I help?
|
||||
</p>
|
||||
<p className="text-xs text-nb-gray-500 mt-1">
|
||||
Use the Explain button to click on any element, or ask a
|
||||
question below.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg) =>
|
||||
msg.role === "context" ? (
|
||||
<div key={msg.id} className="flex justify-center">
|
||||
<div className="text-[11px] text-nb-gray-500 bg-nb-gray-900/50 rounded-full px-3 py-1 flex items-center gap-1.5">
|
||||
<Sparkles size={10} className="text-yellow-500/60" />
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={cn(
|
||||
"flex gap-2.5",
|
||||
msg.role === "user" ? "justify-end" : "justify-start",
|
||||
)}
|
||||
>
|
||||
{msg.role === "assistant" && (
|
||||
<div className="flex-shrink-0 w-6 h-6 rounded-md bg-gradient-to-br from-yellow-400/20 to-orange-400/20 flex items-center justify-center mt-0.5">
|
||||
<Bot size={13} className="text-yellow-400" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[85%] rounded-lg px-3 py-2 text-sm leading-relaxed",
|
||||
msg.role === "user"
|
||||
? "bg-indigo-600 text-white"
|
||||
: "bg-nb-gray-900/80 text-nb-gray-200",
|
||||
)}
|
||||
>
|
||||
{msg.content.split("\n").map((line, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{line
|
||||
.split(/(\*\*[^*]+\*\*)/)
|
||||
.map((part, j) =>
|
||||
part.startsWith("**") && part.endsWith("**") ? (
|
||||
<strong
|
||||
key={j}
|
||||
className="font-semibold text-white"
|
||||
>
|
||||
{part.slice(2, -2)}
|
||||
</strong>
|
||||
) : (
|
||||
<React.Fragment key={j}>{part}</React.Fragment>
|
||||
),
|
||||
)}
|
||||
{i < msg.content.split("\n").length - 1 && <br />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
{msg.role === "user" && (
|
||||
<div className="flex-shrink-0 w-6 h-6 rounded-md bg-indigo-600/30 flex items-center justify-center mt-0.5">
|
||||
<User size={13} className="text-indigo-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
|
||||
{isTyping && (
|
||||
<div className="flex gap-2.5">
|
||||
<div className="flex-shrink-0 w-6 h-6 rounded-md bg-gradient-to-br from-yellow-400/20 to-orange-400/20 flex items-center justify-center mt-0.5">
|
||||
<Bot size={13} className="text-yellow-400" />
|
||||
</div>
|
||||
<div className="bg-nb-gray-900/80 rounded-lg px-4 py-3">
|
||||
<div className="flex gap-1.5">
|
||||
<span className="w-1.5 h-1.5 bg-nb-gray-500 rounded-full animate-bounce [animation-delay:0ms]" />
|
||||
<span className="w-1.5 h-1.5 bg-nb-gray-500 rounded-full animate-bounce [animation-delay:150ms]" />
|
||||
<span className="w-1.5 h-1.5 bg-nb-gray-500 rounded-full animate-bounce [animation-delay:300ms]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="px-4 py-3 border-t border-nb-gray-900">
|
||||
<div className="flex items-center gap-2 bg-nb-gray-900/60 rounded-lg px-3 py-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask a follow-up question..."
|
||||
className="flex-1 bg-transparent text-sm text-white placeholder-nb-gray-500 outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={!input.trim() || isTyping}
|
||||
className={cn(
|
||||
"p-1.5 rounded-md transition-colors",
|
||||
input.trim() && !isTyping
|
||||
? "text-yellow-400 hover:bg-nb-gray-800 cursor-pointer"
|
||||
: "text-nb-gray-600 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<Send size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-nb-gray-600 mt-1.5 text-center">
|
||||
AI-powered assistant
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loaded 20 of 111 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user