Skip to content

Commit 042b703

Browse files
committed
refactor(cli): extract login modal logic into custom hooks and utilities
- Created use-sheen-animation hook for logo animation logic - Extracted login polling into use-login-polling hook - Separated keyboard handlers into use-login-keyboard-handlers hook - Moved login constants to dedicated constants.ts file - Created login utils module for helper functions - Added login-store for state management - Improves code organization and reusability
1 parent f202a90 commit 042b703

File tree

6 files changed

+634
-0
lines changed

6 files changed

+634
-0
lines changed
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { useKeyboard } from '@opentui/react'
2+
import { useCallback } from 'react'
3+
4+
interface UseLoginKeyboardHandlersParams {
5+
loginUrl: string | null
6+
hasOpenedBrowser: boolean
7+
loading: boolean
8+
onFetchLoginUrl: () => void
9+
onCopyUrl: (url: string) => void
10+
}
11+
12+
/**
13+
* Custom hook that handles keyboard input for the login modal
14+
* - Enter key: fetch login URL and open browser
15+
* - 'c' key: copy URL to clipboard
16+
*/
17+
export function useLoginKeyboardHandlers({
18+
loginUrl,
19+
hasOpenedBrowser,
20+
loading,
21+
onFetchLoginUrl,
22+
onCopyUrl,
23+
}: UseLoginKeyboardHandlersParams) {
24+
useKeyboard(
25+
useCallback(
26+
(key: any) => {
27+
const isEnter =
28+
(key.name === 'return' || key.name === 'enter') &&
29+
!key.ctrl &&
30+
!key.meta &&
31+
!key.shift
32+
33+
const isCKey = key.name === 'c' && !key.ctrl && !key.meta && !key.shift
34+
35+
if (isEnter && !hasOpenedBrowser && !loading) {
36+
if (
37+
'preventDefault' in key &&
38+
typeof key.preventDefault === 'function'
39+
) {
40+
key.preventDefault()
41+
}
42+
43+
onFetchLoginUrl()
44+
}
45+
46+
if (isCKey && loginUrl && hasOpenedBrowser) {
47+
if (
48+
'preventDefault' in key &&
49+
typeof key.preventDefault === 'function'
50+
) {
51+
key.preventDefault()
52+
}
53+
54+
onCopyUrl(loginUrl)
55+
}
56+
},
57+
[loginUrl, hasOpenedBrowser, loading, onCopyUrl, onFetchLoginUrl],
58+
),
59+
)
60+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { useEffect, useRef } from 'react'
2+
3+
import { pollLoginStatus } from '../login/login-flow'
4+
import { WEBSITE_URL } from '../login/constants'
5+
import { logger } from '../utils/logger'
6+
7+
import type { User } from '../utils/auth'
8+
9+
interface UseLoginPollingParams {
10+
loginUrl: string | null
11+
fingerprintId: string
12+
fingerprintHash: string | null
13+
expiresAt: string | null
14+
isWaitingForEnter: boolean
15+
onSuccess: (user: User) => void
16+
onTimeout: () => void
17+
onError: (error: string) => void
18+
}
19+
20+
/**
21+
* Custom hook that handles polling for login status
22+
* Extracts the 109-line polling effect from login-modal.tsx
23+
*/
24+
export function useLoginPolling({
25+
loginUrl,
26+
fingerprintId,
27+
fingerprintHash,
28+
expiresAt,
29+
isWaitingForEnter,
30+
onSuccess,
31+
onTimeout,
32+
onError,
33+
}: UseLoginPollingParams) {
34+
// Store callbacks in refs to prevent effect re-runs
35+
const onSuccessRef = useRef(onSuccess)
36+
const onTimeoutRef = useRef(onTimeout)
37+
const onErrorRef = useRef(onError)
38+
39+
useEffect(() => {
40+
onSuccessRef.current = onSuccess
41+
}, [onSuccess])
42+
43+
useEffect(() => {
44+
onTimeoutRef.current = onTimeout
45+
}, [onTimeout])
46+
47+
useEffect(() => {
48+
onErrorRef.current = onError
49+
}, [onError])
50+
51+
useEffect(() => {
52+
if (!loginUrl || !fingerprintHash || !expiresAt || !isWaitingForEnter) {
53+
logger.debug(
54+
{
55+
loginUrl: !!loginUrl,
56+
fingerprintHash: !!fingerprintHash,
57+
expiresAt: !!expiresAt,
58+
isWaitingForEnter,
59+
},
60+
'πŸ” Polling prerequisites not met, skipping setup',
61+
)
62+
return
63+
}
64+
65+
let active = true
66+
67+
logger.info(
68+
{
69+
fingerprintId,
70+
fingerprintHash,
71+
expiresAt,
72+
loginUrl,
73+
},
74+
'πŸš€ Starting login polling session',
75+
)
76+
77+
const sleep = (ms: number) =>
78+
new Promise<void>((resolve) => {
79+
setTimeout(resolve, ms)
80+
})
81+
82+
pollLoginStatus(
83+
{
84+
fetch,
85+
sleep,
86+
logger,
87+
},
88+
{
89+
baseUrl: WEBSITE_URL,
90+
fingerprintId,
91+
fingerprintHash,
92+
expiresAt,
93+
shouldContinue: () => active,
94+
},
95+
)
96+
.then((result) => {
97+
if (!active) {
98+
return
99+
}
100+
101+
if (result.status === 'success') {
102+
const user = result.user as User
103+
logger.info(
104+
{
105+
attempts: result.attempts,
106+
user: user.name,
107+
},
108+
'βœ… Polling returned authenticated user',
109+
)
110+
111+
onSuccessRef.current(user)
112+
} else if (result.status === 'timeout') {
113+
logger.warn('Login polling timed out after configured limit')
114+
onTimeoutRef.current()
115+
}
116+
})
117+
.catch((error) => {
118+
if (!active) {
119+
return
120+
}
121+
logger.error(
122+
{
123+
error: error instanceof Error ? error.message : String(error),
124+
},
125+
'πŸ’₯ Unexpected error while polling login status',
126+
)
127+
onErrorRef.current(
128+
error instanceof Error ? error.message : 'Failed to complete login',
129+
)
130+
})
131+
132+
return () => {
133+
active = false
134+
}
135+
}, [loginUrl, fingerprintHash, expiresAt, isWaitingForEnter, fingerprintId])
136+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import React, { useCallback, useEffect, useMemo } from 'react'
2+
3+
import { getSheenColor } from '../login/utils'
4+
import {
5+
SHADOW_CHARS,
6+
SHEEN_WIDTH,
7+
SHEEN_STEP,
8+
SHEEN_INTERVAL_MS,
9+
} from '../login/constants'
10+
11+
interface UseSheenAnimationParams {
12+
logoColor: string
13+
terminalWidth: number | undefined
14+
sheenPosition: number
15+
setSheenPosition: (value: number | ((prev: number) => number)) => void
16+
}
17+
18+
/**
19+
* Custom hook that handles the sheen animation effect on the logo
20+
* Extracts sheen animation logic from login-modal.tsx
21+
*/
22+
export function useSheenAnimation({
23+
logoColor,
24+
terminalWidth,
25+
sheenPosition,
26+
setSheenPosition,
27+
}: UseSheenAnimationParams) {
28+
// Run sheen animation once
29+
useEffect(() => {
30+
const maxPosition = Math.max(10, Math.min((terminalWidth || 80) - 4, 100))
31+
const sheenWidth = SHEEN_WIDTH
32+
const step = SHEEN_STEP // Advance 2 positions per frame for efficiency
33+
const endPosition = maxPosition + sheenWidth
34+
35+
const interval = setInterval(() => {
36+
setSheenPosition((prev) => {
37+
const next = prev + step
38+
// Stop animation when we've cleared all characters
39+
return next >= endPosition ? endPosition : next
40+
})
41+
}, SHEEN_INTERVAL_MS)
42+
43+
// Calculate when animation should complete and clean up
44+
const animationDuration = Math.ceil(
45+
(endPosition / step) * SHEEN_INTERVAL_MS,
46+
)
47+
const stopTimeout = setTimeout(() => {
48+
clearInterval(interval)
49+
}, animationDuration)
50+
51+
return () => {
52+
clearInterval(interval)
53+
clearTimeout(stopTimeout)
54+
}
55+
}, [terminalWidth, setSheenPosition])
56+
57+
// Apply sheen effect to a character based on its position
58+
const applySheenToChar = useCallback(
59+
(char: string, charIndex: number, _lineIndex: number) => {
60+
if (char === ' ' || char === '\n') {
61+
return <span key={charIndex}>{char}</span>
62+
}
63+
64+
const color = getSheenColor(
65+
char,
66+
charIndex,
67+
sheenPosition,
68+
logoColor,
69+
SHADOW_CHARS,
70+
)
71+
72+
return (
73+
<span key={charIndex} fg={color}>
74+
{char}
75+
</span>
76+
)
77+
},
78+
[sheenPosition, logoColor],
79+
)
80+
81+
return {
82+
applySheenToChar,
83+
}
84+
}

β€Žcli/src/login/constants.tsβ€Ž

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Get the website URL from environment or use default
2+
export const WEBSITE_URL =
3+
process.env.NEXT_PUBLIC_CODEBUFF_APP_URL || 'https://codebuff.com'
4+
5+
// Codebuff ASCII Logo - compact version for 80-width terminals
6+
export const LOGO = `
7+
β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•— β–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—
8+
β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•”β•β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•”β•β•β•β•β•
9+
β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—
10+
β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β• β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β• β–ˆβ–ˆβ•”β•β•β•
11+
β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘
12+
β•šβ•β•β•β•β•β• β•šβ•β•β•β•β•β• β•šβ•β•β•β•β•β• β•šβ•β•β•β•β•β•β•β•šβ•β•β•β•β•β• β•šβ•β•β•β•β•β• β•šβ•β• β•šβ•β•
13+
`
14+
15+
// UI Color constants
16+
export const LINK_COLOR_DEFAULT = '#3b82f6'
17+
export const LINK_COLOR_CLICKED = '#1e40af'
18+
export const COPY_SUCCESS_COLOR = '#22c55e'
19+
export const COPY_ERROR_COLOR = '#ef4444'
20+
export const WARNING_COLOR = '#ef4444'
21+
22+
// Shadow/border characters that receive the sheen animation effect
23+
export const SHADOW_CHARS = new Set([
24+
'β•š',
25+
'═',
26+
'╝',
27+
'β•‘',
28+
'β•”',
29+
'β•—',
30+
'β• ',
31+
'β•£',
32+
'╦',
33+
'β•©',
34+
'╬',
35+
])
36+
37+
// Modal sizing constants
38+
export const DEFAULT_TERMINAL_HEIGHT = 24
39+
export const MODAL_VERTICAL_MARGIN = 2 // Space for top positioning (1) + bottom margin (1)
40+
export const MAX_MODAL_BASE_HEIGHT = 22 // Maximum height when no warning banner
41+
export const WARNING_BANNER_HEIGHT = 3 // Height of invalid credentials banner (padding + text + padding)
42+
43+
// Sheen animation constants
44+
export const SHEEN_WIDTH = 5
45+
export const SHEEN_STEP = 2 // Advance 2 positions per frame for efficiency
46+
export const SHEEN_INTERVAL_MS = 150

0 commit comments

Comments
Β (0)