Task 3: Build LoginScreen component with typing animation
- Created LoginScreen.tsx with character-by-character username typing (30ms/char) - Password dots fill at 20ms per dot - Button shows pressed state before transition - Added 'login' phase to App.tsx flow - Added PMR colors and fonts to tailwind.config.js - Added Inter font family to index.html - Respects prefers-reduced-motion: instant completion in ~500ms
This commit is contained in:
+2
-1
@@ -7,7 +7,8 @@
|
||||
<title>Andy Charlwood — MPharm | CV</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Inter+Tight:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Inter+Tight:wght@400;500;600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+6
-1
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import type { Phase } from './types'
|
||||
import { BootSequence } from './components/BootSequence'
|
||||
import { ECGAnimation } from './components/ECGAnimation'
|
||||
import { LoginScreen } from './components/LoginScreen'
|
||||
import { FloatingNav } from './components/FloatingNav'
|
||||
import { Hero } from './components/Hero'
|
||||
import { Skills } from './components/Skills'
|
||||
@@ -21,7 +22,11 @@ function App() {
|
||||
)}
|
||||
|
||||
{phase === 'ecg' && (
|
||||
<ECGAnimation onComplete={() => setPhase('content')} />
|
||||
<ECGAnimation onComplete={() => setPhase('login')} />
|
||||
)}
|
||||
|
||||
{phase === 'login' && (
|
||||
<LoginScreen onComplete={() => setPhase('content')} />
|
||||
)}
|
||||
|
||||
{phase === 'content' && (
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Shield } from 'lucide-react'
|
||||
|
||||
interface LoginScreenProps {
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
export function LoginScreen({ onComplete }: LoginScreenProps) {
|
||||
const [username, setUsername] = useState('')
|
||||
const [passwordDots, setPasswordDots] = useState(0)
|
||||
const [showCursor, setShowCursor] = useState(true)
|
||||
const [isTypingUsername, setIsTypingUsername] = useState(true)
|
||||
const [isTypingPassword, setIsTypingPassword] = useState(false)
|
||||
const [buttonPressed, setButtonPressed] = useState(false)
|
||||
|
||||
const fullUsername = 'A.CHARLWOOD'
|
||||
const passwordLength = 8
|
||||
|
||||
const prefersReducedMotion = typeof window !== 'undefined'
|
||||
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
: false
|
||||
|
||||
const startLoginSequence = useCallback(() => {
|
||||
if (prefersReducedMotion) {
|
||||
setUsername(fullUsername)
|
||||
setPasswordDots(passwordLength)
|
||||
setTimeout(() => {
|
||||
setButtonPressed(true)
|
||||
setTimeout(onComplete, 200)
|
||||
}, 300)
|
||||
return
|
||||
}
|
||||
|
||||
setIsTypingUsername(true)
|
||||
let usernameIndex = 0
|
||||
|
||||
const usernameInterval = setInterval(() => {
|
||||
if (usernameIndex <= fullUsername.length) {
|
||||
setUsername(fullUsername.slice(0, usernameIndex))
|
||||
usernameIndex++
|
||||
} else {
|
||||
clearInterval(usernameInterval)
|
||||
setIsTypingUsername(false)
|
||||
setIsTypingPassword(true)
|
||||
|
||||
setTimeout(() => {
|
||||
let dotCount = 0
|
||||
const passwordInterval = setInterval(() => {
|
||||
if (dotCount <= passwordLength) {
|
||||
setPasswordDots(dotCount)
|
||||
dotCount++
|
||||
} else {
|
||||
clearInterval(passwordInterval)
|
||||
setIsTypingPassword(false)
|
||||
|
||||
setTimeout(() => {
|
||||
setButtonPressed(true)
|
||||
setTimeout(onComplete, 200)
|
||||
}, 150)
|
||||
}
|
||||
}, 20)
|
||||
}, 150)
|
||||
}
|
||||
}, 30)
|
||||
}, [onComplete, prefersReducedMotion])
|
||||
|
||||
useEffect(() => {
|
||||
const cursorInterval = setInterval(() => {
|
||||
setShowCursor(prev => !prev)
|
||||
}, 530)
|
||||
|
||||
startLoginSequence()
|
||||
|
||||
return () => clearInterval(cursorInterval)
|
||||
}, [startLoginSequence])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center z-50"
|
||||
style={{ backgroundColor: '#1E293B' }}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-xl shadow-lg p-8"
|
||||
style={{
|
||||
width: '320px',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 10px 40px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-center mb-6">
|
||||
<div
|
||||
className="p-3 rounded-lg mb-4"
|
||||
style={{ backgroundColor: 'rgba(0, 94, 184, 0.1)' }}
|
||||
>
|
||||
<Shield
|
||||
size={32}
|
||||
style={{ color: '#005EB8' }}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className="text-sm font-medium"
|
||||
style={{ color: '#6B7280' }}
|
||||
>
|
||||
CareerRecord PMR
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
className="block text-xs font-medium mb-1.5"
|
||||
style={{ color: '#6B7280' }}
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<div
|
||||
className="w-full px-3 py-2.5 rounded text-sm"
|
||||
style={{
|
||||
fontFamily: "'Fira Code', monospace",
|
||||
backgroundColor: '#F9FAFB',
|
||||
border: '1px solid #E5E7EB',
|
||||
color: '#111827',
|
||||
}}
|
||||
>
|
||||
<span>{username}</span>
|
||||
{isTypingUsername && (
|
||||
<span
|
||||
style={{
|
||||
opacity: showCursor ? 1 : 0,
|
||||
color: '#005EB8',
|
||||
}}
|
||||
>
|
||||
|
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className="block text-xs font-medium mb-1.5"
|
||||
style={{ color: '#6B7280' }}
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div
|
||||
className="w-full px-3 py-2.5 rounded text-sm"
|
||||
style={{
|
||||
fontFamily: "'Fira Code', monospace",
|
||||
backgroundColor: '#F9FAFB',
|
||||
border: '1px solid #E5E7EB',
|
||||
color: '#111827',
|
||||
letterSpacing: '0.1em',
|
||||
}}
|
||||
>
|
||||
<span>{'\u2022'.repeat(passwordDots)}</span>
|
||||
{isTypingPassword && (
|
||||
<span
|
||||
style={{
|
||||
opacity: showCursor ? 1 : 0,
|
||||
color: '#005EB8',
|
||||
}}
|
||||
>
|
||||
|
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="w-full py-2.5 rounded text-sm font-semibold text-white transition-all duration-100"
|
||||
style={{
|
||||
backgroundColor: buttonPressed ? '#004494' : '#005EB8',
|
||||
borderRadius: '4px',
|
||||
transform: buttonPressed ? 'scale(0.98)' : 'scale(1)',
|
||||
}}
|
||||
>
|
||||
Log In
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-gray-100">
|
||||
<p
|
||||
className="text-xs text-center"
|
||||
style={{ color: '#9CA3AF' }}
|
||||
>
|
||||
Secure clinical system login
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -33,7 +33,7 @@ export interface ContactItem {
|
||||
href?: string
|
||||
}
|
||||
|
||||
export type Phase = 'boot' | 'ecg' | 'content'
|
||||
export type Phase = 'boot' | 'ecg' | 'login' | 'content'
|
||||
|
||||
export interface BootLine {
|
||||
html: string
|
||||
|
||||
@@ -33,11 +33,22 @@ export default {
|
||||
dim: '#3a6b45',
|
||||
grey: '#666666',
|
||||
},
|
||||
pmr: {
|
||||
sidebar: '#1E293B',
|
||||
banner: '#334155',
|
||||
content: '#F5F7FA',
|
||||
nhsblue: '#005EB8',
|
||||
green: '#22C55E',
|
||||
amber: '#F59E0B',
|
||||
red: '#EF4444',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
primary: ['Plus Jakarta Sans', 'system-ui', 'sans-serif'],
|
||||
secondary: ['Inter Tight', 'system-ui', 'sans-serif'],
|
||||
mono: ['Fira Code', 'monospace'],
|
||||
inter: ['Inter', 'system-ui', 'sans-serif'],
|
||||
geist: ['Geist Mono', 'Fira Code', 'monospace'],
|
||||
},
|
||||
boxShadow: {
|
||||
'sm': '0 1px 3px rgba(0,0,0,0.06)',
|
||||
|
||||
Reference in New Issue
Block a user