Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
22ae9de
refactor: network setup
timofeykafanov Feb 19, 2026
4d082a0
Enhance Game and Home Routes with Improved UI and Functionality
timofeykafanov Feb 22, 2026
2bf3567
feat: add endpoints for fetching user's games
LuckyIntegral Feb 22, 2026
51c9cea
docs: add swagger to GET /games endpoint
LuckyIntegral Feb 22, 2026
46f0fe5
feat: add pagination
LuckyIntegral Feb 23, 2026
6b4bc80
chore: adjust path to .env
LuckyIntegral Feb 23, 2026
e056c52
test: use test connection for tests
LuckyIntegral Feb 23, 2026
1839930
test: add 50% branch coverage
LuckyIntegral Feb 23, 2026
130daef
chore: delete start_at field
LuckyIntegral Feb 23, 2026
540181b
chore: hide user emails
LuckyIntegral Feb 23, 2026
e104022
test: add error handling on testConnection fail
LuckyIntegral Feb 23, 2026
784d3f7
perf: push directly to players array, instead of recreating the entir…
LuckyIntegral Feb 23, 2026
b1716d6
Merge game-history into #8-pretty-frontend
timofeykafanov Feb 24, 2026
f57da04
feat: implement games service and integrate user stats in Welcome com…
timofeykafanov Feb 24, 2026
2e5e3c4
fix: update CLIENT_PORT in .env.example and adjust socket connection …
timofeykafanov Feb 24, 2026
65e3687
style: improve code formatting and consistency across components and …
timofeykafanov Feb 24, 2026
9880b40
Refactor game controls and UI components for improved user experience
timofeykafanov Feb 26, 2026
9a55ff3
feat: enhance authentication flow with hydration and loading states i…
timofeykafanov Feb 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .devcontainer/start-dev.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ done

echo "MySQL is ready!"

mysql -e "CREATE DATABASE IF NOT EXISTS red_tetris;"
mysql -e "CREATE USER IF NOT EXISTS 'app'@'%' IDENTIFIED BY 'app_pw_change_me';"
mysql -e "CREATE DATABASE IF NOT EXISTS red_tetris;"
mysql -e "GRANT ALL PRIVILEGES ON red_tetris.* TO 'app'@'%';"
mysql -e "CREATE DATABASE IF NOT EXISTS red_tetris_test;"
mysql -e "GRANT ALL PRIVILEGES ON red_tetris_test.* TO 'app'@'%';"

mysql -e "FLUSH PRIVILEGES;"

echo "Done!"
10 changes: 7 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@ DB_PASSWORD=app_pw_change_me
DB_NAME=red_tetris
DB_PORT=3306

CLIENT_URL=http://localhost:3001,http://127.0.0.1:3001
DB_PASSWORD_TEST=app_pw_change_me
DB_NAME_TEST=red_tetris_test

CLIENT_URL=http://localhost:3000,http://127.0.0.1:3000
CLIENT_PORT=3001

# Client Configuration
SERVER_PORT=3002
VITE_SERVER_URL=http://localhost:3002
VITE_SERVER_URL=auto

# JWT Configuration
# Generate a strong secret (64 random bytes in hex) with:
# node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
JWT_SECRET=c1d841b16e7e5e19675bad6eee8fab760e82e9c9fbb59ef0b6da1b9d82c78b7ca8e850bbeff8c09d3c897f87b9fd8cfb4ab5ef0aa756041f1d736e8362bbbcc9_change_me
JWT_EXPIRES_IN=7d
JWT_EXPIRES_IN=7d
1 change: 1 addition & 0 deletions client/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
# React Router
/.react-router/
/build/
**/+types/
3 changes: 2 additions & 1 deletion client/app/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@

