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