feat: implement calculator app with pseudo-auth and CI/CD
Some checks failed
continuous-integration/drone/push Build is failing

- Calculator with full keyboard support and click interaction
- Pseudo-auth via localStorage (any login accepted)
- Dark theme (AntD ConfigProvider + custom styles)
- Responsive layout from 320px
- CI/CD: service.yaml + .drone.yml for deployment to test-calculator.vigdorov.ru

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alina
2026-02-17 21:03:52 +03:00
parent 56fee5cbdf
commit 37c201975c
31 changed files with 5286 additions and 0 deletions

23
src/App.css Normal file
View File

@ -0,0 +1,23 @@
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #000;
color: #fff;
min-height: 100vh;
overflow-x: hidden;
}
#root {
min-height: 100vh;
}

48
src/App.tsx Normal file
View File

@ -0,0 +1,48 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { ConfigProvider, theme } from 'antd';
import { AuthProvider, useAuth } from './context/AuthContext';
import LoginPage from './pages/LoginPage';
import CalculatorPage from './pages/CalculatorPage';
import type { ReactNode } from 'react';
import './App.css';
function ProtectedRoute({ children }: { children: ReactNode }) {
const { user } = useAuth();
if (!user) return <Navigate to="/login" replace />;
return <>{children}</>;
}
function GuestRoute({ children }: { children: ReactNode }) {
const { user } = useAuth();
if (user) return <Navigate to="/" replace />;
return <>{children}</>;
}
export default function App() {
return (
<ConfigProvider theme={{ algorithm: theme.darkAlgorithm }}>
<BrowserRouter>
<AuthProvider>
<Routes>
<Route
path="/login"
element={
<GuestRoute>
<LoginPage />
</GuestRoute>
}
/>
<Route
path="/"
element={
<ProtectedRoute>
<CalculatorPage />
</ProtectedRoute>
}
/>
</Routes>
</AuthProvider>
</BrowserRouter>
</ConfigProvider>
);
}

View File

