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>
|
<title>Andy Charlwood — MPharm | CV</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
+6
-1
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
|||||||
import type { Phase } from './types'
|
import type { Phase } from './types'
|
||||||
import { BootSequence } from './components/BootSequence'
|
import { BootSequence } from './components/BootSequence'
|
||||||
import { ECGAnimation } from './components/ECGAnimation'
|
import { ECGAnimation } from './components/ECGAnimation'
|
||||||
|
import { LoginScreen } from './components/LoginScreen'
|
||||||
import { FloatingNav } from './components/FloatingNav'
|
import { FloatingNav } from './components/FloatingNav'
|
||||||
import { Hero } from './components/Hero'
|
import { Hero } from './components/Hero'
|
||||||
import { Skills } from './components/Skills'
|
import { Skills } from './components/Skills'
|
||||||
@@ -21,7 +22,11 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{phase === 'ecg' && (
|
{phase === 'ecg' && (
|
||||||
<ECGAnimation onComplete={() => setPhase('content')} />
|
<ECGAnimation onComplete={() => setPhase('login')} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === 'login' && (
|
||||||
|
<LoginScreen onComplete={() => setPhase('content')} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{phase === '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
|
href?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Phase = 'boot' | 'ecg' | 'content'
|
export type Phase = 'boot' | 'ecg' | 'login' | 'content'
|
||||||
|
|
||||||
export interface BootLine {
|
export interface BootLine {
|
||||||
html: string
|
html: string
|
||||||
|
|||||||
@@ -33,11 +33,22 @@ export default {
|
|||||||
dim: '#3a6b45',
|
dim: '#3a6b45',
|
||||||
grey: '#666666',
|
grey: '#666666',
|
||||||
},
|
},
|
||||||
|
pmr: {
|
||||||
|
sidebar: '#1E293B',
|
||||||
|
banner: '#334155',
|
||||||
|
content: '#F5F7FA',
|
||||||
|
nhsblue: '#005EB8',
|
||||||
|
green: '#22C55E',
|
||||||
|
amber: '#F59E0B',
|
||||||
|
red: '#EF4444',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
primary: ['Plus Jakarta Sans', 'system-ui', 'sans-serif'],
|
primary: ['Plus Jakarta Sans', 'system-ui', 'sans-serif'],
|
||||||
secondary: ['Inter Tight', 'system-ui', 'sans-serif'],
|
secondary: ['Inter Tight', 'system-ui', 'sans-serif'],
|
||||||
mono: ['Fira Code', 'monospace'],
|
mono: ['Fira Code', 'monospace'],
|
||||||
|
inter: ['Inter', 'system-ui', 'sans-serif'],
|
||||||
|
geist: ['Geist Mono', 'Fira Code', 'monospace'],
|
||||||
},
|
},
|
||||||
boxShadow: {
|
boxShadow: {
|
||||||
'sm': '0 1px 3px rgba(0,0,0,0.06)',
|
'sm': '0 1px 3px rgba(0,0,0,0.06)',
|
||||||
|
|||||||
Reference in New Issue
Block a user