first commit
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useFeatureFlags, useSdk, useTheme, useNotifications } from "@lynkd/messaging-inbox-sdk";
|
||||
import { useUi } from "@/lib/ui-state";
|
||||
import { Avatar, IconButton } from "@/components/ui/primitives";
|
||||
import { Search, Sun, Moon, Bell, ChevronDown, PanelLeft } from "lucide-react";
|
||||
|
||||
export function Header({
|
||||
title,
|
||||
subtitle,
|
||||
eyebrow,
|
||||
children,
|
||||
}: {
|
||||
title: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
eyebrow?: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const flags = useFeatureFlags();
|
||||
const { me } = useSdk();
|
||||
const { scheme, toggleScheme } = useTheme();
|
||||
const { setPaletteOpen, setNotifOpen, openProfile, setSidebarOpen, sidebarCollapsed, toggleSidebarCollapsed } = useUi();
|
||||
const { unread } = useNotifications();
|
||||
const [mac, setMac] = useState(true);
|
||||
useEffect(() => {
|
||||
setMac(/mac|iphone|ipad/i.test(navigator.platform || navigator.userAgent));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header className="flex h-[60px] shrink-0 items-center gap-3 border-b border-border bg-surface/80 px-4 backdrop-blur">
|
||||
<button
|
||||
className="focus-ring grid h-9 w-9 place-items-center rounded-control text-muted hover:bg-hover md:hidden"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<PanelLeft size={18} />
|
||||
</button>
|
||||
{sidebarCollapsed && (
|
||||
<button
|
||||
className="focus-ring hidden h-9 w-9 place-items-center rounded-control text-muted hover:bg-hover hover:text-ink md:grid"
|
||||
onClick={toggleSidebarCollapsed}
|
||||
aria-label="Expand sidebar"
|
||||
title="Expand sidebar"
|
||||
>
|
||||
<PanelLeft size={18} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{eyebrow && <div className="text-[11px] font-semibold uppercase tracking-wide text-dim">{eyebrow}</div>}
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="truncate text-[17px] font-bold leading-tight">{title}</h1>
|
||||
</div>
|
||||
{subtitle && <p className="truncate text-[12.5px] text-muted">{subtitle}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{children}
|
||||
|
||||
{flags.search && (
|
||||
<button
|
||||
onClick={() => setPaletteOpen(true)}
|
||||
className="focus-ring hidden items-center gap-2 rounded-control border border-border bg-panel px-3 py-2 text-[13px] text-muted transition-colors hover:bg-hover hover:text-ink sm:flex"
|
||||
>
|
||||
<Search size={15} />
|
||||
<span>Search…</span>
|
||||
<kbd suppressHydrationWarning className="rounded bg-hover px-1.5 py-0.5 font-mono text-[10px] text-dim">{mac ? "⌘K" : "Ctrl K"}</kbd>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{flags.themeToggle && (
|
||||
<IconButton label="Toggle theme" onClick={toggleScheme}>
|
||||
{scheme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{flags.notifications && (
|
||||
<IconButton label="Notifications" onClick={() => setNotifOpen(true)} badge={unread}>
|
||||
<Bell size={18} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{me && (
|
||||
<button
|
||||
onClick={() => openProfile(me.id)}
|
||||
className="focus-ring ml-1 flex items-center gap-2 rounded-control p-1 pr-2 hover:bg-hover"
|
||||
>
|
||||
<Avatar user={me} size={32} showPresence={false} />
|
||||
<span className="hidden text-left lg:block">
|
||||
<span className="block text-[13px] font-semibold leading-tight">{me.name}</span>
|
||||
<span className="block text-[11px] text-muted">{me.title}</span>
|
||||
</span>
|
||||
<ChevronDown size={15} className="hidden text-muted lg:block" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
useChannels,
|
||||
useFeatureFlags,
|
||||
useInbox,
|
||||
useSdk,
|
||||
type Channel,
|
||||
} from "@lynkd/messaging-inbox-sdk";
|
||||
import { useUi } from "@/lib/ui-state";
|
||||
import { Avatar, CountBadge } from "@/components/ui/primitives";
|
||||
import {
|
||||
Hash,
|
||||
Lock,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
MessageSquarePlus,
|
||||
Send,
|
||||
Bookmark,
|
||||
MessagesSquare,
|
||||
Inbox as InboxIcon,
|
||||
Sliders,
|
||||
Circle,
|
||||
PanelLeftClose,
|
||||
} from "lucide-react";
|
||||
|
||||
export function Sidebar() {
|
||||
const { starred, channels, dms } = useChannels();
|
||||
const { me, brandName, channelById } = useSdk();
|
||||
const flags = useFeatureFlags();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { openProfile, setThemingOpen, openModal, toggleSidebarCollapsed } = useUi();
|
||||
const { items: openInbox } = useInbox("OPEN");
|
||||
|
||||
const quick = [
|
||||
flags.threads && { icon: MessagesSquare, label: "Threads", href: "/threads" },
|
||||
flags.inbox && { icon: InboxIcon, label: "Inbox", href: "/inbox", badge: openInbox.length },
|
||||
flags.drafts && { icon: Send, label: "Drafts & sent", href: "/drafts" },
|
||||
flags.savedItems && { icon: Bookmark, label: "Saved", href: "/saved" },
|
||||
].filter(Boolean) as { icon: typeof Hash; label: string; href: string; badge?: number }[];
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-[264px] shrink-0 flex-col border-r border-border bg-surface">
|
||||
{/* workspace header */}
|
||||
<div className="flex items-center gap-2 px-3.5 py-3">
|
||||
<div className="brand-mark grid h-8 w-8 shrink-0 place-items-center rounded-[10px] text-sm font-black text-black/80">
|
||||
{brandName.slice(0, 1)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[15px] font-bold leading-tight">{brandName}</div>
|
||||
<div className="flex items-center gap-1 text-[11px] text-muted">
|
||||
<Circle size={7} className="fill-success text-success" /> Business+
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
title="New message"
|
||||
onClick={() => openModal("new-message")}
|
||||
className="focus-ring grid h-8 w-8 shrink-0 place-items-center rounded-control border border-border bg-panel text-ink transition-colors hover:bg-hover"
|
||||
>
|
||||
<MessageSquarePlus size={16} />
|
||||
</button>
|
||||
<button
|
||||
title="Collapse sidebar"
|
||||
onClick={toggleSidebarCollapsed}
|
||||
className="focus-ring hidden h-8 w-8 shrink-0 place-items-center rounded-control text-muted transition-colors hover:bg-hover hover:text-ink md:grid"
|
||||
>
|
||||
<PanelLeftClose size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* quick nav */}
|
||||
<div className="px-2 pb-1">
|
||||
{quick.map((q) => {
|
||||
const active = pathname === q.href;
|
||||
return (
|
||||
<button
|
||||
key={q.href}
|
||||
onClick={() => router.push(q.href)}
|
||||
className={clsx(
|
||||
"focus-ring flex w-full items-center gap-2.5 rounded-control px-2.5 py-[7px] text-[14px] transition-colors",
|
||||
active ? "bg-accent-soft font-semibold text-accent" : "text-muted hover:bg-hover hover:text-ink",
|
||||
)}
|
||||
>
|
||||
<q.icon size={17} />
|
||||
<span className="flex-1 text-left">{q.label}</span>
|
||||
{q.badge ? <CountBadge count={q.badge} /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mx-3 my-1.5 h-px bg-border" />
|
||||
|
||||
{/* channel lists */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
|
||||
{flags.messaging && starred.length > 0 && (
|
||||
<ChannelGroup title="Starred" defaultOpen items={starred} activePath={pathname} onOpen={(c) => router.push(`/c/${c.id}`)} me={me?.id} channelById={channelById} />
|
||||
)}
|
||||
{flags.messaging && (
|
||||
<ChannelGroup
|
||||
title="Channels"
|
||||
defaultOpen
|
||||
items={channels}
|
||||
activePath={pathname}
|
||||
onOpen={(c) => router.push(`/c/${c.id}`)}
|
||||
footer={
|
||||
flags.channelBrowser ? (
|
||||
<SidebarAction icon={Plus} label="Add channels" onClick={() => openModal("create-channel")} />
|
||||
) : null
|
||||
}
|
||||
me={me?.id}
|
||||
channelById={channelById}
|
||||
/>
|
||||
)}
|
||||
{flags.directMessages && (
|
||||
<ChannelGroup
|
||||
title="Direct messages"
|
||||
defaultOpen
|
||||
items={dms}
|
||||
activePath={pathname}
|
||||
onOpen={(c) => router.push(`/c/${c.id}`)}
|
||||
footer={<SidebarAction icon={Plus} label="Invite people" onClick={() => openModal("invite")} />}
|
||||
me={me?.id}
|
||||
channelById={channelById}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* user card footer */}
|
||||
{me && (
|
||||
<div className="flex items-center gap-2.5 border-t border-border px-3 py-2.5">
|
||||
<button
|
||||
className="focus-ring flex min-w-0 flex-1 items-center gap-2.5 rounded-control p-1 text-left hover:bg-hover"
|
||||
onClick={() => openProfile(me.id)}
|
||||
>
|
||||
<Avatar user={me} size={34} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] font-semibold">{me.name}</div>
|
||||
<div className="truncate text-[11px] text-muted">
|
||||
{me.statusEmoji} {me.statusText ?? "Active"}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{flags.themingPanel && (
|
||||
<button
|
||||
title="Theme & appearance"
|
||||
onClick={() => setThemingOpen(true)}
|
||||
className="focus-ring grid h-8 w-8 place-items-center rounded-control text-muted hover:bg-hover hover:text-ink"
|
||||
>
|
||||
<Sliders size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelGroup({
|
||||
title,
|
||||
items,
|
||||
activePath,
|
||||
onOpen,
|
||||
footer,
|
||||
defaultOpen,
|
||||
me,
|
||||
channelById,
|
||||
}: {
|
||||
title: string;
|
||||
items: Channel[];
|
||||
activePath: string;
|
||||
onOpen: (c: Channel) => void;
|
||||
footer?: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
me?: string;
|
||||
channelById: (id: string) => Channel | undefined;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen ?? true);
|
||||
return (
|
||||
<div className="mb-1.5">
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="focus-ring flex w-full items-center gap-1 rounded px-2 py-1 text-[12px] font-bold uppercase tracking-wide text-dim hover:text-muted"
|
||||
>
|
||||
{open ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||
{title}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="mt-0.5">
|
||||
{items.map((c) => (
|
||||
<ChannelRow key={c.id} channel={c} active={activePath === `/c/${c.id}`} onOpen={() => onOpen(c)} meId={me} />
|
||||
))}
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelRow({
|
||||
channel,
|
||||
active,
|
||||
onOpen,
|
||||
meId,
|
||||
}: {
|
||||
channel: Channel;
|
||||
active: boolean;
|
||||
onOpen: () => void;
|
||||
meId?: string;
|
||||
}) {
|
||||
const { userById } = useSdk();
|
||||
const unread = channel.unreadCount ?? 0;
|
||||
const isDm = channel.kind === "dm";
|
||||
const other = isDm ? channel.memberIds.find((id) => id !== meId) : undefined;
|
||||
const otherUser = other ? userById(other) : undefined;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onOpen}
|
||||
className={clsx(
|
||||
"focus-ring group flex w-full items-center gap-2 rounded-control px-2.5 py-[6px] text-[14px] transition-colors",
|
||||
active
|
||||
? "bg-accent-soft font-semibold text-accent"
|
||||
: unread
|
||||
? "font-semibold text-ink hover:bg-hover"
|
||||
: "text-muted hover:bg-hover hover:text-ink",
|
||||
)}
|
||||
>
|
||||
<span className="grid w-4 shrink-0 place-items-center">
|
||||
{channel.kind === "channel" && <Hash size={15} />}
|
||||
{channel.kind === "private" && <Lock size={13} />}
|
||||
{channel.kind === "group_dm" && <MessagesSquare size={14} />}
|
||||
{isDm && otherUser && <Avatar user={otherUser} size={18} showPresence rounded="6px" />}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-left">
|
||||
{channel.name}
|
||||
{channel.external && <span className="ml-1 text-[10px] text-dim">◆</span>}
|
||||
</span>
|
||||
{channel.mentionCount ? (
|
||||
<CountBadge count={channel.mentionCount} tone="danger" />
|
||||
) : unread ? (
|
||||
<CountBadge count={unread} />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarAction({
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
icon: typeof Hash;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="focus-ring flex w-full items-center gap-2 rounded-control px-2.5 py-[6px] text-[13px] text-muted transition-colors hover:bg-hover hover:text-ink"
|
||||
>
|
||||
<span className="grid h-[18px] w-[18px] place-items-center rounded bg-hover">
|
||||
<Icon size={13} />
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useSdk, useFeatureFlags } from "@lynkd/messaging-inbox-sdk";
|
||||
import { useUi } from "@/lib/ui-state";
|
||||
import { IconButton } from "@/components/ui/primitives";
|
||||
import { Home, MessageSquare, Inbox as InboxIcon, Bell, LifeBuoy, MoreHorizontal, Plus } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
|
||||
/** Slack-style far-left rail: workspace switcher + primary destinations. */
|
||||
export function WorkspaceRail() {
|
||||
const { bootstrap, brandName } = useSdk();
|
||||
const flags = useFeatureFlags();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { setNotifOpen, openModal, toast } = useUi();
|
||||
|
||||
const workspaces = bootstrap?.workspaces ?? [];
|
||||
const [activeWs, setActiveWs] = useState<string | undefined>(bootstrap?.workspace?.id);
|
||||
|
||||
const nav = [
|
||||
{ icon: Home, label: "Home", href: "/", match: (p: string) => p === "/" || p.startsWith("/c/") },
|
||||
flags.messaging && { icon: MessageSquare, label: "DMs", href: "/dms", match: (p: string) => p.startsWith("/dms") },
|
||||
flags.inbox && { icon: InboxIcon, label: "Inbox", href: "/inbox", match: (p: string) => p.startsWith("/inbox") },
|
||||
flags.support && { icon: LifeBuoy, label: "Support", href: "/support", match: (p: string) => p.startsWith("/support") },
|
||||
].filter(Boolean) as { icon: typeof Home; label: string; href: string; match: (p: string) => boolean }[];
|
||||
|
||||
const active = activeWs ?? bootstrap?.workspace?.id;
|
||||
|
||||
return (
|
||||
<nav className="hidden w-[68px] shrink-0 flex-col items-center gap-1 border-r border-border bg-bg py-3 md:flex">
|
||||
{flags.workspaceSwitcher && (
|
||||
<div className="flex flex-col items-center gap-2 pb-2">
|
||||
{workspaces.map((w) => (
|
||||
<button
|
||||
key={w.id}
|
||||
title={w.name}
|
||||
onClick={() => {
|
||||
setActiveWs(w.id);
|
||||
if (w.id !== bootstrap?.workspace?.id) toast(`Switched to ${w.name} (demo)`, "success");
|
||||
}}
|
||||
className={clsx(
|
||||
"focus-ring grid h-11 w-11 place-items-center rounded-[14px] text-sm font-bold transition-all",
|
||||
w.id === active ? "text-white ring-2 ring-accent" : "text-ink/80 hover:rounded-[12px]",
|
||||
)}
|
||||
style={{ background: w.id === active ? "var(--brand-gradient)" : "var(--c-elevated)" }}
|
||||
>
|
||||
{w.initials}
|
||||
</button>
|
||||
))}
|
||||
<IconButton label="Add workspace" onClick={() => openModal("create-workspace")}>
|
||||
<Plus size={18} />
|
||||
</IconButton>
|
||||
<div className="my-1 h-px w-8 bg-border" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center gap-1 overflow-y-auto no-scrollbar">
|
||||
{nav.map((n) => {
|
||||
const isActive = n.match(pathname);
|
||||
return (
|
||||
<button
|
||||
key={n.href}
|
||||
title={n.label}
|
||||
onClick={() => router.push(n.href)}
|
||||
className={clsx(
|
||||
"focus-ring flex h-12 w-12 flex-col items-center justify-center gap-0.5 rounded-control text-[10px] font-medium transition-colors",
|
||||
isActive ? "bg-accent-soft text-accent" : "text-muted hover:bg-hover hover:text-ink",
|
||||
)}
|
||||
>
|
||||
<n.icon size={20} />
|
||||
<span>{n.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
{flags.notifications && (
|
||||
<IconButton label="Notifications" onClick={() => setNotifOpen(true)}>
|
||||
<Bell size={20} />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton label="Help & more" onClick={() => toast("Help center — coming soon", "default")}>
|
||||
<MoreHorizontal size={20} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<span className="sr-only">{brandName}</span>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user