feat: Visual CoPilot module + collapsible sidebar

- Add visual-copilot module (iframe embed, env: NEXT_PUBLIC_VIM_URL)
- Sidebar collapse to icon-only with localStorage persistence
- Tooltips on collapsed nav items
- Full-viewport layout for canvas routes (/visual-copilot)
- Register module in modules.ts + feature flag in flags.ts
This commit is contained in:
Marius Tarau
2026-03-01 03:52:43 +02:00
parent 5ca276fb26
commit afdd349631
9 changed files with 297 additions and 74 deletions
Binary file not shown.
@@ -0,0 +1,5 @@
import { VisualCopilotModule } from "@/modules/visual-copilot";
export default function VisualCopilotPage() {
return <VisualCopilotModule />;
}
+8
View File
@@ -98,6 +98,14 @@ export const DEFAULT_FLAGS: FeatureFlag[] = [
category: "module",
overridable: true,
},
{
key: "module.visual-copilot",
enabled: true,
label: "Visual CoPilot",
description: "Canvas AI pentru vizualizare arhitecturală",
category: "module",
overridable: true,
},
{
key: "module.hot-desk",
enabled: true,
+4 -2
View File
@@ -14,6 +14,7 @@ import { tagManagerConfig } from "@/modules/tag-manager/config";
import { miniUtilitiesConfig } from "@/modules/mini-utilities/config";
import { aiChatConfig } from "@/modules/ai-chat/config";
import { hotDeskConfig } from "@/modules/hot-desk/config";
import { visualCopilotConfig } from "@/modules/visual-copilot/config";
/**
* Toate configurările modulelor ArchiTools, ordonate după navOrder.
@@ -31,8 +32,9 @@ export const MODULE_CONFIGS: ModuleConfig[] = [
hotDeskConfig, // navOrder: 33 | management
tagManagerConfig, // navOrder: 40 | tools
miniUtilitiesConfig, // navOrder: 41 | tools
promptGeneratorConfig, // navOrder: 50 | ai
aiChatConfig, // navOrder: 51 | ai
promptGeneratorConfig, // navOrder: 50 | ai
aiChatConfig, // navOrder: 51 | ai
visualCopilotConfig, // navOrder: 52 | ai
];
// Înregistrare automată a tuturor modulelor în registru
@@ -0,0 +1,62 @@
"use client";
import { ExternalLink, AlertTriangle, Maximize2 } from "lucide-react";
import { useState } from "react";
const VIM_URL = process.env.NEXT_PUBLIC_VIM_URL ?? "";
export function VisualCopilotModule() {
const [isFullscreen, setIsFullscreen] = useState(false);
if (!VIM_URL) {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 text-muted-foreground">
<AlertTriangle className="h-8 w-8 text-amber-500" />
<div className="text-center">
<p className="text-sm font-medium text-foreground">
Visual CoPilot nu este configurat
</p>
<p className="mt-1 text-xs">
Setează{" "}
<code className="rounded bg-muted px-1 py-0.5 text-xs">
NEXT_PUBLIC_VIM_URL
</code>{" "}
în fișierul .env
</p>
</div>
</div>
);
}
return (
<div className="relative h-full w-full">
<iframe
src={VIM_URL}
className="h-full w-full border-0"
title="Visual CoPilot"
allow="fullscreen"
/>
{/* Floating action bar */}
<div className="absolute bottom-4 right-4 flex items-center gap-1.5">
<button
onClick={() => setIsFullscreen(!isFullscreen)}
className="flex items-center gap-1.5 rounded-md bg-background/80 px-2 py-1.5 text-xs text-muted-foreground backdrop-blur transition-colors hover:text-foreground"
title="Deschide fullscreen"
>
<Maximize2 className="h-3 w-3" />
</button>
<a
href={VIM_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 rounded-md bg-background/80 px-2 py-1.5 text-xs text-muted-foreground backdrop-blur transition-colors hover:text-foreground"
title="Deschide în tab nou"
>
<ExternalLink className="h-3 w-3" />
Tab nou
</a>
</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import type { ModuleConfig } from "@/core/module-registry/types";
export const visualCopilotConfig: ModuleConfig = {
id: "visual-copilot",
name: "Visual CoPilot",
description:
"Canvas AI pentru vizualizare arhitecturală — generare imagini cu noduri conectate",
icon: "image",
route: "/visual-copilot",
category: "ai",
featureFlag: "module.visual-copilot",
visibility: "all",
version: "0.1.0",
dependencies: [],
storageNamespace: "visual-copilot",
navOrder: 52,
tags: ["ai", "imagine", "canvas", "arhitectura", "generare", "flux"],
};
+2
View File
@@ -0,0 +1,2 @@
export { VisualCopilotModule } from "./components/visual-copilot-module";
export { visualCopilotConfig } from "./config";
+41 -7
View File
@@ -1,35 +1,69 @@
'use client';
import { useState } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { usePathname } from 'next/navigation';
import { Sidebar } from './sidebar';
import { Header } from './header';
import { Sheet, SheetContent } from '@/shared/components/ui/sheet';
import { cn } from '@/shared/lib/utils';
const SIDEBAR_COLLAPSED_KEY = 'sidebar:collapsed';
// Routes that need full viewport height and no padding (canvas tools)
const FULLSCREEN_ROUTES = ['/visual-copilot'];
interface AppShellProps {
children: React.ReactNode;
}
export function AppShell({ children }: AppShellProps) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const pathname = usePathname();
const [mobileOpen, setMobileOpen] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const [mounted, setMounted] = useState(false);
// Read persisted state after mount (avoids SSR mismatch)
useEffect(() => {
const stored = localStorage.getItem(SIDEBAR_COLLAPSED_KEY);
if (stored === 'true') setCollapsed(true);
setMounted(true);
}, []);
const handleToggle = useCallback(() => {
setCollapsed((prev) => {
const next = !prev;
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(next));
return next;
});
}, []);
const isFullscreen = FULLSCREEN_ROUTES.some((r) => pathname.startsWith(r));
return (
<div className="flex h-screen overflow-hidden bg-background">
{/* Desktop sidebar */}
<div className="hidden lg:block">
<Sidebar />
{mounted && (
<Sidebar collapsed={collapsed} onToggle={handleToggle} />
)}
</div>
{/* Mobile sidebar */}
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side="left" className="w-64 p-0">
<Sidebar />
<Sidebar collapsed={false} onToggle={() => setMobileOpen(false)} />
</SheetContent>
</Sheet>
{/* Main area */}
<div className="flex flex-1 flex-col overflow-hidden">
<Header onToggleSidebar={() => setSidebarOpen(true)} />
<main className="flex-1 overflow-y-auto p-6">
<Header onToggleSidebar={() => setMobileOpen(true)} />
<main
className={cn(
'flex-1 overflow-y-auto',
isFullscreen ? 'overflow-hidden p-0' : 'p-6',
)}
>
{children}
</main>
</div>
+157 -65
View File
@@ -6,6 +6,7 @@ import { usePathname } from "next/navigation";
import { useMemo, useState, useCallback, useEffect } from "react";
import { useTheme } from "next-themes";
import * as Icons from "lucide-react";
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { buildNavigation } from "@/config/navigation";
import { COMPANIES } from "@/config/companies";
import { useFeatureFlag } from "@/core/feature-flags";
@@ -13,6 +14,14 @@ import { useAuth } from "@/core/auth/auth-provider";
import { cn } from "@/shared/lib/utils";
import { ScrollArea } from "@/shared/components/ui/scroll-area";
import { Separator } from "@/shared/components/ui/separator";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
const SIDEBAR_COLLAPSED_KEY = "sidebar:collapsed";
function DynamicIcon({
name,
@@ -37,6 +46,7 @@ function DynamicIcon({
function NavItem({
item,
isActive,
collapsed,
}: {
item: {
id: string;
@@ -46,20 +56,36 @@ function NavItem({
featureFlag: string;
};
isActive: boolean;
collapsed: boolean;
}) {
const enabled = useFeatureFlag(item.featureFlag);
if (!enabled) return null;
const linkClass = cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors",
collapsed && "justify-center px-2",
isActive
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground",
);
if (collapsed) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Link href={item.href} className={linkClass}>
<DynamicIcon name={item.icon} className="h-4 w-4 shrink-0" />
</Link>
</TooltipTrigger>
<TooltipContent side="right">
<p>{item.label}</p>
</TooltipContent>
</Tooltip>
);
}
return (
<Link
href={item.href}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors",
isActive
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground",
)}
>
<Link href={item.href} className={linkClass}>
<DynamicIcon name={item.icon} className="h-4 w-4 shrink-0" />
<span className="truncate">{item.label}</span>
</Link>
@@ -80,7 +106,6 @@ const COMPANY_GLOW: Record<string, string> = {
"studii-de-teren": "hover:drop-shadow-[0_0_6px_#f59e0b]",
};
// Secret combo: click logos in order BTG → US → SDT to trigger confetti
const SECRET_COMBO = ["beletage", "urban-switch", "studii-de-teren"];
function SidebarLogos() {
@@ -94,14 +119,12 @@ function SidebarLogos() {
const isDark = mounted ? resolvedTheme === "dark" : false;
// Reset combo after 3 seconds of inactivity
useEffect(() => {
if (comboProgress === 0) return;
const timer = setTimeout(() => setComboProgress(0), 3000);
return () => clearTimeout(timer);
}, [comboProgress]);
// Hide confetti after 2 seconds
useEffect(() => {
if (!showConfetti) return;
const timer = setTimeout(() => setShowConfetti(false), 2000);
@@ -113,11 +136,9 @@ function SidebarLogos() {
e.preventDefault();
e.stopPropagation();
// Trigger individual animation
setAnimatingId(companyId);
setTimeout(() => setAnimatingId(null), 500);
// Check secret combo
if (SECRET_COMBO[comboProgress] === companyId) {
const next = comboProgress + 1;
if (next >= SECRET_COMBO.length) {
@@ -160,7 +181,6 @@ function SidebarLogos() {
)}
title={company.shortName}
>
{/* Light variant — hidden in dark mode */}
<Image
src={lightSrc}
alt={company.shortName}
@@ -172,7 +192,6 @@ function SidebarLogos() {
)}
suppressHydrationWarning
/>
{/* Dark variant — hidden in light mode */}
<Image
src={darkSrc ?? lightSrc}
alt={company.shortName}
@@ -188,7 +207,6 @@ function SidebarLogos() {
);
})}
{/* Confetti burst on secret combo */}
{showConfetti && (
<div className="pointer-events-none absolute -bottom-6 left-1/2 -translate-x-1/2 z-50">
<div className="flex gap-0.5 animate-[fadeIn_0.2s_ease-in]">
@@ -211,7 +229,12 @@ function SidebarLogos() {
);
}
export function Sidebar() {
interface SidebarProps {
collapsed: boolean;
onToggle: () => void;
}
export function Sidebar({ collapsed, onToggle }: SidebarProps) {
const pathname = usePathname();
const navGroups = useMemo(() => buildNavigation(), []);
const { user } = useAuth();
@@ -224,55 +247,124 @@ export function Sidebar() {
}, [user?.name]);
return (
<aside className="flex h-full w-64 shrink-0 flex-col border-r bg-card">
<div className="border-b px-4 py-3">
<Link
href="/"
className="mb-2 flex w-full items-center justify-center"
title="Panou principal"
>
<SidebarLogos />
</Link>
<Link
href="/"
className="block text-center text-base font-semibold tracking-wide transition-colors hover:text-primary"
title="Panou principal"
>
{brandingLabel}
</Link>
</div>
<ScrollArea className="flex-1 px-3 py-3">
<Link
href="/"
className={cn(
"mb-1 flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors",
pathname === "/"
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground",
<TooltipProvider delayDuration={200}>
<aside
className={cn(
"flex h-full shrink-0 flex-col border-r bg-card transition-[width] duration-200",
collapsed ? "w-14" : "w-64",
)}
>
{/* Header */}
<div className={cn("border-b px-4 py-3", collapsed && "px-2")}>
{collapsed ? (
<div className="flex flex-col items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Link
href="/"
className="flex items-center justify-center rounded-md p-1 hover:bg-accent"
>
<Icons.Home className="h-5 w-5 text-primary" />
</Link>
</TooltipTrigger>
<TooltipContent side="right">ArchiTools</TooltipContent>
</Tooltip>
</div>
) : (
<>
<Link
href="/"
className="mb-2 flex w-full items-center justify-center"
title="Panou principal"
>
<SidebarLogos />
</Link>
<Link
href="/"
className="block text-center text-base font-semibold tracking-wide transition-colors hover:text-primary"
title="Panou principal"
>
{brandingLabel}
</Link>
</>
)}
>
<Icons.Home className="h-4 w-4" />
<span>Panou principal</span>
</Link>
</div>
<Separator className="my-2" />
{/* Nav */}
<ScrollArea className={cn("flex-1 py-3", collapsed ? "px-1" : "px-3")}>
{/* Home link */}
{collapsed ? (
<Tooltip>
<TooltipTrigger asChild>
<Link
href="/"
className={cn(
"mb-1 flex items-center justify-center rounded-md px-2 py-2 text-sm transition-colors",
pathname === "/"
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground",
)}
>
<Icons.LayoutDashboard className="h-4 w-4" />
</Link>
</TooltipTrigger>
<TooltipContent side="right">Panou principal</TooltipContent>
</Tooltip>
) : (
<Link
href="/"
className={cn(
"mb-1 flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors",
pathname === "/"
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground",
)}
>
<Icons.LayoutDashboard className="h-4 w-4" />
<span>Panou principal</span>
</Link>
)}
{navGroups.map((group) => (
<div key={group.category} className="mb-3">
<p className="mb-1 px-3 text-xs font-medium uppercase tracking-wider text-muted-foreground/60">
{group.label}
</p>
{group.items.map((item) => (
<NavItem
key={item.id}
item={item}
isActive={pathname.startsWith(item.href)}
/>
))}
</div>
))}
</ScrollArea>
</aside>
<Separator className="my-2" />
{navGroups.map((group) => (
<div key={group.category} className="mb-3">
{!collapsed && (
<p className="mb-1 px-3 text-xs font-medium uppercase tracking-wider text-muted-foreground/60">
{group.label}
</p>
)}
{collapsed && <Separator className="my-1 opacity-30" />}
{group.items.map((item) => (
<NavItem
key={item.id}
item={item}
isActive={pathname.startsWith(item.href)}
collapsed={collapsed}
/>
))}
</div>
))}
</ScrollArea>
{/* Collapse toggle button */}
<div className={cn("border-t p-2", collapsed && "flex justify-center")}>
<button
onClick={onToggle}
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-accent-foreground"
title={collapsed ? "Extinde sidebar" : "Restrânge sidebar"}
>
{collapsed ? (
<PanelLeftOpen className="h-4 w-4 shrink-0" />
) : (
<>
<PanelLeftClose className="h-4 w-4 shrink-0" />
<span>Restrânge</span>
</>
)}
</button>
</div>
</aside>
</TooltipProvider>
);
}