@ -0,0 +1,54 @@
.button {
display: flex;
align-items: center;
justify-content: center;
min-height: 56px;
border: none;
border-radius: 12px;
font-size: clamp(18px, 4vw, 22px);
font-weight: 500;
cursor: pointer;
transition: background-color 0.12s, transform 0.1s;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.button:active {
transform: scale(0.95);
}
.default {
background: #2d2d2d;
color: #fff;
}
.default:hover {
background: #3a3a3a;
}
.function {
background: #a5a5a5;
color: #000;
}
.function:hover {
background: #b8b8b8;
}
.operator {
background: #ff9f0a;
color: #fff;
}
.operator:hover {
background: #ffb340;
}
.wide {
grid-column: span 2;
}
.active {
filter: brightness(1.35);
transform: scale(0.95);
}

View File

@ -0,0 +1,28 @@
import styles from './Button.module.css';
export type ButtonVariant = 'default' | 'function' | 'operator';
interface Props {
label: string;
variant?: ButtonVariant;
wide?: boolean;
active?: boolean;
onClick: () => void;
}
export default function Button({ label, variant = 'default', wide, active, onClick }: Props) {
const classNames = [
styles.button,
styles[variant],
wide ? styles.wide : '',
active ? styles.active : '',
]
.filter(Boolean)
.join(' ');
return (
<button className={classNames} onClick={onClick}>
{label}
</button>
);
}

View File

@ -0,0 +1,8 @@
.calculator {
width: calc(100vw - 32px);
max-width: 360px;
background: #1c1c1e;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}

View File

@ -0,0 +1,17 @@
import Display from '../Display/Display';
import Keypad from '../Keypad/Keypad';
import { useCalculator } from '../../hooks/useCalculator';
import { useKeyboard } from '../../hooks/useKeyboard';
import styles from './Calculator.module.css';
export default function Calculator() {
const { state, actions } = useCalculator();
const activeKey = useKeyboard(actions);
return (
<div className={styles.calculator}>
<Display expression={state.expression} display={state.display} />
<Keypad actions={actions} activeKey={activeKey} />
</div>
);
}

View File

@ -0,0 +1,36 @@
.display {
padding: 16px 20px 8px;
text-align: right;
min-height: 90px;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.expression {
font-size: clamp(14px, 3.5vw, 16px);
color: #888;
min-height: 22px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.result {
font-size: clamp(32px, 9vw, 44px);
font-weight: 300;
color: #fff;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: font-size 0.15s;
}
.medium {
font-size: clamp(24px, 7vw, 34px);
}
.small {
font-size: clamp(18px, 5vw, 26px);
}

View File

@ -0,0 +1,21 @@
import { formatDisplay } from '../../utils/calculate';
import styles from './Display.module.css';
interface Props {
expression: string;
display: string;
}
export default function Display({ expression, display }: Props) {
const formatted = formatDisplay(display);
const fontSize = formatted.length > 12 ? 'small' : formatted.length > 8 ? 'medium' : '';
return (
<div className={styles.display}>
<div className={styles.expression}>{expression}&nbsp;</div>
<div className={`${styles.result} ${fontSize ? styles[fontSize] : ''}`}>
{formatted}
</div>
</div>
);
}

View File

@ -0,0 +1,6 @@
.keypad {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
padding: 10px;
}

View File

@ -0,0 +1,56 @@
import Button from '../Button/Button';
import type { CalculatorActions } from '../../hooks/useCalculator';
import styles from './Keypad.module.css';
interface Props {
actions: CalculatorActions;
activeKey: string | null;
}
const BUTTON_KEY_MAP: Record<string, string> = {
'÷': '/',
'×': '*',
'': '-',
'+': '+',
'=': '=',
'.': '.',
C: 'C',
'⌫': 'Backspace',
'%': '%',
'±': '±',
};
export default function Keypad({ actions, activeKey }: Props) {
const isActive = (label: string) => {
const mapped = BUTTON_KEY_MAP[label] ?? label;
return activeKey === mapped;
};
return (
<div className={styles.keypad}>
<Button label="C" variant="function" active={isActive('C')} onClick={actions.clear} />
<Button label="±" variant="function" active={isActive('±')} onClick={actions.toggleSign} />
<Button label="%" variant="function" active={isActive('%')} onClick={actions.percent} />
<Button label="÷" variant="operator" active={isActive('÷')} onClick={() => actions.inputOperator('/')} />
<Button label="7" active={isActive('7')} onClick={() => actions.inputDigit('7')} />
<Button label="8" active={isActive('8')} onClick={() => actions.inputDigit('8')} />
<Button label="9" active={isActive('9')} onClick={() => actions.inputDigit('9')} />
<Button label="×" variant="operator" active={isActive('×')} onClick={() => actions.inputOperator('*')} />
<Button label="4" active={isActive('4')} onClick={() => actions.inputDigit('4')} />
<Button label="5" active={isActive('5')} onClick={() => actions.inputDigit('5')} />
<Button label="6" active={isActive('6')} onClick={() => actions.inputDigit('6')} />
<Button label="" variant="operator" active={isActive('')} onClick={() => actions.inputOperator('-')} />
<Button label="1" active={isActive('1')} onClick={() => actions.inputDigit('1')} />
<Button label="2" active={isActive('2')} onClick={() => actions.inputDigit('2')} />
<Button label="3" active={isActive('3')} onClick={() => actions.inputDigit('3')} />
<Button label="+" variant="operator" active={isActive('+')} onClick={() => actions.inputOperator('+')} />
<Button label="0" wide active={isActive('0')} onClick={() => actions.inputDigit('0')} />
<Button label="." active={isActive('.')} onClick={actions.inputDecimal} />
<Button label="=" variant="operator" active={isActive('=')} onClick={actions.calculate} />
</div>
);
}

View File

@ -0,0 +1,39 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
interface AuthContextType {
user: string | null;
login: (name: string) => void;
logout: () => void;
}
const STORAGE_KEY = 'calculator_user';
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<string | null>(
() => localStorage.getItem(STORAGE_KEY),
);
const login = useCallback((name: string) => {
localStorage.setItem(STORAGE_KEY, name);
setUser(name);
}, []);
const logout = useCallback(() => {
localStorage.removeItem(STORAGE_KEY);
setUser(null);
}, []);
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}

192
src/hooks/useCalculator.ts Normal file
View File

@ -0,0 +1,192 @@
import { useReducer, useCallback } from 'react';
import { calculate } from '../utils/calculate';
interface State {
display: string;
expression: string;
operator: string | null;
prevValue: number | null;
waitingForOperand: boolean;
lastResult: boolean;
}
type Action =
| { type: 'DIGIT'; digit: string }
| { type: 'DECIMAL' }
| { type: 'OPERATOR'; operator: string }
| { type: 'CALCULATE' }
| { type: 'CLEAR' }
| { type: 'BACKSPACE' }
| { type: 'TOGGLE_SIGN' }
| { type: 'PERCENT' };
const initialState: State = {
display: '0',
expression: '',
operator: null,
prevValue: null,
waitingForOperand: false,
lastResult: false,
};
const OPERATOR_SYMBOLS: Record<string, string> = {
'+': '+',
'-': '',
'*': '×',
'/': '÷',
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'DIGIT': {
if (state.waitingForOperand) {
return {
...state,
display: action.digit,
waitingForOperand: false,
lastResult: false,
};
}
if (state.lastResult) {
return {
...state,
display: action.digit,
expression: '',
operator: null,
prevValue: null,
lastResult: false,
};
}
const newDisplay = state.display === '0' ? action.digit : state.display + action.digit;
if (newDisplay.replace(/[^0-9]/g, '').length > 16) return state;
return { ...state, display: newDisplay };
}
case 'DECIMAL': {
if (state.waitingForOperand) {
return { ...state, display: '0.', waitingForOperand: false, lastResult: false };
}
if (state.lastResult) {
return {
...state,
display: '0.',
expression: '',
operator: null,
prevValue: null,
lastResult: false,
};
}
if (state.display.includes('.')) return state;
return { ...state, display: state.display + '.' };
}
case 'OPERATOR': {
const currentValue = parseFloat(state.display);
const symbol = OPERATOR_SYMBOLS[action.operator] || action.operator;
if (state.prevValue !== null && !state.waitingForOperand) {
const result = calculate(state.prevValue, state.operator!, currentValue);
const resultStr = String(result);
if (!isFinite(result)) {
return { ...initialState, display: 'Error', expression: '' };
}
return {
...state,
display: resultStr,
expression: `${resultStr} ${symbol}`,
prevValue: result,
operator: action.operator,
waitingForOperand: true,
lastResult: false,
};
}
return {
...state,
expression: `${state.display} ${symbol}`,
prevValue: currentValue,
operator: action.operator,
waitingForOperand: true,
lastResult: false,
};
}
case 'CALCULATE': {
if (state.operator === null || state.prevValue === null) return state;
if (state.waitingForOperand) return state;
const currentValue = parseFloat(state.display);
const result = calculate(state.prevValue, state.operator, currentValue);
const symbol = OPERATOR_SYMBOLS[state.operator] || state.operator;
if (!isFinite(result)) {
return { ...initialState, display: 'Error', expression: '' };
}
return {
display: String(result),
expression: `${state.prevValue} ${symbol} ${currentValue} =`,
operator: null,
prevValue: null,
waitingForOperand: false,
lastResult: true,
};
}
case 'CLEAR':
return initialState;
case 'BACKSPACE': {
if (state.lastResult || state.waitingForOperand) return state;
if (state.display === 'Error') return initialState;
const newDisplay = state.display.length > 1 ? state.display.slice(0, -1) : '0';
return { ...state, display: newDisplay };
}
case 'TOGGLE_SIGN': {
if (state.display === '0' || state.display === 'Error') return state;
const toggled = state.display.startsWith('-')
? state.display.slice(1)
: '-' + state.display;
return { ...state, display: toggled };
}
case 'PERCENT': {
const val = parseFloat(state.display);
if (isNaN(val)) return state;
const result = val / 100;
return { ...state, display: String(result), lastResult: true };
}
default:
return state;
}
}
export interface CalculatorActions {
inputDigit: (digit: string) => void;
inputDecimal: () => void;
inputOperator: (op: string) => void;
calculate: () => void;
clear: () => void;
backspace: () => void;
toggleSign: () => void;
percent: () => void;
}
export function useCalculator() {
const [state, dispatch] = useReducer(reducer, initialState);
const actions: CalculatorActions = {
inputDigit: useCallback((digit: string) => dispatch({ type: 'DIGIT', digit }), []),
inputDecimal: useCallback(() => dispatch({ type: 'DECIMAL' }), []),
inputOperator: useCallback((operator: string) => dispatch({ type: 'OPERATOR', operator }), []),
calculate: useCallback(() => dispatch({ type: 'CALCULATE' }), []),
clear: useCallback(() => dispatch({ type: 'CLEAR' }), []),
backspace: useCallback(() => dispatch({ type: 'BACKSPACE' }), []),
toggleSign: useCallback(() => dispatch({ type: 'TOGGLE_SIGN' }), []),
percent: useCallback(() => dispatch({ type: 'PERCENT' }), []),
};
return { state, actions };
}

60
src/hooks/useKeyboard.ts Normal file
View File

@ -0,0 +1,60 @@
import { useEffect, useState, useCallback } from 'react';
import type { CalculatorActions } from './useCalculator';
const KEY_MAP: Record<string, string> = {
'0': '0', '1': '1', '2': '2', '3': '3', '4': '4',
'5': '5', '6': '6', '7': '7', '8': '8', '9': '9',
'.': '.', ',': '.',
'+': '+', '-': '-', '*': '*', '/': '/',
Enter: '=', '=': '=',
Escape: 'C',
Backspace: 'Backspace',
'%': '%',
};
export function useKeyboard(actions: CalculatorActions) {
const [activeKey, setActiveKey] = useState<string | null>(null);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
const mapped = KEY_MAP[e.key];
if (!mapped) return;
if (e.key === '/') e.preventDefault();
setActiveKey(mapped);
if (mapped >= '0' && mapped <= '9') {
actions.inputDigit(mapped);
} else if (mapped === '.') {
actions.inputDecimal();
} else if (['+', '-', '*', '/'].includes(mapped)) {
actions.inputOperator(mapped);
} else if (mapped === '=') {
actions.calculate();
} else if (mapped === 'C') {
actions.clear();
} else if (mapped === 'Backspace') {
actions.backspace();
} else if (mapped === '%') {
actions.percent();
}
},
[actions],
);
const handleKeyUp = useCallback(() => {
setActiveKey(null);
}, []);
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, [handleKeyDown, handleKeyUp]);
return activeKey;
}

