feat: one click installation for popular MCP servers (#657)

* backend configuration

* frontend

* fmt

* adapt remote config

* lock

* opencode adapter
This commit is contained in:
Gabriel Gordon-Hall
2025-09-10 10:39:45 +01:00
committed by GitHub
parent c79f0a200d
commit 4c5be4e807
13 changed files with 755 additions and 74 deletions

View File

@@ -20,6 +20,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-portal": "^1.1.9",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
@@ -1964,6 +1965,67 @@
}
}
},
"node_modules/@radix-ui/react-scroll-area": {
"version": "1.2.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz",
"integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-presence": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-select": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz",

View File

@@ -43,6 +43,7 @@
"click-to-react-component": "^1.1.2",
"clsx": "^2.0.0",
"diff": "^8.0.2",
"embla-carousel-react": "^8.6.0",
"fancy-ansi": "^0.1.3",
"lucide-react": "^0.539.0",
"react": "^18.2.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" id="Playwright--Streamline-Svg-Logos" height="24" width="24">
<desc>
Playwright Streamline Icon: https://streamlinehq.com
</desc>
<path fill="#2d4552" d="M7.99585 13.141725c-0.87725 0.248975 -1.452775 0.685475 -1.8319 1.12165 0.363125 -0.317775 0.849525 -0.609425 1.505675 -0.795425 0.6711 -0.1902 1.243625 -0.188825 1.7167 -0.09755v-0.369925c-0.40355 -0.0369 -0.866225 -0.0075 -1.390475 0.14125Zm-1.872 -3.109775 -3.25795 0.858325s0.059375 0.083875 0.1693 0.195775l2.76235 -0.727875s-0.03915 0.5044 -0.379075 0.9556c0.643 -0.486475 0.705375 -1.281825 0.705375 -1.281825Zm2.727125 7.65675C4.26615 18.923575 1.8404825 13.61025 1.1060875 10.852425c-0.3393 -1.273 -0.487415 -2.2371 -0.5268925 -2.859275 -0.0042425 -0.0646 -0.0022825 -0.11905 0.002285 -0.16895 -0.237835 0.01435 -0.3517015 0.137975 -0.328535 0.49525 0.0394775 0.621825 0.187595 1.585875 0.526895 2.859275C1.5139075 13.936125 3.9399 19.24945 8.52475 18.0146c0.99795 -0.26885 1.747675 -0.758525 2.310475 -1.383625 -0.51875 0.468525 -1.168 0.8375 -1.98425 1.057725Zm0.861575 -10.90855v0.3263h1.79835c-0.0369 -0.115525 -0.074075 -0.219625 -0.110975 -0.3263h-1.687375Z" stroke-width="0.25"></path>
<path fill="#2d4552" d="M11.9129 9.46735c0.80875 0.229675 1.2365 0.7967 1.462575 1.2985l0.90175 0.2561s-0.123 -1.756175 -1.711525 -2.2074c-1.486075 -0.422225 -2.400575 0.8257 -2.5118 0.9872 0.4323 -0.308 1.063575 -0.56015 1.859 -0.3344Zm7.178175 1.3066c-1.487425 -0.424125 -2.401575 0.8264 -2.511175 0.985625 0.432625 -0.307625 1.063575 -0.559875 1.85865 -0.3331 0.80745 0.23005 1.23485 0.796375 1.461625 1.298525l0.90305 0.25705s-0.125 -1.756525 -1.71215 -2.2081Zm-0.8959 4.6305 -7.501475 -2.097125s0.0812 0.411725 0.3928 0.94485l6.3159 1.765675c0.519975 -0.30085 0.792775 -0.6134 0.792775 -0.6134ZM12.994375 19.918475C7.054675 18.326 7.77275 10.758025 8.733875 7.171825c0.395725 -1.4779 0.802575 -2.576375 1.13995 -3.312725 -0.2013 -0.041425 -0.368025 0.0646 -0.532775 0.39965 -0.358225 0.726575 -0.8163 1.90955 -1.259625 3.5656 -0.96085 3.586125 -1.67895 11.15385 4.2605 12.746325 2.79955 0.75 4.980475 -0.3899 6.60625 -2.18005 -1.543175 1.3977 -3.513425 2.181325 -5.9538 1.52785Z" stroke-width="0.25"></path>
<path fill="#e2574c" d="M9.7126 15.915175V14.388l-4.243175 1.2032s0.313525 -1.82175 2.526475 -2.4495c0.6711 -0.1902 1.2437 -0.1889 1.7167 -0.09755V6.780125h2.124575c-0.231325 -0.714825 -0.4551 -1.26515 -0.64305 -1.64755 -0.310925 -0.632925 -0.62965 -0.21335 -1.35325 0.39185 -0.50965 0.425775 -1.797675 1.33405 -3.7359 1.85635 -1.938275 0.522625 -3.50525 0.384025 -4.15906 0.2708 -0.9268825 -0.1599 -1.4116925 -0.36345 -1.3663375 0.34155 0.03947 0.621825 0.187595 1.58595 0.5268925 2.859275C1.8405325 13.609875 4.266525 18.9232 8.85135 17.68835c1.197625 -0.3227 2.04295 -0.960525 2.6289 -1.7735h-1.76765v0.000325ZM2.865625 10.89025l3.258275 -0.858325s-0.094975 1.25345 -1.31645 1.57545c-1.2218 0.321675 -1.941825 -0.717125 -1.941825 -0.717125Z" stroke-width="0.25"></path>
<path fill="#2ead33" d="M21.975075 6.8525c-0.84695 0.148475 -2.878875 0.33345 -5.389975 -0.339625 -2.5118 -0.672675 -4.17835 -1.849175 -4.838625 -2.402175 -0.936 -0.783975 -1.347725 -1.328825 -1.752925 -0.5047 -0.358225 0.726875 -0.816325 1.909875 -1.259725 3.565925 -0.960775 3.586125 -1.67885 11.15385 4.260525 12.7463 5.938125 1.591125 9.09945 -5.322175 10.0603 -8.908625 0.4434 -1.655725 0.637825 -2.9095 0.691325 -3.717925 0.061 -0.915775 -0.568025 -0.64995 -1.7709 -0.439175ZM10.0418 9.81945s0.936 -1.45575 2.523525 -1.00455c1.588525 0.451225 1.711525 2.207425 1.711525 2.207425l-4.23505 -1.202875ZM13.917 16.352c-2.79235 -0.817975 -3.223 -3.04465 -3.223 -3.04465l7.501125 2.0972c0 -0.00035 -1.5141 1.755175 -4.278125 0.94745Zm2.6521 -4.57605s0.9347 -1.45475 2.521925 -1.00225c1.587175 0.4519 1.71215 2.2081 1.71215 2.2081l-4.234075 -1.20585Z" stroke-width="0.25"></path>
<path fill="#d65348" d="M8.2299 14.808525 5.4695 15.590875s0.29985 -1.708225 2.33335 -2.385175l-1.563075 -5.865975 -0.135075 0.04105c-1.93825 0.5227 -3.505225 0.384025 -4.15903 0.2708 -0.9268775 -0.159825 -1.411685 -0.36345 -1.3663375 0.341625 0.0394775 0.621825 0.187595 1.585875 0.5268925 2.85925 0.7340675 2.757425 3.160075 8.07075 7.744875 6.8359l0.135075 -0.042425 -0.756275 -2.8374ZM2.8657 10.8903l3.258275 -0.858375s-0.094975 1.25345 -1.316425 1.57545c-1.221825 0.321675 -1.94185 -0.717075 -1.94185 -0.717075Z" stroke-width="0.25"></path>
<path fill="#1d8d22" d="m14.04295 16.382625 -0.1263 -0.0307c-2.792325 -0.8179 -3.223 -3.044575 -3.223 -3.044575l3.86805 1.0812 2.047825 -7.86915 -0.024775 -0.006525c-2.5118 -0.672675 -4.17825 -1.849175 -4.838625 -2.402175 -0.936 -0.783975 -1.347725 -1.328825 -1.752925 -0.5047 -0.357875 0.726875 -0.815975 1.909875 -1.259375 3.565925 -0.960775 3.586125 -1.67885 11.15385 4.260525 12.74625l0.121725 0.027425 0.926875 -3.562975ZM10.0418 9.819475s0.936 -1.455775 2.523525 -1.004575c1.588525 0.451225 1.711525 2.207425 1.711525 2.207425l-4.23505 -1.20285Z" stroke-width="0.25"></path>
<path fill="#c04b41" d="m8.37055 14.7683 -0.740275 0.2101c0.174875 0.9859 0.483125 1.93205 0.966975 2.7679 0.0842 -0.0186 0.167725 -0.034575 0.2535 -0.058075 0.2248 -0.06065 0.43325 -0.13575 0.63395 -0.21765 -0.5406 -0.802225 -0.898225 -1.726175 -1.11415 -2.702275Zm-0.289075 -6.9439c-0.3804 1.419825 -0.720725 3.46345 -0.62705 5.51325 0.167675 -0.072775 0.3448 -0.140575 0.54155 -0.1964l0.13705 -0.030625c-0.167075 -2.189525 0.194075 -4.4207 0.600925 -5.93875 0.103125 -0.384025 0.206525 -0.741225 0.3096 -1.07435 -0.166025 0.105675 -0.3448 0.213975 -0.548425 0.32555 -0.137325 0.42385 -0.276 0.887125 -0.41365 1.401325Z" stroke-width="0.25"></path>
</svg>

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

@@ -0,0 +1,260 @@
import * as React from 'react';
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from 'embla-carousel-react';
import { ArrowLeft, ArrowRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: 'horizontal' | 'vertical';
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error('useCarousel must be used within a <Carousel />');
}
return context;
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = 'horizontal',
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === 'horizontal' ? 'x' : 'y',
},
plugins
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return;
}
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'ArrowLeft') {
event.preventDefault();
scrollPrev();
} else if (event.key === 'ArrowRight') {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext]
);
React.useEffect(() => {
if (!api || !setApi) {
return;
}
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) {
return;
}
onSelect(api);
api.on('reInit', onSelect);
api.on('select', onSelect);
return () => {
api?.off('select', onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn('relative', className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
);
Carousel.displayName = 'Carousel';
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
'flex',
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
className
)}
{...props}
/>
</div>
);
});
CarouselContent.displayName = 'CarouselContent';
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel();
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
'min-w-0 shrink-0 grow-0 basis-full',
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
className
)}
{...props}
/>
);
});
CarouselItem.displayName = 'CarouselItem';
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
'absolute h-8 w-8 rounded-full',
orientation === 'horizontal'
? '-left-12 top-1/2 -translate-y-1/2'
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
);
});
CarouselPrevious.displayName = 'CarouselPrevious';
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
'absolute h-8 w-8 rounded-full',
orientation === 'horizontal'
? '-right-12 top-1/2 -translate-y-1/2'
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
);
});
CarouselNext.displayName = 'CarouselNext';
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};

