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:
56
.drone.yml
Normal file
56
.drone.yml
Normal file
@ -0,0 +1,56 @@
|
||||
kind: pipeline
|
||||
type: kubernetes
|
||||
name: ci
|
||||
|
||||
steps:
|
||||
- name: prepare
|
||||
image: alpine:3.19
|
||||
environment:
|
||||
GITEA_TOKEN:
|
||||
from_secret: GITEA_TOKEN
|
||||
commands:
|
||||
- apk add --no-cache git bash yq
|
||||
- git clone --depth 1 https://token:$GITEA_TOKEN@git.vigdorov.ru/vigdorov/ci-templates.git .ci
|
||||
- chmod +x .ci/scripts/*.sh
|
||||
- bash .ci/scripts/prepare.sh
|
||||
|
||||
- name: build
|
||||
image: gcr.io/kaniko-project/executor:v1.23.2-debug
|
||||
depends_on: [prepare]
|
||||
environment:
|
||||
HARBOR_USER:
|
||||
from_secret: HARBOR_USER
|
||||
HARBOR_PASSWORD:
|
||||
from_secret: HARBOR_PASSWORD
|
||||
commands:
|
||||
- /busybox/sh .ci/scripts/build.sh
|
||||
|
||||
- name: deploy
|
||||
image: alpine:3.19
|
||||
depends_on: [build]
|
||||
environment:
|
||||
KUBE_CONFIG:
|
||||
from_secret: KUBE_CONFIG
|
||||
commands:
|
||||
- apk add --no-cache bash yq kubectl helm
|
||||
- bash .ci/scripts/deploy.sh
|
||||
|
||||
- name: notify
|
||||
image: appleboy/drone-telegram
|
||||
depends_on: [deploy]
|
||||
settings:
|
||||
token:
|
||||
from_secret: TELEGRAM_TOKEN
|
||||
to:
|
||||
from_secret: TELEGRAM_CHAT_ID
|
||||
format: markdown
|
||||
message: >
|
||||
{{#success build.status}}✅{{else}}❌{{/success}} **{{repo.name}}**
|
||||
Branch: `{{commit.branch}}`
|
||||
{{commit.message}}
|
||||
when:
|
||||
status: [success, failure]
|
||||
|
||||
trigger:
|
||||
branch: [master, main]
|
||||
event: [push, custom]
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.vite
|
||||
*.local
|
||||
.claude
|
||||
.serena
|
||||
23
eslint.config.js
Normal file
23
eslint.config.js
Normal file
@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>calc-tmp</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4292
package-lock.json
generated
Normal file
4292
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
package.json
Normal file
33
package.json
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "test-calculator",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.0",
|
||||
"antd": "^6.3.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
7
service.yaml
Normal file
7
service.yaml
Normal file
@ -0,0 +1,7 @@
|
||||
service:
|
||||
name: test-calculator
|
||||
type: web-frontend
|
||||
|
||||
deploy:
|
||||
namespace: test-calculator
|
||||
domain: test-calculator.vigdorov.ru
|
||||
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;
|
||||
}
|
||||
28
tsconfig.app.json
Normal file
28
tsconfig.app.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
tsconfig.node.json
Normal file
26
tsconfig.node.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
16
vite.config.ts
Normal file
16
vite.config.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vendor: ['react', 'react-dom', 'react-router-dom'],
|
||||
antd: ['antd', '@ant-design/icons'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user