diff --git a/app/admin/activation-requests/page.tsx b/app/admin/activation-requests/page.tsx index 8e3edf4..5722c83 100644 --- a/app/admin/activation-requests/page.tsx +++ b/app/admin/activation-requests/page.tsx @@ -45,10 +45,7 @@ export default async function AdminActivationRequestsPage() { const requests = await getActivationRequests(); return ( -
+ <>
admin.activations @@ -109,6 +106,6 @@ export default async function AdminActivationRequestsPage() { )}
-
+ ); } diff --git a/app/admin/bans/page.tsx b/app/admin/bans/page.tsx index 2ffdd5e..0b88848 100644 --- a/app/admin/bans/page.tsx +++ b/app/admin/bans/page.tsx @@ -35,10 +35,7 @@ export default async function AdminBansPage() { const revoked = bans.filter(b => b.revoked_at); return ( -
+ <>
@@ -124,6 +121,6 @@ export default async function AdminBansPage() {
)} -
+ ); } diff --git a/app/admin/keys/page.tsx b/app/admin/keys/page.tsx index e10d578..147fb17 100644 --- a/app/admin/keys/page.tsx +++ b/app/admin/keys/page.tsx @@ -27,10 +27,7 @@ export default function AdminKeysPage() { })); return ( -
+ <>
admin.keys @@ -107,6 +104,6 @@ export default function AdminKeysPage() { ))}
-
+ ); } diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 21f4e23..ffd90db 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -1,9 +1,20 @@ import { headers } from "next/headers"; import { redirect } from "next/navigation"; -import { AdminSidebar } from "@/components/AdminSidebar"; +import { AppShell } from "@/components/AppShell"; import { isAdminStepUpVerified } from "@/lib/server/adminStepUp"; import { getCurrentSession } from "@/lib/server/session"; +const TRAILS: Record = { + "/admin": "Overview", + "/admin/users": "Users", + "/admin/oauth-clients": "OAuth clients", + "/admin/keys": "Signing keys", + "/admin/activation-requests": "Activation requests", + "/admin/webhooks": "Webhook deliveries", + "/admin/bans": "Bans", + "/admin/security": "Security events", +}; + export default async function AdminLayout({ children, }: { @@ -17,19 +28,27 @@ export default async function AdminLayout({ const headersList = await headers(); const pathname = headersList.get("x-pathname") || ""; - if (pathname !== "/admin/verify") { - const verified = await isAdminStepUpVerified(current.user.id); - if (!verified) { - redirect("/admin/verify"); - } + // The step-up gate renders its own minimal shell; everything else gets the + // admin app shell once the live Telegram step-up is verified. + if (pathname === "/admin/verify") { + return <>{children}; + } + + const verified = await isAdminStepUpVerified(current.user.id); + if (!verified) { + redirect("/admin/verify"); } return ( -
- {pathname !== "/admin/verify" && ( - - )} -
{children}
-
+ + {children} + ); } diff --git a/app/admin/oauth-clients/page.tsx b/app/admin/oauth-clients/page.tsx index 3445710..57c1f86 100644 --- a/app/admin/oauth-clients/page.tsx +++ b/app/admin/oauth-clients/page.tsx @@ -12,10 +12,7 @@ export default async function AdminOAuthClientsPage() { const requests = await listPendingOAuthClientRegistrationRequests(); return ( -
+ <>
Admin @@ -121,6 +118,6 @@ export default async function AdminOAuthClientsPage() { )}
-
+ ); } diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 445604b..702b3df 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -53,10 +53,7 @@ export default async function AdminPage() { const data = await getOverviewData(); return ( -
+ <>
Admin @@ -153,6 +150,6 @@ export default async function AdminPage() { )}
-
+ ); } diff --git a/app/admin/security/page.tsx b/app/admin/security/page.tsx index 61f991f..7d27d51 100644 --- a/app/admin/security/page.tsx +++ b/app/admin/security/page.tsx @@ -46,10 +46,7 @@ export default async function AdminSecurityPage({ } return ( -
+ <>
-
+ ); } diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx index 765bf7b..3dc961d 100644 --- a/app/admin/users/page.tsx +++ b/app/admin/users/page.tsx @@ -65,10 +65,7 @@ export default async function AdminUsersPage({ const users = await getUsers(search); return ( -
+ <>

Admin / Users · {users.length} records @@ -144,6 +141,6 @@ export default async function AdminUsersPage({ )} -

+ ); } diff --git a/app/admin/webhooks/page.tsx b/app/admin/webhooks/page.tsx index f45c76f..e88ce4a 100644 --- a/app/admin/webhooks/page.tsx +++ b/app/admin/webhooks/page.tsx @@ -47,10 +47,7 @@ export default async function AdminWebhooksPage({ }); return ( -
+ <>
-
+ ); } diff --git a/app/developers/apps/[slug]/page.tsx b/app/developers/apps/[slug]/page.tsx index 9fe44f0..a8d9df6 100644 --- a/app/developers/apps/[slug]/page.tsx +++ b/app/developers/apps/[slug]/page.tsx @@ -1,6 +1,5 @@ import { redirect } from "next/navigation"; -import { Sidebar } from "@/components/Sidebar"; -import { TopNav } from "@/components/TopNav"; +import { AppShell } from "@/components/AppShell"; import { Tag } from "@/components/Tag"; import { Section, Row, RowLabel, RowValue } from "@/components/Section"; import { getCurrentSession } from "@/lib/server/session"; @@ -47,102 +46,92 @@ export default async function AppSettingsPage({ const initials = app.name.slice(0, 2).toUpperCase(); return ( -
- -
- -
+
+
-
-
- {initials} -
-
-
- App settings - {app.status === "disabled" && ( - Disabled - )} -
-

- {app.name} -

-

- Client ID: -

-
-
- -
-
- - Client ID - - - - - - - Slug - - {slug} - - - - - Created - - - {app.created_at.slice(0, 16).replace("T", " ")} - - - - -
+ {initials} +
+
+
+ App settings + {app.status === "disabled" && ( + Disabled + )}
+

+ {app.name} +

+

+ Client ID: +

+
+
-
- -
+
+
+ + Client ID + + + + + + + Slug + + {slug} + + + + + Created + + + {app.created_at.slice(0, 16).replace("T", " ")} + + + + +
+
-
- ({ - publicId: e.publicId, - url: e.url, - eventTypes: e.eventTypes, - status: e.status, - createdAt: e.createdAt, - }))} - /> -
-
+
+ +
+ +
+ ({ + publicId: e.publicId, + url: e.url, + eventTypes: e.eventTypes, + status: e.status, + createdAt: e.createdAt, + }))} + />
-
+ ); } diff --git a/app/developers/apps/new/page.tsx b/app/developers/apps/new/page.tsx index 72fc922..d166059 100644 --- a/app/developers/apps/new/page.tsx +++ b/app/developers/apps/new/page.tsx @@ -1,6 +1,5 @@ import { redirect } from "next/navigation"; -import { Sidebar } from "@/components/Sidebar"; -import { TopNav } from "@/components/TopNav"; +import { AppShell } from "@/components/AppShell"; import { getCurrentSession } from "@/lib/server/session"; import { ClientForm } from "./ClientForm"; @@ -13,33 +12,27 @@ export default async function NewAppPage() { } return ( -
- -
- -
-
-

- New application -

-

- Register a new OAuth client to authenticate users and access APIs. -

-
+ +
+
+

+ New application +

+

+ Register a new OAuth client to authenticate users and access APIs. +

+
-
- -
-
+
+ +
-
+ ); } diff --git a/app/developers/apps/page.tsx b/app/developers/apps/page.tsx index a89de53..e965ad8 100644 --- a/app/developers/apps/page.tsx +++ b/app/developers/apps/page.tsx @@ -1,7 +1,6 @@ import { redirect } from "next/navigation"; import Link from "next/link"; -import { Sidebar } from "@/components/Sidebar"; -import { TopNav } from "@/components/TopNav"; +import { AppShell } from "@/components/AppShell"; import { Button } from "@/components/Button"; import { Tag } from "@/components/Tag"; import { Section, Empty } from "@/components/Section"; @@ -31,78 +30,68 @@ export default async function DeveloperAppsPage() { ); return ( -
- -
- -
+
+
+
+ Developer / apps + + {apps.length} app{apps.length === 1 ? "" : "s"} + +
+

+ OAuth apps +

+
+ + + +
+ +
+
-
-
-
- Developer / apps + {apps.length === 0 ? ( + No apps registered yet + ) : ( + apps.map(app => ( + +
+ + {app.name} + + {app.status === "disabled" && ( + Disabled + )} +
+ + {app.public_id} + - {apps.length} app{apps.length === 1 ? "" : "s"} + {app.created_at.slice(0, 10)} -
-

- OAuth apps -

-
- - - -
- -
-
- {apps.length === 0 ? ( - No apps registered yet - ) : ( - apps.map(app => ( - -
- - {app.name} - - {app.status === "disabled" && ( - Disabled - )} -
- - {app.public_id} - - - {app.created_at.slice(0, 10)} - - - )) - )} -
-
-
+ + )) + )} +
-
+ ); } diff --git a/app/developers/oauth/page.tsx b/app/developers/oauth/page.tsx index da38a4c..8af5761 100644 --- a/app/developers/oauth/page.tsx +++ b/app/developers/oauth/page.tsx @@ -1,6 +1,5 @@ import { redirect } from "next/navigation"; -import { Sidebar } from "@/components/Sidebar"; -import { TopNav } from "@/components/TopNav"; +import { AppShell } from "@/components/AppShell"; import { Tag } from "@/components/Tag"; import { getCurrentSession } from "@/lib/server/session"; import { CodeTabs } from "./CodeTabs"; @@ -16,16 +15,13 @@ export default async function OAuthDocsPage() { } return ( -
- -
- -
+
docs / oauth @@ -1011,9 +1007,7 @@ if result['status'] == 'approved':
-
-
-
+ ); } diff --git a/app/developers/test-lab/page.tsx b/app/developers/test-lab/page.tsx index fd8aa2e..0786839 100644 --- a/app/developers/test-lab/page.tsx +++ b/app/developers/test-lab/page.tsx @@ -1,6 +1,5 @@ import { redirect } from "next/navigation"; -import { Sidebar } from "@/components/Sidebar"; -import { TopNav } from "@/components/TopNav"; +import { AppShell } from "@/components/AppShell"; import { Tag } from "@/components/Tag"; import { getCurrentSession } from "@/lib/server/session"; import { TestLab } from "./test-lab"; @@ -14,34 +13,29 @@ export default async function TestLabPage() { } return ( -
- -
- -
-
-
- OAuth lab - API lab -
-

- Test field lab -

-

- Build an authorization URL, generate PKCE values, inspect - discovery metadata, and test token endpoints against this auth - server. -

-
+ +
+
+ OAuth lab + API lab +
+

+ Test field lab +

+

+ Build an authorization URL, generate PKCE values, inspect + discovery metadata, and test token endpoints against this auth + server. +

+
- -
-
-
+ + ); } diff --git a/app/globals.css b/app/globals.css index 30657d2..fa14751 100644 --- a/app/globals.css +++ b/app/globals.css @@ -21,7 +21,7 @@ /* Aliases kept so existing utility usage keeps resolving. */ --color-surface: var(--bg-soft); - --color-elevated: var(--bg-soft); + --color-elevated: var(--elevated); --color-border: var(--rule); --color-border-strong: var(--rule-strong); --color-success: var(--ok); @@ -48,6 +48,7 @@ --radius-lg: 0.75rem; --radius-xl: 1rem; + --shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.05); --shadow-card: 0 1px 3px 0 rgba(0, 0, 0, 0.08), 0 1px 2px -1px rgba(0, 0, 0, 0.08); --shadow-elevated: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -2px rgba(0, 0, 0, 0.08); } @@ -78,6 +79,7 @@ --bg: #fafafa; --bg-soft: #ffffff; --hover: #f4f4f5; + --elevated: #f7f7f8; --rule: #e6e6e6; --rule-strong: #d4d4d4; --fg: #1a1a1a; diff --git a/app/page.tsx b/app/page.tsx index 6695796..4075091 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,7 +1,6 @@ import { redirect } from "next/navigation"; import { BearerSection } from "@/components/BearerSection"; -import { Sidebar } from "@/components/Sidebar"; -import { TopNav } from "@/components/TopNav"; +import { AppShell } from "@/components/AppShell"; import { Section, Row, RowLabel, RowValue, Empty } from "@/components/Section"; import { Tag } from "@/components/Tag"; import { getCurrentSession } from "@/lib/server/session"; @@ -49,15 +48,12 @@ export default async function DashboardPage() { ]; return ( -
- -
- -
-
+ +