html,
body {
@apply bg-white dark:bg-gray-950;
background: #0f172a;
min-height: 100vh;

@media (prefers-color-scheme: dark) {
color-scheme: dark;
Expand Down
12 changes: 12 additions & 0 deletions client/app/components/LoadingOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export function LoadingOverlay() {
return (
<div className='fixed inset-0 bg-black/40 flex items-center justify-center z-50'>
<div className='bg-gray-900 rounded-xl p-6 border border-gray-600'>
<div className='text-center'>
<div className='w-8 h-8 border-2 border-red-500 border-t-transparent rounded-full animate-spin mx-auto mb-4'></div>
<p className='text-gray-300 font-medium'>Loading...</p>
</div>
</div>
</div>
);
}
165 changes: 97 additions & 68 deletions client/app/components/auth/LoginForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,85 +28,114 @@ export function LoginForm() {
};

return (
<div className='flex min-h-screen items-center justify-center bg-white dark:bg-gray-950 px-4 py-12 sm:px-6 lg:px-8'>
<div className='w-full max-w-md space-y-8'>
<div>
<h2 className='mt-6 text-center text-3xl font-bold tracking-tight text-gray-900 dark:text-gray-100'>
Red Tetris
</h2>
<p className='mt-2 text-center text-sm text-gray-600 dark:text-gray-400'>Sign in to your account</p>
<main className='min-h-screen bg-slate-900 flex items-center justify-center px-4 py-12'>
<div className='w-full max-w-md'>
{/* Header */}
<div className='text-center mb-8'>
<h1 className='text-5xl font-bold text-red-500 mb-4'>RED TETRIS</h1>
<p className='text-xl text-gray-300'>Welcome Back!</p>
<p className='text-gray-400 mt-2'>Sign in to continue your game</p>
</div>

<form
className='mt-8 space-y-6'
onSubmit={(e) => {
void handleSubmit(e);
}}
>
{error && (
<div className='rounded-md bg-red-50 dark:bg-red-900/20 p-4'>
<div className='text-sm text-red-800 dark:text-red-300'>{error}</div>
</div>
)}
{/* Login Form */}
<div className='bg-gray-900 rounded-xl p-8 border border-gray-600'>
<form
className='space-y-6'
onSubmit={(e) => {
void handleSubmit(e);
}}
>
{error && (
<div className='bg-red-500/20 backdrop-blur-sm border border-red-400/30 rounded-lg p-4'>
<div className='text-red-200 text-sm text-center'>{error}</div>
</div>
)}

<div className='-space-y-px rounded-md shadow-sm'>
<div>
<label
htmlFor='username'
className='sr-only'
>
Username
</label>
<input
id='username'
name='username'
type='text'
required
className='relative block w-full appearance-none rounded-none rounded-t-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:z-10 focus:border-blue-500 dark:focus:border-blue-400 focus:outline-none focus:ring-blue-500 dark:focus:ring-blue-400 sm:text-sm'
placeholder='Username'
disabled={isLoading}
/>
</div>
<div>
<label
htmlFor='password'
className='sr-only'
>
Password
</label>
<input
id='password'
name='password'
type='password'
required
className='relative block w-full appearance-none rounded-none rounded-b-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:z-10 focus:border-blue-500 dark:focus:border-blue-400 focus:outline-none focus:ring-blue-500 dark:focus:ring-blue-400 sm:text-sm'
placeholder='Password'
disabled={isLoading}
/>
<div className='space-y-4'>
<div>
<label
htmlFor='username'
className='block text-sm font-medium text-gray-300 mb-2'
>
Username
</label>
<input
id='username'
name='username'
type='text'
required
className='w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 transition-colors'
placeholder='Enter your username'
disabled={isLoading}
/>
</div>
<div>
<label
htmlFor='password'
className='block text-sm font-medium text-gray-400 mb-2'
>
Password
</label>
<input
id='password'
name='password'
type='password'
required
className='w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 transition-colors'
placeholder='Enter your password'
disabled={isLoading}
/>
</div>
</div>
</div>

<div>
<button
type='submit'
disabled={isLoading}
className='group relative flex w-full justify-center rounded-md border border-transparent bg-blue-600 dark:bg-blue-700 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 dark:hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-offset-2 dark:focus:ring-offset-gray-950 disabled:opacity-50'
className='w-full bg-red-600 hover:bg-red-500 disabled:bg-gray-700 disabled:opacity-70 text-white font-bold py-4 px-6 rounded-lg transition-colors'
>
{isLoading ? 'Signing in...' : 'Sign in'}
{isLoading ? (
<span className='flex items-center justify-center'>
<svg
className='animate-spin -ml-1 mr-3 h-5 w-5 text-white'
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
>
<circle
className='opacity-25'
cx='12'
cy='12'
r='10'
stroke='currentColor'
strokeWidth='4'
></circle>
<path
className='opacity-75'
fill='currentColor'
d='m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z'
></path>
</svg>
Signing in...
</span>
) : (
'Sign In'
)}
</button>
</div>

<div className='text-center text-sm'>
<span className='text-gray-600 dark:text-gray-400'>Don&apos;t have an account? </span>
<Link
to='/register'
className='font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300'
>
Register here
</Link>
</div>
</form>
<div className='text-center pt-4 border-t border-white/10'>
<p className='text-gray-400 text-sm'>
Don't have an account?{' '}
<Link
to='/register'
className='text-red-400 hover:text-red-300 font-medium hover:underline transition-colors'
>
Create one here
</Link>
</p>
</div>
</form>
</div>
</div>
</div>
</main>
);
}
30 changes: 19 additions & 11 deletions client/app/components/auth/ProtectedRoute.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,31 @@
import { useEffect } from 'react';
import { Navigate } from 'react-router';
import { useAppDispatch, useAppSelector } from '../../store/hooks';
import { fetchProfile } from '../../store/authSlice';
import { fetchProfile, hydrateAuth } from '../../store/authSlice';
import { LoadingOverlay } from '../LoadingOverlay';

interface ProtectedRouteProps {
children: React.ReactNode;
}

export function ProtectedRoute({ children }: ProtectedRouteProps) {
const dispatch = useAppDispatch();
const { isAuthenticated, isLoading, user } = useAppSelector((state) => state.auth);
const { isAuthenticated, isLoading, user, hydrated } = useAppSelector((state) => state.auth);

useEffect(() => {
if (isAuthenticated && !user) {
if (!hydrated) {
dispatch(hydrateAuth());
}
}, [dispatch, hydrated]);

useEffect(() => {
if (hydrated && isAuthenticated && !user) {
void dispatch(fetchProfile());
}
}, [dispatch, isAuthenticated, user]);
}, [dispatch, hydrated, isAuthenticated, user]);

if (isLoading || (isAuthenticated && !user)) {
return (
<div className='flex items-center justify-center min-h-screen'>
<div className='text-lg'>Loading...</div>
</div>
);
if (!hydrated) {
return <LoadingOverlay />;
}

if (!isAuthenticated) {
Expand All @@ -34,5 +37,10 @@ export function ProtectedRoute({ children }: ProtectedRouteProps) {
);
}

return <>{children}</>;
return (
<>
{children}
{(isLoading || (isAuthenticated && !user)) && <LoadingOverlay />}
</>
);
}
30 changes: 19 additions & 11 deletions client/app/components/auth/PublicOnlyRoute.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,31 @@
import { useEffect } from 'react';
import { Navigate } from 'react-router';
import { useAppDispatch, useAppSelector } from '../../store/hooks';
import { fetchProfile } from '../../store/authSlice';
import { fetchProfile, hydrateAuth } from '../../store/authSlice';
import { LoadingOverlay } from '../LoadingOverlay';

interface PublicOnlyRouteProps {
children: React.ReactNode;
}

export function PublicOnlyRoute({ children }: PublicOnlyRouteProps) {
const dispatch = useAppDispatch();
const { isAuthenticated, isLoading, user } = useAppSelector((state) => state.auth);
const { isAuthenticated, isLoading, user, hydrated } = useAppSelector((state) => state.auth);

useEffect(() => {
if (isAuthenticated && !user) {
if (!hydrated) {
dispatch(hydrateAuth());
}
}, [dispatch, hydrated]);

useEffect(() => {
if (hydrated && isAuthenticated && !user) {
void dispatch(fetchProfile());
}
}, [dispatch, isAuthenticated, user]);
}, [dispatch, hydrated, isAuthenticated, user]);

if (isLoading || (isAuthenticated && !user)) {
return (
<div className='flex items-center justify-center min-h-screen'>
<div className='text-lg'>Loading...</div>
</div>
);
if (!hydrated) {
return <LoadingOverlay />;
}

if (isAuthenticated) {
Expand All @@ -34,5 +37,10 @@ export function PublicOnlyRoute({ children }: PublicOnlyRouteProps) {
);
}

return <>{children}</>;
return (
<>
{children}
{(isLoading || (isAuthenticated && !user)) && <LoadingOverlay />}
</>
);
}
Loading