feat: implement calculator app with pseudo-auth and CI/CD
Some checks failed
continuous-integration/drone/push Build is failing
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:
23
src/App.css
Normal file
23
src/App.css
Normal 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
48
src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/components/Button/Button.module.css
Normal file
54
src/components/Button/Button.module.css
Normal 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);
|
||||
}
|
||||
28
src/components/Button/Button.tsx
Normal file
28
src/components/Button/Button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
src/components/Calculator/Calculator.module.css
Normal file
8
src/components/Calculator/Calculator.module.css
Normal 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);
|
||||
}
|
||||
17
src/components/Calculator/Calculator.tsx
Normal file
17
src/components/Calculator/Calculator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/components/Display/Display.module.css
Normal file
36
src/components/Display/Display.module.css
Normal 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);
|
||||
}
|
||||
21
src/components/Display/Display.tsx
Normal file
21
src/components/Display/Display.tsx
Normal 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} </div>
|
||||
<div className={`${styles.result} ${fontSize ? styles[fontSize] : ''}`}>
|
||||
{formatted}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
src/components/Keypad/Keypad.module.css
Normal file
6
src/components/Keypad/Keypad.module.css
Normal file
@ -0,0 +1,6 @@
|
||||
.keypad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
56
src/components/Keypad/Keypad.tsx
Normal file
56
src/components/Keypad/Keypad.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
src/context/AuthContext.tsx
Normal file
39
src/context/AuthContext.tsx
Normal 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
192
src/hooks/useCalculator.ts
Normal 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
60
src/hooks/useKeyboard.ts
Normal 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
9
src/main.tsx
Normal 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>,
|
||||
);
|
||||
31
src/pages/CalculatorPage.module.css
Normal file
31
src/pages/CalculatorPage.module.css
Normal 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;
|
||||
}
|
||||
31
src/pages/CalculatorPage.tsx
Normal file
31
src/pages/CalculatorPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
src/pages/LoginPage.module.css
Normal file
27
src/pages/LoginPage.module.css
Normal 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
48
src/pages/LoginPage.tsx
Normal 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
44
src/utils/calculate.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user