{account.firstName} @@ -77,7 +73,7 @@ export default async function DashboardPage() {
{stats.map((stat, i) => (
- + {String(stat.value).padStart(2, "0")} {stat.hint} @@ -356,8 +352,6 @@ export default async function DashboardPage() { bottleneck auth.bneck.com -

-
-
+ ); } diff --git a/app/request-bearer/page.tsx b/app/request-bearer/page.tsx index 1aa5345..11696ae 100644 --- a/app/request-bearer/page.tsx +++ b/app/request-bearer/page.tsx @@ -5,8 +5,7 @@ import { useState } from "react"; import { Alert } from "@/components/Alert"; import { Button } from "@/components/Button"; import { Field } from "@/components/Field"; -import { Sidebar } from "@/components/Sidebar"; -import { TopNav } from "@/components/TopNav"; +import { AppShell } from "@/components/AppShell"; type Status = "idle" | "submitting" | "submitted" | "error"; @@ -44,11 +43,8 @@ export default function RequestBearerPage() { } return ( -
- -
- -
+ +

Bearer request

@@ -125,8 +121,7 @@ export default function RequestBearerPage() {

)} -
-
+ ); } diff --git a/app/security/page.tsx b/app/security/page.tsx index 28e48ed..e4d589e 100644 --- a/app/security/page.tsx +++ b/app/security/page.tsx @@ -3,9 +3,8 @@ import { redirect } from "next/navigation"; import { PasskeyManager } from "@/components/PasskeyManager"; import { ChangePasswordForm } from "@/components/ChangePasswordForm"; import { Section, Row, RowLabel, RowValue, Empty } from "@/components/Section"; -import { Sidebar } from "@/components/Sidebar"; +import { AppShell } from "@/components/AppShell"; import { Tag } from "@/components/Tag"; -import { TopNav } from "@/components/TopNav"; import { findWebauthnCredentialsByUser } from "@/lib/server/repositories/webauthn"; import { getCurrentSession } from "@/lib/server/session"; import { getDashboard } from "@/lib/server/services/dashboard"; @@ -35,19 +34,11 @@ export default async function SecurityCenterPage() { const hasTelegram = Boolean(current.user.telegramVerifiedAt); return ( -
- -
- -
+
@@ -236,8 +227,6 @@ export default async function SecurityCenterPage() { )}
-
-
-
+ ); } diff --git a/components/AdminSidebar.tsx b/components/AdminSidebar.tsx deleted file mode 100644 index f36e327..0000000 --- a/components/AdminSidebar.tsx +++ /dev/null @@ -1,91 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { usePathname } from "next/navigation"; - -const navItems = [ - { href: "/admin", label: "Overview" }, - { href: "/admin/users", label: "Users" }, - { href: "/admin/oauth-clients", label: "OAuth clients" }, - { href: "/admin/keys", label: "Signing keys" }, - { href: "/admin/activation-requests", label: "Activation requests" }, - { href: "/admin/webhooks", label: "Webhook deliveries" }, - { href: "/admin/bans", label: "Bans" }, - { href: "/admin/security", label: "Security events" }, -]; - -function Monogram() { - return ( - - ); -} - -export function AdminSidebar({ username }: { username: string }) { - const pathname = usePathname(); - - return ( - - ); -} diff --git a/components/AppShell.tsx b/components/AppShell.tsx new file mode 100644 index 0000000..5099c10 --- /dev/null +++ b/components/AppShell.tsx @@ -0,0 +1,421 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { + User, + CreditCard, + LayoutGrid, + KeyRound, + MonitorSmartphone, + ShieldCheck, + History, + Shield, + Boxes, + BookOpen, + FlaskConical, + Send, + CircleHelp, + Search, + PanelLeft, + LogOut, + LayoutDashboard, + Users, + ClipboardCheck, + Webhook, + Ban, + ShieldAlert, + type LucideIcon, +} from "lucide-react"; +import { Kbd } from "./Kbd"; + +type NavGroup = { + label: string; + items: { href: string; label: string; icon: LucideIcon; newWindow?: boolean }[]; +}; + +const USER_NAV: NavGroup[] = [ + { + label: "Account", + items: [ + { href: "/#profile", label: "Profile", icon: User }, + { href: "/#subscriptions", label: "Subscriptions", icon: CreditCard }, + { href: "/#apps", label: "Connected apps", icon: LayoutGrid }, + { href: "/#bearers", label: "API bearers", icon: KeyRound }, + ], + }, + { + label: "Security", + items: [ + { href: "/#sessions", label: "Sessions", icon: MonitorSmartphone }, + { href: "/#security", label: "Password & 2FA", icon: ShieldCheck }, + { href: "/#events", label: "Recent events", icon: History }, + { href: "/security", label: "Security center", icon: Shield }, + ], + }, + { + label: "Developer", + items: [ + { href: "/developers/apps", label: "OAuth apps", icon: Boxes }, + { href: "/developers/oauth", label: "OAuth docs", icon: BookOpen, newWindow: true }, + { href: "/developers/test-lab", label: "Test field lab", icon: FlaskConical }, + ], + }, + { + label: "Support", + items: [ + { href: "https://t.me/bottleneck_help", label: "Telegram", icon: Send, newWindow: true }, + { href: "/#faq", label: "FAQ", icon: CircleHelp }, + ], + }, +]; + +const ADMIN_NAV: NavGroup[] = [ + { + label: "Controls", + items: [ + { href: "/admin", label: "Overview", icon: LayoutDashboard }, + { href: "/admin/users", label: "Users", icon: Users }, + { href: "/admin/oauth-clients", label: "OAuth clients", icon: Boxes }, + { href: "/admin/keys", label: "Signing keys", icon: KeyRound }, + { href: "/admin/activation-requests", label: "Activation requests", icon: ClipboardCheck }, + { href: "/admin/webhooks", label: "Webhook deliveries", icon: Webhook }, + { href: "/admin/bans", label: "Bans", icon: Ban }, + { href: "/admin/security", label: "Security events", icon: ShieldAlert }, + ], + }, +]; + +type FlatItem = NavGroup["items"][number] & { group: string }; + +function Monogram({ admin }: { admin?: boolean }) { + const stroke = admin ? "var(--danger)" : "var(--accent)"; + return ( + + ); +} + +function SearchPalette({ items, onClose }: { items: FlatItem[]; onClose: () => void }) { + const [query, setQuery] = useState(""); + const [selected, setSelected] = useState(0); + const inputRef = useRef(null); + const router = useRouter(); + + const results = query.trim() + ? items.filter( + it => + it.label.toLowerCase().includes(query.toLowerCase()) || + it.group.toLowerCase().includes(query.toLowerCase()), + ) + : items; + + useEffect(() => { + inputRef.current?.focus(); + }, []); + useEffect(() => { + setSelected(0); + }, [query]); + + function navigate(item: FlatItem) { + onClose(); + if (item.newWindow) window.open(item.href, "_blank", "noreferrer"); + else router.push(item.href); + } + + function onKeyDown(e: React.KeyboardEvent) { + if (e.key === "Escape") return onClose(); + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelected(i => Math.min(i + 1, results.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSelected(i => Math.max(i - 1, 0)); + } else if (e.key === "Enter" && results[selected]) { + navigate(results[selected]); + } + } + + return ( +
+
e.stopPropagation()} + > +
+ + setQuery(e.target.value)} + onKeyDown={onKeyDown} + placeholder="Search pages, settings, docs" + aria-label="Search" + className="flex-1 bg-transparent text-[15px] text-fg placeholder-faint outline-hidden focus:outline-hidden" + /> + Esc +
+
    + {results.length === 0 ? ( +
  • + No match{query && <> for "{query}"} +
  • + ) : ( + results.map((it, i) => { + const Icon = it.icon; + return ( +
  • + +
  • + ); + }) + )} +
+
+
+ ); +} + +export function AppShell({ + user, + trail = "Account", + isAdmin, + variant = "user", + children, +}: { + user: { name: string; username: string }; + trail?: string; + isAdmin?: boolean; + variant?: "user" | "admin"; + children: React.ReactNode; +}) { + const pathname = usePathname(); + const router = useRouter(); + const [collapsed, setCollapsed] = useState(false); + const [searchOpen, setSearchOpen] = useState(false); + const [isMac, setIsMac] = useState(false); + + const admin = variant === "admin"; + const nav = admin ? ADMIN_NAV : USER_NAV; + const homeHref = admin ? "/admin" : "/"; + const flatItems: FlatItem[] = nav.flatMap(g => g.items.map(it => ({ ...it, group: g.label }))); + + useEffect(() => { + setIsMac(navigator.platform.toUpperCase().includes("MAC")); + setCollapsed(localStorage.getItem("bn-sidebar") === "collapsed"); + }, []); + + function toggleCollapsed() { + setCollapsed(c => { + const next = !c; + localStorage.setItem("bn-sidebar", next ? "collapsed" : "expanded"); + return next; + }); + } + + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + if (e.key === "k" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + setSearchOpen(o => !o); + } + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, []); + + async function signOut() { + await fetch("/api/auth/logout", { method: "POST" }); + router.push("/login"); + } + + return ( +
+
+ + + + bottleneck + + + {admin && ( + + Admin + + )} + / + {trail} +
+ {admin ? ( + + Account + + ) : ( + <> + {isAdmin && ( + + Admin + + )} + + + + + )} + +
+
+ +
+ + +
+
+ {children} +
+
+
+ + {searchOpen && setSearchOpen(false)} />} +
+ ); +} diff --git a/components/Section.tsx b/components/Section.tsx index 206be78..003de09 100644 --- a/components/Section.tsx +++ b/components/Section.tsx @@ -1,6 +1,6 @@ -// A titled settings group: a heading with optional hint and action, then a -// white card holding rows divided by hairlines. Replaces the prior RFC-style -// rule-line subsection. +// A settings card: a white surface lifted by a hairline ring and a faint +// shadow, with the title in an elevated header strip across the top and rows +// beneath it. `data-mount-row` lets it ride the page-load stagger. export function Section({ title, @@ -16,8 +16,11 @@ export function Section({ children: React.ReactNode; }) { return ( -
-
+
+
{index && ( @@ -31,9 +34,7 @@ export function Section({
{action}
-
- {children} -
+
{children}
); } diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx deleted file mode 100644 index cbbab32..0000000 --- a/components/Sidebar.tsx +++ /dev/null @@ -1,324 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; -import Link from "next/link"; -import { usePathname, useRouter } from "next/navigation"; -import { Kbd } from "./Kbd"; - -type NavItem = { - href: string; - label: string; - group: string; - newWindow?: boolean; -}; - -const groups: { label: string; items: Omit[] }[] = [ - { - label: "Account", - items: [ - { href: "/#profile", label: "Profile" }, - { href: "/#subscriptions", label: "Subscriptions" }, - { href: "/#apps", label: "Connected apps" }, - { href: "/#bearers", label: "API bearers" }, - ], - }, - { - label: "Security", - items: [ - { href: "/#sessions", label: "Sessions" }, - { href: "/#security", label: "Password & 2FA" }, - { href: "/#events", label: "Recent events" }, - { href: "/security", label: "Security center" }, - ], - }, - { - label: "Developer", - items: [ - { href: "/developers/apps", label: "OAuth apps" }, - { href: "/developers/oauth", label: "OAuth docs", newWindow: true }, - { href: "/developers/test-lab", label: "Test field lab" }, - ], - }, - { - label: "Support", - items: [ - { href: "https://t.me/bottleneck_help", label: "Telegram" }, - { href: "/#faq", label: "FAQ" }, - ], - }, -]; - -const allItems: NavItem[] = groups.flatMap(g => - g.items.map(it => ({ ...it, group: g.label })), -); - -function Monogram() { - return ( - - ); -} - -function SearchPalette({ onClose }: { onClose: () => void }) { - const [query, setQuery] = useState(""); - const [selected, setSelected] = useState(0); - const inputRef = useRef(null); - const router = useRouter(); - - const results = query.trim() - ? allItems.filter( - it => - it.label.toLowerCase().includes(query.toLowerCase()) || - it.group.toLowerCase().includes(query.toLowerCase()), - ) - : allItems; - - useEffect(() => { - inputRef.current?.focus(); - }, []); - - useEffect(() => { - setSelected(0); - }, [query]); - - function navigate(item: NavItem) { - onClose(); - if (item.newWindow) { - window.open(item.href, "_blank", "noreferrer"); - } else { - router.push(item.href); - } - } - - function onKeyDown(e: React.KeyboardEvent) { - if (e.key === "Escape") { - onClose(); - return; - } - if (e.key === "ArrowDown") { - e.preventDefault(); - setSelected(i => Math.min(i + 1, results.length - 1)); - return; - } - if (e.key === "ArrowUp") { - e.preventDefault(); - setSelected(i => Math.max(i - 1, 0)); - return; - } - if (e.key === "Enter" && results[selected]) { - navigate(results[selected]); - } - } - - return ( -
-
e.stopPropagation()} - > -
- setQuery(e.target.value)} - onKeyDown={onKeyDown} - placeholder="Search pages, settings, docs" - aria-label="Search" - aria-controls="sidebar-search-results" - aria-activedescendant={ - results[selected] - ? `sidebar-search-option-${selected}` - : undefined - } - className="flex-1 bg-transparent text-[15px] text-fg placeholder-faint outline-hidden focus:outline-hidden focus-visible:outline-hidden" - /> - Esc -
- -
-
- - ↑↓ - Navigate - - - - Open - -
- - {results.length}/{allItems.length} - -
-
-
- ); -} - -export function Sidebar({ user }: { user: { name: string; username: string } }) { - const [searchOpen, setSearchOpen] = useState(false); - const [isMac, setIsMac] = useState(false); - const pathname = usePathname(); - - useEffect(() => { - setIsMac(navigator.platform.toUpperCase().includes("MAC")); - }, []); - - useEffect(() => { - function onKeyDown(e: KeyboardEvent) { - if (e.key === "k" && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - setSearchOpen(open => !open); - } - } - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, []); - - return ( - <> -
- - - - {searchOpen && setSearchOpen(false)} />} - - ); -} diff --git a/package-lock.json b/package-lock.json index e17454e..ab2e27b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@simplewebauthn/server": "^13.3.0", "bullmq": "^5.79.0", "ioredis": "5.10.1", + "lucide-react": "^1.21.0", "next": "^16.2.9", "pg": "^8.20.0", "prismjs": "^1.30.0", @@ -2554,6 +2555,15 @@ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", "license": "MIT" }, + "node_modules/lucide-react": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.21.0.tgz", + "integrity": "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/luxon": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", diff --git a/package.json b/package.json index ee253e4..3dfbe9d 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@simplewebauthn/server": "^13.3.0", "bullmq": "^5.79.0", "ioredis": "5.10.1", + "lucide-react": "^1.21.0", "next": "^16.2.9", "pg": "^8.20.0", "prismjs": "^1.30.0",