ubiquitous-invention/apps/web/components/layout/icon-rail.tsx
Randall Stillwell 663bc77afe feat: ECHODO app shell, Coolify deploy, Authentik + Umami
Bundles in-flight ECHODO work with the Coolify deployment configuration:

App
- New routes: ai, forms, planner, settings (templates/types), teams,
  doc detail, whiteboard detail
- New components: app shell rework (icon-rail, top-header), forms
  builder/renderer/responses, types manager, objects creation dialog,
  card primitive, form + overview views
- New tRPC routers: favorites, forms, types, workspaces; updates to
  health and objects routers
- Markdown backlog sync (packages/database) + cursor-sync schema/migrations
- Schema additions: forms, types, favorites, markdown_backlog, cursor_sync
- Initial Drizzle migrations checked in

Deployment
- docker/docker-compose.coolify.yml: drops bundled Postgres/Redis
  (uses CT 102 shared services), removes host port mappings, adds
  Coolify SERVICE_FQDN_* magic vars for web + collab
- .env.example rewritten as the full ECHODO/Coolify variable manifest
- NextAuth gains an Authentik OIDC provider (gated on env presence)
- Root layout injects Umami tracking script when configured;
  metadata title flipped to ECHODO

Security
- .gitignore expanded to exclude AGENT-DEPLOY.md, .env.*, secrets/,
  credentials.*, *.key, *.crt, *.pem, ssh keys

Made-with: Cursor
2026-04-26 14:34:34 -05:00

200 lines
6.3 KiB
TypeScript

"use client";
import type { ComponentType } from "react";
import {
CalendarDays,
ClipboardList,
FileText,
Home,
LayoutGrid,
MoreHorizontal,
Presentation,
Sparkles,
UserPlus,
Users,
} from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
import { cn } from "@/lib/utils";
export interface IconRailProps {
className?: string;
}
type NavItem = {
id: string;
label: string;
segment: string;
icon: ComponentType<{ className?: string }>;
};
const NAV_ITEMS: NavItem[] = [
{ id: "home", label: "Home", segment: "", icon: Home },
{ id: "spaces", label: "Spaces", segment: "", icon: LayoutGrid },
{ id: "planner", label: "Planner", segment: "planner", icon: CalendarDays },
{ id: "ai", label: "AI", segment: "ai", icon: Sparkles },
{ id: "teams", label: "Teams", segment: "teams", icon: Users },
{ id: "docs", label: "Docs", segment: "docs", icon: FileText },
{ id: "whiteboards", label: "Whiteboards", segment: "whiteboards", icon: Presentation },
{ id: "forms", label: "Forms", segment: "forms", icon: ClipboardList },
];
function workspaceHref(workspaceSegment: string, segment: string) {
return segment ? `/${workspaceSegment}/${segment}` : `/${workspaceSegment}`;
}
const RESERVED_ROOT_SEGMENTS = new Set([
"planner",
"ai",
"teams",
"docs",
"whiteboards",
"forms",
]);
function isAtWorkspaceHome(pathname: string, prefix: string): boolean {
const rest = pathname.slice(prefix.length).replace(/^\//, "");
const first = rest.split("/")[0] ?? "";
if (!first) return true;
return !RESERVED_ROOT_SEGMENTS.has(first);
}
function isNavItemActive(
pathname: string,
workspaceSegment: string | undefined,
item: NavItem,
): boolean {
if (!workspaceSegment) return false;
const prefix = `/${workspaceSegment}`;
if (!pathname.startsWith(prefix)) return false;
if (item.segment) {
const next = pathname[prefix.length] === "/" ? pathname.slice(prefix.length + 1) : "";
const first = next.split("/")[0] ?? "";
return first === item.segment;
}
if (item.id === "home") {
return isAtWorkspaceHome(pathname, prefix);
}
// Spaces uses the same URL as home for now; keep inactive here to avoid double highlight
if (item.id === "spaces") {
return false;
}
return false;
}
export function IconRail({ className }: IconRailProps) {
const pathname = usePathname();
const router = useRouter();
const params = useParams();
const currentWorkspace = useWorkspaceStore((s) => s.currentWorkspace);
const slugParam =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : "";
const workspaceSegment =
currentWorkspace?.slug ||
currentWorkspace?.id ||
slugParam ||
undefined;
const workspaceName = currentWorkspace?.name ?? "";
const initials = workspaceName.trim().slice(0, 2).toUpperCase() || "—";
const push = (segment: string) => {
if (!workspaceSegment) return;
router.push(workspaceHref(workspaceSegment, segment));
};
return (
<aside
className={cn(
"flex h-full w-[60px] shrink-0 flex-col",
"bg-gradient-to-b from-[hsl(263,70%,25%)] to-[hsl(263,70%,18%)]",
"dark:from-[hsl(263,70%,20%)] dark:to-[hsl(263,70%,12%)]",
className,
)}
>
<nav
className="flex min-h-0 flex-1 flex-col items-stretch overflow-y-auto overflow-x-hidden px-1 py-2"
aria-label="Main"
>
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const active = isNavItemActive(pathname, workspaceSegment, item);
return (
<Tooltip key={item.id}>
<TooltipTrigger asChild>
<button
type="button"
disabled={!workspaceSegment}
onClick={() => push(item.segment)}
className={cn(
"w-full cursor-pointer rounded-lg px-1 py-2 transition-colors",
"flex flex-col items-center gap-0.5",
active
? "bg-white/20 text-white"
: "text-white/70 hover:bg-white/10",
)}
>
<Icon className="h-5 w-5 shrink-0" aria-hidden />
<span
className={cn(
"max-w-[52px] truncate text-center text-[10px] font-medium leading-tight",
active ? "text-white" : "text-white/70",
)}
>
{item.label}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="right">{item.label}</TooltipContent>
</Tooltip>
);
})}
</nav>
<div className="flex shrink-0 flex-col items-stretch gap-0.5 px-1 pb-3">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
"w-full cursor-pointer rounded-lg px-1 py-2 transition-colors",
"flex flex-col items-center gap-0.5 text-white/50 hover:bg-white/10 hover:text-white/70",
)}
>
<MoreHorizontal className="h-5 w-5 shrink-0" aria-hidden />
<span className="max-w-[52px] truncate text-center text-[10px] font-medium leading-tight">
More
</span>
</button>
</TooltipTrigger>
<TooltipContent side="right">More</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
"w-full cursor-pointer rounded-lg px-1 py-2 transition-colors",
"flex flex-col items-center gap-0.5 text-white/70 hover:bg-white/10",
)}
>
<UserPlus className="h-5 w-5 shrink-0" aria-hidden />
<span className="max-w-[52px] truncate text-center text-[10px] font-medium leading-tight">
Invite
</span>
</button>
</TooltipTrigger>
<TooltipContent side="right">Invite</TooltipContent>
</Tooltip>
</div>
</aside>
);
}