View File

@@ -53,32 +53,31 @@ export class McpConfigStrategyGeneral {
return current;
}
static addVibeKanbanToConfig(
static addPreconfiguredToConfig(
mcp_config: McpConfig,
existingConfig: Record<string, any>
existingConfig: Record<string, any>,
serverKey: string
): Record<string, any> {
// Clone the existing config to avoid mutations
const updatedConfig = JSON.parse(JSON.stringify(existingConfig));
let current = updatedConfig;
const preconf = mcp_config.preconfigured as Record<string, any>;
if (!preconf || typeof preconf !== 'object' || !(serverKey in preconf)) {
throw new Error(`Unknown preconfigured server '${serverKey}'`);
}
const updated = JSON.parse(JSON.stringify(existingConfig || {}));
let current = updated;
// Navigate to the correct location for servers (all except the last element)
for (let i = 0; i < mcp_config.servers_path.length - 1; i++) {
const key = mcp_config.servers_path[i];
if (!current[key]) {
current[key] = {};
}
if (!current[key] || typeof current[key] !== 'object') current[key] = {};
current = current[key];
}
// Get or create the servers object at the final path element
const lastKey = mcp_config.servers_path[mcp_config.servers_path.length - 1];
if (!current[lastKey]) {
if (!current[lastKey] || typeof current[lastKey] !== 'object')
current[lastKey] = {};
}
// Add vibe_kanban server with the config from the schema
current[lastKey]['vibe_kanban'] = mcp_config.vibe_kanban;
current[lastKey][serverKey] = preconf[serverKey];
return updatedConfig;
return updated;
}
}