9
src/main.tsx Normal file
View File

@ -0,0 +1,9 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@ -0,0 +1,31 @@
.wrapper {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
flex-shrink: 0;
}
.user {
color: #888;
font-size: 14px;
}
.logout {
color: #888 !important;
font-size: 13px;
}
.main {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}

View File

@ -0,0 +1,31 @@
import { Button, Typography } from 'antd';
import { LogoutOutlined, UserOutlined } from '@ant-design/icons';
import { useAuth } from '../context/AuthContext';
import Calculator from '../components/Calculator/Calculator';
import styles from './CalculatorPage.module.css';
export default function CalculatorPage() {
const { user, logout } = useAuth();
return (
<div className={styles.wrapper}>
<header className={styles.header}>
<Typography.Text className={styles.user}>
<UserOutlined /> {user}
</Typography.Text>
<Button
type="text"
size="small"
icon={<LogoutOutlined />}
onClick={logout}
className={styles.logout}
>
Выйти
</Button>
</header>
<main className={styles.main}>
<Calculator />
</main>
</div>
);
}

View File

@ -0,0 +1,27 @@
.wrapper {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.card {
width: 100%;
max-width: 340px;
text-align: center;
}
.icon {
font-size: 48px;
color: #1677ff;
margin-bottom: 12px;
}
.title {
margin-bottom: 24px !important;
}
.button {
margin-top: 12px;
}

48
src/pages/LoginPage.tsx Normal file
View File

@ -0,0 +1,48 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Input, Button, Typography } from 'antd';
import { CalculatorOutlined } from '@ant-design/icons';
import { useAuth } from '../context/AuthContext';
import styles from './LoginPage.module.css';
export default function LoginPage() {
const [name, setName] = useState('');
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = () => {
const trimmed = name.trim();
if (!trimmed) return;
login(trimmed);
navigate('/', { replace: true });
};
return (
<div className={styles.wrapper}>
<div className={styles.card}>
<CalculatorOutlined className={styles.icon} />
<Typography.Title level={3} className={styles.title}>
Калькулятор
</Typography.Title>
<Input
size="large"
placeholder="Введите логин"
value={name}
onChange={(e) => setName(e.target.value)}
onPressEnter={handleSubmit}
autoFocus
/>
<Button
type="primary"
size="large"
block
onClick={handleSubmit}
disabled={!name.trim()}
className={styles.button}
>
Войти
</Button>
</div>
</div>
);
}

44
src/utils/calculate.ts Normal file
View File

@ -0,0 +1,44 @@
export function calculate(a: number, operator: string, b: number): number {
let result: number;
switch (operator) {
case '+':
result = a + b;
break;
case '-':
result = a - b;
break;
case '*':
result = a * b;
break;
case '/':
if (b === 0) return Infinity;
result = a / b;
break;
default:
return b;
}
return Math.round(result * 1e12) / 1e12;
}
export function formatDisplay(value: string): string {
if (value === 'Error') return value;
const num = parseFloat(value);
if (isNaN(num)) return '0';
if (!isFinite(num)) return 'Error';
if (value.includes('.') && value.endsWith('0') && !value.includes('e')) {
return value;
}
if (value.endsWith('.')) {
return value;
}
if (Math.abs(num) >= 1e16 || (Math.abs(num) < 1e-10 && num !== 0)) {
return num.toExponential(6);
}
return value;
}