r9stack 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +217 -0
  3. package/dist/commands/init.d.ts +10 -0
  4. package/dist/commands/init.d.ts.map +1 -0
  5. package/dist/commands/init.js +239 -0
  6. package/dist/commands/init.js.map +1 -0
  7. package/dist/index.d.ts +3 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +65 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/payload/assets/.gitkeep +0 -0
  12. package/dist/payload/assets/favicon.ico +0 -0
  13. package/dist/payload/assets/images/r9stack-logo-markonly-circle.png +0 -0
  14. package/dist/payload/assets/images/r9stack-logo-markonly-whitebg.png +0 -0
  15. package/dist/payload/assets/images/r9stack-logo-markonly.png +0 -0
  16. package/dist/payload/assets/images/r9stack-logo.png +0 -0
  17. package/dist/payload/assets/logo192.png +0 -0
  18. package/dist/payload/assets/logo512.png +0 -0
  19. package/dist/payload/assets/manifest.json +26 -0
  20. package/dist/payload/assets/robots.txt +3 -0
  21. package/dist/payload/templates/.gitkeep +0 -0
  22. package/dist/payload/templates/config/components.json +25 -0
  23. package/dist/payload/templates/config/env.example +14 -0
  24. package/dist/payload/templates/config/tsconfig.json +26 -0
  25. package/dist/payload/templates/config/vite.config.ts +23 -0
  26. package/dist/payload/templates/convex/auth.config.ts +7 -0
  27. package/dist/payload/templates/convex/messages.ts +28 -0
  28. package/dist/payload/templates/convex/schema.ts +24 -0
  29. package/dist/payload/templates/convex/tsconfig.json +21 -0
  30. package/dist/payload/templates/src/components/AppShell.tsx +21 -0
  31. package/dist/payload/templates/src/components/AuthProvider.tsx +50 -0
  32. package/dist/payload/templates/src/components/ConvexClientProvider.tsx +20 -0
  33. package/dist/payload/templates/src/components/NavGroup.tsx +46 -0
  34. package/dist/payload/templates/src/components/NavItem.tsx +36 -0
  35. package/dist/payload/templates/src/components/Sidebar.tsx +76 -0
  36. package/dist/payload/templates/src/components/UserMenu.tsx +102 -0
  37. package/dist/payload/templates/src/components/ui/button.tsx +59 -0
  38. package/dist/payload/templates/src/lib/auth-client.ts +29 -0
  39. package/dist/payload/templates/src/lib/auth-server.ts +97 -0
  40. package/dist/payload/templates/src/lib/auth.ts +15 -0
  41. package/dist/payload/templates/src/lib/utils.ts +7 -0
  42. package/dist/payload/templates/src/router.tsx +18 -0
  43. package/dist/payload/templates/src/routes/__root.tsx +53 -0
  44. package/dist/payload/templates/src/routes/app/demo/convex.messages.tsx +66 -0
  45. package/dist/payload/templates/src/routes/app/index.tsx +20 -0
  46. package/dist/payload/templates/src/routes/app/route.tsx +23 -0
  47. package/dist/payload/templates/src/routes/auth/callback.tsx +36 -0
  48. package/dist/payload/templates/src/routes/auth/sign-in.tsx +22 -0
  49. package/dist/payload/templates/src/routes/auth/sign-out.tsx +22 -0
  50. package/dist/payload/templates/src/routes/index.tsx +85 -0
  51. package/dist/payload/templates/src/styles.css +141 -0
  52. package/dist/utils/exec.d.ts +17 -0
  53. package/dist/utils/exec.d.ts.map +1 -0
  54. package/dist/utils/exec.js +49 -0
  55. package/dist/utils/exec.js.map +1 -0
  56. package/dist/utils/flight-rules.d.ts +5 -0
  57. package/dist/utils/flight-rules.d.ts.map +1 -0
  58. package/dist/utils/flight-rules.js +23 -0
  59. package/dist/utils/flight-rules.js.map +1 -0
  60. package/dist/utils/github.d.ts +17 -0
  61. package/dist/utils/github.d.ts.map +1 -0
  62. package/dist/utils/github.js +64 -0
  63. package/dist/utils/github.js.map +1 -0
  64. package/dist/utils/logger.d.ts +10 -0
  65. package/dist/utils/logger.d.ts.map +1 -0
  66. package/dist/utils/logger.js +27 -0
  67. package/dist/utils/logger.js.map +1 -0
  68. package/dist/utils/starters.d.ts +20 -0
  69. package/dist/utils/starters.d.ts.map +1 -0
  70. package/dist/utils/starters.js +43 -0
  71. package/dist/utils/starters.js.map +1 -0
  72. package/dist/utils/templates.d.ts +12 -0
  73. package/dist/utils/templates.d.ts.map +1 -0
  74. package/dist/utils/templates.js +77 -0
  75. package/dist/utils/templates.js.map +1 -0
  76. package/package.json +46 -0
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""}
package/dist/index.js ADDED
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import pc from "picocolors";
4
+ import { initCommand } from "./commands/init.js";
5
+ import { fetchStarters } from "./utils/starters.js";
6
+ import { logger } from "./utils/logger.js";
7
+ const program = new Command();
8
+ program
9
+ .name("r9stack")
10
+ .description("CLI tool that scaffolds opinionated SaaS projects")
11
+ .version("0.4.0");
12
+ // Starter list option
13
+ program
14
+ .option("--starter-list", "List available starters")
15
+ .hook("preAction", async (thisCommand) => {
16
+ if (thisCommand.opts().starterList) {
17
+ await listStarters();
18
+ process.exit(0);
19
+ }
20
+ });
21
+ program
22
+ .command("init [project-name]")
23
+ .description("Scaffold a new r9stack project")
24
+ .option("-y, --yes", "Skip confirmation prompts")
25
+ .option("-s, --starter <id>", "Use a specific starter (e.g., 'standard')")
26
+ .option("--no-flight-rules", "Skip Flight Rules installation")
27
+ .option("--github", "Create GitHub repository")
28
+ .option("--no-github", "Skip GitHub repository creation")
29
+ .option("--private", "Make GitHub repository private (default)")
30
+ .option("--public", "Make GitHub repository public")
31
+ .action(initCommand);
32
+ // Make init the default command when no command is specified
33
+ program.action(async (options) => {
34
+ if (options.starterList) {
35
+ // Already handled by hook
36
+ return;
37
+ }
38
+ await initCommand(undefined, {});
39
+ });
40
+ async function listStarters() {
41
+ logger.banner("r9stack - Available Starters");
42
+ try {
43
+ const starters = await fetchStarters();
44
+ if (starters.length === 0) {
45
+ logger.info("No starters available.");
46
+ return;
47
+ }
48
+ for (const starter of starters) {
49
+ console.log();
50
+ console.log(` ${pc.cyan(starter.name)} ${pc.dim(`v${starter.version}`)}`);
51
+ console.log(` ${pc.dim(starter.description)}`);
52
+ console.log(` ${pc.dim("ID:")} ${starter.id}`);
53
+ }
54
+ console.log();
55
+ logger.info("Create a project with:");
56
+ console.log(` ${pc.dim("$")} ${pc.cyan("npx r9stack init my-project")}`);
57
+ console.log();
58
+ }
59
+ catch {
60
+ logger.error("Failed to fetch starters. Please check your internet connection.");
61
+ process.exit(1);
62
+ }
63
+ }
64
+ program.parse();
65
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
File without changes
Binary file
Binary file
Binary file
@@ -0,0 +1,26 @@
1
+ {
2
+ "short_name": "{{PROJECT_NAME}}",
3
+ "name": "{{PROJECT_NAME}}",
4
+ "icons": [
5
+ {
6
+ "src": "favicon.ico",
7
+ "sizes": "64x64 32x32 24x24 16x16",
8
+ "type": "image/x-icon"
9
+ },
10
+ {
11
+ "src": "logo192.png",
12
+ "type": "image/png",
13
+ "sizes": "192x192"
14
+ },
15
+ {
16
+ "src": "logo512.png",
17
+ "type": "image/png",
18
+ "sizes": "512x512"
19
+ }
20
+ ],
21
+ "start_url": ".",
22
+ "display": "standalone",
23
+ "theme_color": "#000000",
24
+ "background_color": "#ffffff"
25
+ }
26
+
@@ -0,0 +1,3 @@
1
+ # https://www.robotstxt.org/robotstxt.html
2
+ User-agent: *
3
+ Disallow:
File without changes
@@ -0,0 +1,25 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "radix-vega",
4
+ "rsc": false,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "",
8
+ "css": "src/styles.css",
9
+ "baseColor": "gray",
10
+ "cssVariables": true,
11
+ "prefix": ""
12
+ },
13
+ "iconLibrary": "lucide",
14
+ "aliases": {
15
+ "components": "@/components",
16
+ "utils": "@/lib/utils",
17
+ "ui": "@/components/ui",
18
+ "lib": "@/lib",
19
+ "hooks": "@/hooks"
20
+ },
21
+ "menuColor": "default",
22
+ "menuAccent": "subtle",
23
+ "registries": {}
24
+ }
25
+
@@ -0,0 +1,14 @@
1
+ # Convex
2
+ # This is auto-populated when you run `npx convex dev`
3
+ VITE_CONVEX_URL=
4
+
5
+ # WorkOS
6
+ # Get these from https://dashboard.workos.com
7
+ WORKOS_API_KEY=
8
+ WORKOS_CLIENT_ID=
9
+ WORKOS_REDIRECT_URI=http://localhost:3000/auth/callback
10
+
11
+ # Session
12
+ # Generate with: openssl rand -base64 32
13
+ WORKOS_COOKIE_PASSWORD=
14
+
@@ -0,0 +1,26 @@
1
+ {
2
+ "include": ["**/*.ts", "**/*.tsx", "eslint.config.js", "prettier.config.js", "vite.config.js"],
3
+
4
+ "compilerOptions": {
5
+ "target": "ES2022",
6
+ "jsx": "react-jsx",
7
+ "module": "ESNext",
8
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
9
+ "types": ["vite/client"],
10
+ "moduleResolution": "bundler",
11
+ "allowImportingTsExtensions": true,
12
+ "verbatimModuleSyntax": false,
13
+ "noEmit": true,
14
+ "skipLibCheck": true,
15
+ "strict": true,
16
+ "noUnusedLocals": true,
17
+ "noUnusedParameters": true,
18
+ "noFallthroughCasesInSwitch": true,
19
+ "noUncheckedSideEffectImports": true,
20
+ "baseUrl": ".",
21
+ "paths": {
22
+ "@/*": ["./src/*"]
23
+ }
24
+ }
25
+ }
26
+
@@ -0,0 +1,23 @@
1
+ import { defineConfig } from 'vite'
2
+ import { devtools } from '@tanstack/devtools-vite'
3
+ import { tanstackStart } from '@tanstack/react-start/plugin/vite'
4
+ import viteReact from '@vitejs/plugin-react'
5
+ import viteTsConfigPaths from 'vite-tsconfig-paths'
6
+ import tailwindcss from '@tailwindcss/vite'
7
+ import { nitro } from 'nitro/vite'
8
+
9
+ const config = defineConfig({
10
+ plugins: [
11
+ devtools(),
12
+ nitro(),
13
+ viteTsConfigPaths({
14
+ projects: ['./tsconfig.json'],
15
+ }),
16
+ tailwindcss(),
17
+ tanstackStart(),
18
+ viteReact(),
19
+ ],
20
+ })
21
+
22
+ export default config
23
+
@@ -0,0 +1,7 @@
1
+ // Placeholder for Convex auth configuration
2
+ // This file will be configured when you set up Convex-level authentication
3
+
4
+ export default {
5
+ // Future: Add Convex auth providers here
6
+ }
7
+
@@ -0,0 +1,28 @@
1
+ import { query, mutation } from "./_generated/server";
2
+ import { v } from "convex/values";
3
+
4
+ export const list = query({
5
+ args: {},
6
+ handler: async (ctx) => {
7
+ const messages = await ctx.db
8
+ .query("messages")
9
+ .withIndex("by_created_at")
10
+ .order("desc")
11
+ .collect();
12
+ return messages;
13
+ },
14
+ });
15
+
16
+ export const send = mutation({
17
+ args: {
18
+ text: v.string(),
19
+ },
20
+ handler: async (ctx, args) => {
21
+ const messageId = await ctx.db.insert("messages", {
22
+ text: args.text,
23
+ createdAt: Date.now(),
24
+ });
25
+ return messageId;
26
+ },
27
+ });
28
+
@@ -0,0 +1,24 @@
1
+ import { defineSchema, defineTable } from 'convex/server'
2
+ import { v } from 'convex/values'
3
+
4
+ export default defineSchema({
5
+ // Demo messages table
6
+ messages: defineTable({
7
+ text: v.string(),
8
+ createdAt: v.number(),
9
+ }).index('by_created_at', ['createdAt']),
10
+
11
+ // Users table for storing WorkOS user information
12
+ users: defineTable({
13
+ workosId: v.string(),
14
+ email: v.string(),
15
+ firstName: v.optional(v.string()),
16
+ lastName: v.optional(v.string()),
17
+ profilePictureUrl: v.optional(v.string()),
18
+ createdAt: v.number(),
19
+ updatedAt: v.number(),
20
+ })
21
+ .index('by_workos_id', ['workosId'])
22
+ .index('by_email', ['email']),
23
+ })
24
+
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "lib": ["ES2021", "dom"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "forceConsistentCasingInFileNames": true,
9
+ "noEmit": true,
10
+ "incremental": true,
11
+ "esModuleInterop": true,
12
+ "module": "ESNext",
13
+ "moduleResolution": "Bundler",
14
+ "resolveJsonModule": true,
15
+ "isolatedModules": true,
16
+ "jsx": "react-jsx"
17
+ },
18
+ "include": ["./**/*"],
19
+ "exclude": ["_generated"]
20
+ }
21
+
@@ -0,0 +1,21 @@
1
+ import { useState, type ReactNode } from 'react'
2
+ import { Sidebar } from './Sidebar'
3
+
4
+ interface AppShellProps {
5
+ children: ReactNode
6
+ }
7
+
8
+ export function AppShell({ children }: AppShellProps) {
9
+ const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
10
+
11
+ return (
12
+ <div className="flex h-screen bg-background text-foreground">
13
+ <Sidebar
14
+ collapsed={sidebarCollapsed}
15
+ onToggle={() => setSidebarCollapsed(!sidebarCollapsed)}
16
+ />
17
+ <main className="flex-1 overflow-auto">{children}</main>
18
+ </div>
19
+ )
20
+ }
21
+
@@ -0,0 +1,50 @@
1
+ import { useState, useEffect, type ReactNode } from 'react'
2
+ import { AuthContext, signIn, signOut } from '../lib/auth-client'
3
+ import { getCurrentUser } from '../lib/auth-server'
4
+ import type { User } from '../lib/auth'
5
+
6
+ interface AuthProviderProps {
7
+ children: ReactNode
8
+ initialUser?: User | null
9
+ }
10
+
11
+ export function AuthProvider({ children, initialUser = null }: AuthProviderProps) {
12
+ const [user, setUser] = useState<User | null>(initialUser)
13
+ const [isLoading, setIsLoading] = useState(initialUser === null)
14
+
15
+ useEffect(() => {
16
+ if (initialUser !== null) {
17
+ setUser(initialUser)
18
+ setIsLoading(false)
19
+ return
20
+ }
21
+
22
+ async function fetchUser() {
23
+ try {
24
+ const currentUser = await getCurrentUser()
25
+ setUser(currentUser)
26
+ } catch {
27
+ setUser(null)
28
+ } finally {
29
+ setIsLoading(false)
30
+ }
31
+ }
32
+
33
+ fetchUser()
34
+ }, [initialUser])
35
+
36
+ return (
37
+ <AuthContext.Provider
38
+ value={{
39
+ user,
40
+ isAuthenticated: !!user,
41
+ isLoading,
42
+ signIn,
43
+ signOut,
44
+ }}
45
+ >
46
+ {children}
47
+ </AuthContext.Provider>
48
+ )
49
+ }
50
+
@@ -0,0 +1,20 @@
1
+ import { ConvexProvider, ConvexReactClient } from "convex/react";
2
+ import { ReactNode, useEffect, useState } from "react";
3
+
4
+ export function ConvexClientProvider({ children }: { children: ReactNode }) {
5
+ const [client, setClient] = useState<ConvexReactClient | null>(null);
6
+
7
+ useEffect(() => {
8
+ const convexUrl = import.meta.env.VITE_CONVEX_URL as string;
9
+ if (convexUrl) {
10
+ setClient(new ConvexReactClient(convexUrl));
11
+ }
12
+ }, []);
13
+
14
+ if (!client) {
15
+ return <>{children}</>;
16
+ }
17
+
18
+ return <ConvexProvider client={client}>{children}</ConvexProvider>;
19
+ }
20
+
@@ -0,0 +1,46 @@
1
+ import { useState, type ReactNode } from 'react'
2
+ import { ChevronDown, ChevronRight } from 'lucide-react'
3
+ import { cn } from '../lib/utils'
4
+
5
+ interface NavGroupProps {
6
+ label: string
7
+ children: ReactNode
8
+ collapsed?: boolean
9
+ defaultExpanded?: boolean
10
+ }
11
+
12
+ export function NavGroup({
13
+ label,
14
+ children,
15
+ collapsed,
16
+ defaultExpanded = true,
17
+ }: NavGroupProps) {
18
+ const [expanded, setExpanded] = useState(defaultExpanded)
19
+
20
+ if (collapsed) {
21
+ return <div className="space-y-1">{children}</div>
22
+ }
23
+
24
+ return (
25
+ <div className="space-y-1">
26
+ <button
27
+ onClick={() => setExpanded(!expanded)}
28
+ className={cn(
29
+ 'flex items-center justify-between w-full px-3 py-2',
30
+ 'text-xs font-semibold uppercase tracking-wider',
31
+ 'text-sidebar-foreground/50 hover:text-sidebar-foreground/70',
32
+ 'transition-colors'
33
+ )}
34
+ >
35
+ <span>{label}</span>
36
+ {expanded ? (
37
+ <ChevronDown className="h-3 w-3" />
38
+ ) : (
39
+ <ChevronRight className="h-3 w-3" />
40
+ )}
41
+ </button>
42
+ {expanded && <div className="space-y-1">{children}</div>}
43
+ </div>
44
+ )
45
+ }
46
+
@@ -0,0 +1,36 @@
1
+ import { Link } from '@tanstack/react-router'
2
+ import type { ReactNode } from 'react'
3
+ import { cn } from '../lib/utils'
4
+
5
+ interface NavItemProps {
6
+ to: string
7
+ icon: ReactNode
8
+ label: string
9
+ collapsed?: boolean
10
+ }
11
+
12
+ export function NavItem({ to, icon, label, collapsed }: NavItemProps) {
13
+ return (
14
+ <Link
15
+ to={to}
16
+ className={cn(
17
+ 'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium',
18
+ 'text-sidebar-foreground/70 hover:text-sidebar-foreground',
19
+ 'hover:bg-sidebar-accent transition-colors',
20
+ collapsed && 'justify-center px-2'
21
+ )}
22
+ activeProps={{
23
+ className: cn(
24
+ 'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium',
25
+ 'bg-sidebar-accent text-sidebar-accent-foreground',
26
+ 'transition-colors',
27
+ collapsed && 'justify-center px-2'
28
+ ),
29
+ }}
30
+ >
31
+ <span className="flex-shrink-0">{icon}</span>
32
+ {!collapsed && <span>{label}</span>}
33
+ </Link>
34
+ )
35
+ }
36
+
@@ -0,0 +1,76 @@
1
+ import { Home, MessageSquare, PanelLeftClose, PanelLeft } from 'lucide-react'
2
+ import { cn } from '../lib/utils'
3
+ import { NavGroup } from './NavGroup'
4
+ import { NavItem } from './NavItem'
5
+ import { UserMenu } from './UserMenu'
6
+
7
+ interface SidebarProps {
8
+ collapsed: boolean
9
+ onToggle: () => void
10
+ }
11
+
12
+ export function Sidebar({ collapsed, onToggle }: SidebarProps) {
13
+ return (
14
+ <aside
15
+ className={cn(
16
+ 'flex flex-col h-full bg-sidebar border-r border-sidebar-border',
17
+ 'transition-all duration-200 ease-in-out',
18
+ collapsed ? 'w-14' : 'w-60'
19
+ )}
20
+ >
21
+ {/* Header with collapse toggle */}
22
+ <div
23
+ className={cn(
24
+ 'flex items-center h-14 px-3 border-b border-sidebar-border',
25
+ collapsed ? 'justify-center' : 'justify-between'
26
+ )}
27
+ >
28
+ {!collapsed && (
29
+ <span className="text-lg font-semibold text-sidebar-foreground">
30
+ {{PROJECT_NAME}}
31
+ </span>
32
+ )}
33
+ <button
34
+ onClick={onToggle}
35
+ className={cn(
36
+ 'p-2 rounded-md',
37
+ 'text-sidebar-foreground/70 hover:text-sidebar-foreground',
38
+ 'hover:bg-sidebar-accent transition-colors'
39
+ )}
40
+ aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
41
+ >
42
+ {collapsed ? (
43
+ <PanelLeft className="w-4 h-4" />
44
+ ) : (
45
+ <PanelLeftClose className="w-4 h-4" />
46
+ )}
47
+ </button>
48
+ </div>
49
+
50
+ {/* Navigation */}
51
+ <nav className="flex-1 p-2 space-y-4 overflow-y-auto">
52
+ <NavItem
53
+ to="/app"
54
+ icon={<Home className="w-4 h-4" />}
55
+ label="Home"
56
+ collapsed={collapsed}
57
+ />
58
+
59
+ <NavGroup label="Demos" collapsed={collapsed}>
60
+ <NavItem
61
+ to="/app/demo/convex/messages"
62
+ icon={<MessageSquare className="w-4 h-4" />}
63
+ label="Messages"
64
+ collapsed={collapsed}
65
+ />
66
+ </NavGroup>
67
+ </nav>
68
+
69
+ {/* User menu at bottom */}
70
+ <div className="p-2 border-t border-sidebar-border">
71
+ <UserMenu collapsed={collapsed} />
72
+ </div>
73
+ </aside>
74
+ )
75
+ }
76
+
@@ -0,0 +1,102 @@
1
+ import { ChevronUp, LogOut } from 'lucide-react'
2
+ import { useState, useRef, useEffect } from 'react'
3
+ import { cn } from '../lib/utils'
4
+ import { useAuth } from '../lib/auth-client'
5
+
6
+ interface UserMenuProps {
7
+ collapsed?: boolean
8
+ }
9
+
10
+ export function UserMenu({ collapsed }: UserMenuProps) {
11
+ const { user, signOut } = useAuth()
12
+ const [isOpen, setIsOpen] = useState(false)
13
+ const menuRef = useRef<HTMLDivElement>(null)
14
+
15
+ useEffect(() => {
16
+ function handleClickOutside(event: MouseEvent) {
17
+ if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
18
+ setIsOpen(false)
19
+ }
20
+ }
21
+ document.addEventListener('mousedown', handleClickOutside)
22
+ return () => document.removeEventListener('mousedown', handleClickOutside)
23
+ }, [])
24
+
25
+ const displayName = user?.firstName || user?.email?.split('@')[0] || 'User'
26
+ const initials = user?.firstName?.[0]?.toUpperCase() || user?.email?.[0]?.toUpperCase() || 'U'
27
+
28
+ return (
29
+ <div className="relative" ref={menuRef}>
30
+ <button
31
+ onClick={() => setIsOpen(!isOpen)}
32
+ className={cn(
33
+ 'flex items-center gap-3 w-full p-3 rounded-md',
34
+ 'hover:bg-sidebar-accent transition-colors',
35
+ 'text-sidebar-foreground',
36
+ collapsed && 'justify-center'
37
+ )}
38
+ >
39
+ <div className="flex-shrink-0 w-8 h-8 rounded-full bg-sidebar-primary flex items-center justify-center overflow-hidden">
40
+ {user?.profilePictureUrl ? (
41
+ <img
42
+ src={user.profilePictureUrl}
43
+ alt={displayName}
44
+ className="w-8 h-8 rounded-full object-cover"
45
+ />
46
+ ) : (
47
+ <span className="text-sm font-medium text-sidebar-primary-foreground">
48
+ {initials}
49
+ </span>
50
+ )}
51
+ </div>
52
+ {!collapsed && (
53
+ <>
54
+ <div className="flex-1 text-left min-w-0">
55
+ <p className="text-sm font-medium truncate">{displayName}</p>
56
+ <p className="text-xs text-sidebar-foreground/50 truncate">
57
+ {user?.email}
58
+ </p>
59
+ </div>
60
+ <ChevronUp
61
+ className={cn(
62
+ 'w-4 h-4 text-sidebar-foreground/50 transition-transform',
63
+ isOpen && 'rotate-180'
64
+ )}
65
+ />
66
+ </>
67
+ )}
68
+ </button>
69
+
70
+ {isOpen && (
71
+ <div
72
+ className={cn(
73
+ 'absolute bottom-full mb-2 bg-popover border border-border rounded-lg shadow-lg py-1 min-w-[200px]',
74
+ collapsed ? 'left-full ml-2' : 'left-0 right-0'
75
+ )}
76
+ >
77
+ <div className="px-3 py-2 border-b border-border">
78
+ <p className="text-sm font-medium text-foreground truncate">
79
+ {user?.firstName && user?.lastName
80
+ ? `${user.firstName} ${user.lastName}`
81
+ : displayName}
82
+ </p>
83
+ <p className="text-xs text-muted-foreground truncate">
84
+ {user?.email}
85
+ </p>
86
+ </div>
87
+ <button
88
+ onClick={() => {
89
+ setIsOpen(false)
90
+ signOut()
91
+ }}
92
+ className="w-full px-3 py-2 text-left text-sm text-foreground hover:bg-accent flex items-center gap-2"
93
+ >
94
+ <LogOut className="w-4 h-4" />
95
+ Sign out
96
+ </button>
97
+ </div>
98
+ )}
99
+ </div>
100
+ )
101
+ }
102
+