View File

@@ -14,6 +14,13 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from '@/components/ui/carousel';
import { Label } from '@/components/ui/label';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { JSONEditor } from '@/components/ui/json-editor';
@@ -119,29 +126,6 @@ export function McpSettings() {
}
};
const handleConfigureVibeKanban = async () => {
if (!selectedProfile || !mcpConfig) return;
try {
// Parse existing configuration
const existingConfig = mcpServers.trim() ? JSON.parse(mcpServers) : {};
// Add vibe_kanban to the existing configuration using the schema
const updatedConfig = McpConfigStrategyGeneral.addVibeKanbanToConfig(
mcpConfig,
existingConfig
);
// Update the textarea with the new configuration
const configJson = JSON.stringify(updatedConfig, null, 2);
setMcpServers(configJson);
setMcpError(null);
} catch (err) {
setMcpError('Failed to configure vibe-kanban MCP server');
console.error('Error configuring vibe-kanban:', err);
}
};
const handleApplyMcpServers = async () => {
if (!selectedProfile || !mcpConfig) return;
@@ -200,6 +184,36 @@ export function McpSettings() {
}
};
const addServer = (key: string) => {
try {
const existing = mcpServers.trim() ? JSON.parse(mcpServers) : {};
const updated = McpConfigStrategyGeneral.addPreconfiguredToConfig(
mcpConfig!,
existing,
key
);
setMcpServers(JSON.stringify(updated, null, 2));
setMcpError(null);
} catch (err) {
console.error(err);
setMcpError(
err instanceof Error
? err.message
: 'Failed to add preconfigured server'
);
}
};
const preconfigured = (mcpConfig?.preconfigured ?? {}) as Record<string, any>;
const meta = (preconfigured.meta ?? {}) as Record<
string,
{ name?: string; description?: string; url?: string; icon?: string }
>;
const servers = Object.fromEntries(
Object.entries(preconfigured).filter(([k]) => k !== 'meta')
) as Record<string, any>;
const getMetaFor = (key: string) => meta[key] || {};
if (!config) {
return (
<div className="py-8">
@@ -324,18 +338,83 @@ export function McpSettings() {
)}
</div>
<div className="pt-4">
<Button
onClick={handleConfigureVibeKanban}
disabled={mcpApplying || mcpLoading || !selectedProfile}
className="w-64"
>
Add Vibe-Kanban MCP
</Button>
<p className="text-sm text-muted-foreground mt-2">
Automatically adds the Vibe-Kanban MCP server configuration.
</p>
</div>
{mcpConfig?.preconfigured &&
typeof mcpConfig.preconfigured === 'object' && (
<div className="pt-4">
<Label>Popular servers</Label>
<p className="text-sm text-muted-foreground mb-2">
Click a card to insert that MCP Server into the JSON
above.
</p>
<div className="relative overflow-hidden rounded-xl border bg-background">
<Carousel className="w-full px-4 py-3">
<CarouselContent className="gap-3 justify-center">
{Object.entries(servers).map(([key]) => {
const metaObj = getMetaFor(key) as {
name?: string;
description?: string;
url?: string;
icon?: string;
};
const name = metaObj.name || key;
const description =
metaObj.description || 'No description';
const icon = metaObj.icon
? `/${metaObj.icon}`
: null;
return (
<CarouselItem
key={name}
className="sm:basis-1/3 lg:basis-1/4"
>
<button
type="button"
onClick={() => addServer(key)}
aria-label={`Add ${name} to config`}
className="group w-full text-left outline-none"
>
<Card className="h-32 rounded-xl border hover:shadow-md transition">
<CardHeader className="pb-0">
<div className="flex items-center gap-3">
<div className="w-6 h-6 rounded-lg border bg-muted grid place-items-center overflow-hidden">
{icon ? (
<img
src={icon}
alt=""
className="w-full h-full object-cover"
/>
) : (
<span className="font-semibold">
{name.slice(0, 1).toUpperCase()}
</span>
)}
</div>
<CardTitle className="text-base font-medium truncate">
{name}
</CardTitle>
</div>
</CardHeader>
<CardContent className="pt-2 px-4">
<p className="text-sm text-muted-foreground line-clamp-3">
{description}
</p>
</CardContent>
</Card>
</button>
</CarouselItem>
);
})}
</CarouselContent>
<CarouselPrevious className="left-2 top-1/2 -translate-y-1/2 h-8 w-8 rounded-full border bg-background/80 shadow-sm backdrop-blur hover:bg-background" />
<CarouselNext className="right-2 top-1/2 -translate-y-1/2 h-8 w-8 rounded-full border bg-background/80 shadow-sm backdrop-blur hover:bg-background" />
</Carousel>
</div>
</div>
)}
</div>
)}
</CardContent>