Compare commits

...
Author SHA1 Message Date
braginini fae360a9f7 Add new explain 2026-04-25 15:44:56 +02:00
braginini 5b3c147eb1 Add reverse proxy explain functionality 2026-04-03 16:00:20 +02:00
braginini 15a5f6bf3d Merge branch 'main' into feature/explain-prototype 2026-04-03 15:49:02 +02:00
shuuri-labs 9701e6503b Add new pull request template + enforce documentation acknowledgement… (#602)
* Add new pull request template + enforce documentation acknowledgement in new workflow

* fix docs-ack workflow: pass PR number via env and simplify checkbox validation
2026-04-02 21:39:38 +02:00
Eduard Gert 0841caecbb Fix dns zone domain validation and peers last seen sort (#595) 2026-03-25 17:50:22 +01:00
Eduard Gert c7846760d1 Add reverse proxy auth headers (#593)
* Add reverse proxy access rules

* Fix coderabbit comments

* Fix coderabbit comments

* Fix coderabbit comments

* Add auth header modal

* Remove password managers from auth headers

* fix unique id

* Remove gradient, fix button roundness

* update lucide, add additional event auth methods

* Clear existing header value on change
2026-03-25 14:31:36 +01:00
Viktor Liu 8c283b6ef9 Support optional subdomain for reverse proxy domains (#589) 2026-03-24 16:01:01 +01:00
Eduard Gert 34ae3b4da6 Add reverse proxy access rules (#592)
* Add reverse proxy access rules

* Fix coderabbit comments

* Fix coderabbit comments

* Fix coderabbit comments
2026-03-24 16:00:31 +01:00
braginini 98f6615074 Add explain to more parts of teh software 2026-03-20 15:35:41 +01:00
Viktor LiuandEduard Gert aff2365ef7 Add layer 4 protocol support to reverse proxy (#579)
* Add layer 4 proto support

* Fix initialResource fallback and UDP session_idle_timeout

* Fix tlsResourceId init for resource-driven create flows, UDP timeout label

* Address PR review: ServiceMode enum, resource init fix, modal title, a11y

* Add L4 protocol values to ReverseProxyTargetProtocol, remove unsafe double cast

* Add aria-labels to L4 port/host inputs

* Unify domain input for all service modes including L4

* Support L4 proxy events

* Fix custom port reset on edit and show port in L4 service link

* Remove redundant listen port from L4 target cell

* Show link only for HTTP/TLS services, copy-on-click for TCP/UDP

* Move mode badge before domain and use fixed width for alignment

* Fix HTTP services to open as link instead of copy

* Hide old proxy clusters from L4 domain selector

* Move service type inside modal

* Update auth cell

* Add target selector component

* Extract into separate components

* hide services types for not supported clusters

* Remove advanced settings tab in http targetmodal and use accordion instead

* Update advanced settings

* Update target device row

* Update text

* Add type cell

* Fix flat target name cell

* Update modal title

* Fix edit target in flat table

* Remove unused proxycluster interface

* Move proxy type icon into type component

* sync cloud

* use emptyrow

* fix l4 type

* fix duplicate error notification

* Set the correct target type

* Fix subnet host editable

* Fix subnet host editable

* hide selector when initial resource or peer

* Rename dropdown

* Update text

* update status cell

* merge cloud

* Update tooltips

* Address coderabbit comments

* Fix skeleton device card

* Update listen port tooltip

* Adjust padding

* update package-lock.json

* bump next to 16.1.7

---------

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
2026-03-18 17:43:00 +01:00
braginini 45058a24f8 Support new styling 2026-03-16 20:05:56 +01:00
braginini 7141c8434d Merge branch 'main' into feature/explain-prototype 2026-03-16 19:17:16 +01:00
braginini 7e15008760 Add new version of the explain lib 2026-03-16 19:17:03 +01:00
Zoltan PappandEduard Gert bad057d415 [dashboard] feat: add auto_update_always toggle to client settings (#580)
* [dashboard] feat: add auto_update_always toggle to client settings

Add "Always Update" toggle to the Clients settings tab that controls
whether updates are installed automatically in the background or require
user interaction from the UI. Includes a warning icon and caution callout
when enabled to highlight the risk of disrupting active connections.

* [dashboard] fix: improve auto-update UI clarity and toggle label

Clarify that automatic updates require user interaction by updating the
description. Rename "Always Update" to "Force Automatic Updates" for
clarity. Move warning callout below the toggle switch instead of inside it.

* Update src/modules/settings/ClientSettingsTab.tsx

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>

---------

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
2026-03-16 15:35:44 +01:00
braginini e7d470e3fc Add lib prototype 2026-03-13 13:42:19 +01:00
Misha Bragin 4d846e2c94 Improve text for optional resource setiings (#584) 2026-03-12 20:48:09 +01:00
braginini b705485981 Add Explain button prototype 2026-03-12 18:21:36 +01:00
braginini f4acdba1ef Add Explain button prototype 2026-03-12 17:44:01 +01:00
Eduard Gert 15fb6e0b05 Refactor resource modal (#582) 2026-03-12 16:30:51 +01:00
Eduard Gert 55c5525626 Fix resource group policy when adding single resource as destination (#581) 2026-03-11 19:23:59 +01:00
83 changed files with 7554 additions and 1605 deletions

No files matched your search

+12
View File
@@ -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/__
+105
View File
@@ -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
View File
@@ -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;
+2371 -88
View File
File diff suppressed because it is too large. Load diff
+7 -3
View File
@@ -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",
+9 -1
View File
@@ -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,
@@ -99,6 +100,7 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
<PageContainer>
<NetworkAccessControlProvider>
<NetworkProvider network={network}>
<div>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
@@ -131,7 +133,10 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
description={network.description}
/>
</div>
<NetworkActions />
<div className="flex items-center gap-2">
<ExplainButton />
<NetworkActions />
</div>
</div>
</div>
@@ -192,6 +197,7 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
/>
</TabsContent>
</Tabs>
</div>
</NetworkProvider>
</NetworkAccessControlProvider>
</PageContainer>
@@ -282,6 +288,7 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
<Card.List>
<Card.ListItem
tooltip={false}
data-nb-explain="High Availability"
label={
<>
<ServerIcon size={16} />
@@ -327,6 +334,7 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
/>
<Card.ListItem
tooltip={false}
data-nb-explain="Active Policies"
label={
policyCount > 0 ? (
<>
+195
View File
@@ -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);
}
+5 -1
View File
@@ -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>
);
}
+36 -14
View File
@@ -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 };
+1 -1
View File
@@ -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",
+9 -7
View File
@@ -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>
);
+11 -1
View File
@@ -85,6 +85,7 @@ interface MultiSelectProps {
users?: User[];
placeholderForSearch?: string;
resourceIds?: string[];
additionalResources?: NetworkResource[];
policies?: Policy[];
}
export function PeerGroupSelector({
@@ -117,12 +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");
+4
View File
@@ -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,
)}
>
+42 -9
View File
@@ -77,23 +77,56 @@ const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
extra?: React.ReactNode;
icon?: React.ReactNode;
description?: React.ReactNode;
}
>(({ className, children, extra, ...props }, ref) => (
>(({ 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>
));
@@ -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>
);
}
+327
View File
@@ -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>
);
}
@@ -0,0 +1,74 @@
"use client";
import { cn } from "@utils/helpers";
import { MessageCircleQuestion, Sparkles } from "lucide-react";
import React, { forwardRef } from "react";
type Props = {
x: number;
y: number;
selectedText: string;
onExplain: () => void;
onClose: () => void;
};
const AIContextMenu = forwardRef<HTMLDivElement, Props>(
({ x, y, selectedText, onExplain, onClose }, ref) => {
// Adjust position to stay within viewport
const adjustedX = Math.min(x, window.innerWidth - 220);
const adjustedY = Math.min(y, window.innerHeight - 100);
const truncatedText =
selectedText.length > 40
? selectedText.slice(0, 40) + "..."
: selectedText;
return (
<div
ref={ref}
className={cn(
"fixed z-[9999] min-w-[200px] rounded-lg border",
"border-nb-gray-900 bg-nb-gray-940 shadow-2xl",
"animate-in fade-in-0 zoom-in-95 duration-100",
"py-1",
)}
style={{ left: adjustedX, top: adjustedY }}
>
<div className="px-3 py-1.5 border-b border-nb-gray-900">
<div className="flex items-center gap-1.5 text-[11px] text-nb-gray-500 font-medium uppercase tracking-wider">
<Sparkles size={12} className="text-yellow-400" />
AI Assistant
</div>
</div>
<button
className={cn(
"w-full flex items-center gap-2.5 px-3 py-2 text-sm text-nb-gray-300",
"hover:bg-nb-gray-900 hover:text-white transition-colors cursor-pointer",
"outline-none",
)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onExplain();
}}
>
<MessageCircleQuestion size={15} className="text-yellow-400 shrink-0" />
<div className="flex flex-col items-start gap-0.5">
<span className="font-medium">Explain</span>
<span className="text-[11px] text-nb-gray-500 leading-tight truncate max-w-[160px]">
&quot;{truncatedText}&quot;
</span>
</div>
</button>
</div>
);
},
);
AIContextMenu.displayName = "AIContextMenu";
export default AIContextMenu;
@@ -0,0 +1,29 @@
"use client";
import { cn } from "@utils/helpers";
import { MessageCircleQuestion, X } from "lucide-react";
import React from "react";
type Props = {
isOpen: boolean;
onClick: () => void;
};
export default function AIFloatingButton({ isOpen, onClick }: Props) {
return (
<button
onClick={onClick}
className={cn(
"fixed bottom-5 right-5 z-[9997] w-12 h-12 rounded-full",
"flex items-center justify-center",
"shadow-lg transition-all duration-200 cursor-pointer",
"hover:scale-105 active:scale-95",
isOpen
? "bg-nb-gray-800 text-nb-gray-400 hover:text-white"
: "bg-gradient-to-br from-yellow-500 to-orange-500 text-white hover:from-yellow-400 hover:to-orange-400",
)}
>
{isOpen ? <X size={20} /> : <MessageCircleQuestion size={22} />}
</button>
);
}
+1
View File
@@ -38,6 +38,7 @@ const ModalOverlay = React.forwardRef<
"bg-black/30 dark:bg-black/40 backdrop-blur-sm",
className,
)}
style={{ scrollbarGutter: "stable both-edges" }}
{...props}
/>
));
+25 -7
View File
@@ -48,6 +48,9 @@ interface SelectDropdownProps {
children?: React.ReactNode;
maxHeight?: number;
triggerClassName?: string;
iconSize?: number;
truncate?: boolean;
compact?: boolean;
}
export function SelectDropdown({
@@ -68,6 +71,9 @@ export function SelectDropdown({
children,
maxHeight,
triggerClassName,
iconSize = 14,
truncate = false,
compact = false,
}: Readonly<SelectDropdownProps>) {
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
@@ -107,15 +113,18 @@ export function SelectDropdown({
const SelectedItem = () => {
return (
<div className={"flex items-center gap-2.5"}>
{selected?.icon && <selected.icon size={14} width={14} />}
<div className={cn("flex items-center gap-2.5", truncate && "min-w-0")}>
{selected?.icon && <selected.icon size={iconSize} width={iconSize} />}
<div
className={cn(
"flex flex-col text-sm font-medium",
size === "xs" && "text-xs",
truncate && "min-w-0",
)}
>
<span className={"text-nb-gray-200"}>{selected?.label}</span>
<span className={cn("text-nb-gray-200", truncate && "truncate")}>
{selected?.label}
</span>
</div>
</div>
);
@@ -216,20 +225,22 @@ export function SelectDropdown({
<ScrollArea
className={cn(
"overflow-y-auto flex flex-col gap-1 pl-2 pr-3",
!showSearch && "pt-2",
"overflow-y-auto flex flex-col gap-1",
compact ? "pl-1 pr-1" : "pl-2 pr-3",
!showSearch && (compact ? "pt-1" : "pt-2"),
)}
style={{
maxHeight: maxHeight ?? 380,
}}
>
<CommandGroup>
<div className={"grid grid-cols-1 gap-1 pb-2 w-full"}>
<div className={cn("grid grid-cols-1 gap-1 w-full", compact ? "pb-1" : "pb-2")}>
{filteredItems.map((option) => (
<SelectDropdownItem
option={option}
toggle={toggle}
key={option.value}
iconSize={iconSize}
showValue={showValues}
size={size}
/>
@@ -249,11 +260,13 @@ const SelectDropdownItem = ({
toggle,
showValue = false,
size = "sm",
iconSize = 14,
}: {
option: SelectOption;
toggle: (value: string) => void;
showValue?: boolean;
size: "xs" | "sm";
iconSize?: number;
}) => {
const value = option.value || "" + option.label || "";
const elementRef = useRef<HTMLDivElement>(null);
@@ -285,7 +298,12 @@ const SelectDropdownItem = ({
option?.disabled && "cursor-not-allowed",
)}
>
{option.icon && <option.icon size={14} width={14} />}
{option.icon && (
<div className={"shrink-0"}>
<option.icon size={iconSize} width={iconSize} />
</div>
)}
{option?.renderItem && option.renderItem()}
{!option?.renderItem && (
<div
Loaded 20 of 83 files, more files were not shown because too many files have changed in this diff. Show more