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
200 lines
6.3 KiB
TypeScript
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>
|
|
);
|
|
}
|