initial commit (frontend)
@@ -0,0 +1,27 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.env
|
||||
.env.*
|
||||
deploy.zip
|
||||
@@ -0,0 +1,75 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
```
|
||||
@@ -0,0 +1,22 @@
|
||||
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: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="version" content="3.7.2" />
|
||||
<title>Filwords PL - Nauka polskiego przez grę</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
background: #F5F5F7;
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll !important;
|
||||
overscroll-behavior: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-y;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"eslint": "^10.6.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^17.7.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.62.0",
|
||||
"vite": "^8.1.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" width="1600" height="900">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#0B3D24"/><stop offset="1" stop-color="#9BEFC0"/>
|
||||
</linearGradient>
|
||||
<filter id="blur" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="90"/></filter>
|
||||
<linearGradient id="mint" x1="0" y1="0" x2="0.6" y2="1">
|
||||
<stop offset="0" stop-color="#6FE9AA"/><stop offset="1" stop-color="#22B964"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="mintD" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#3FD68A"/><stop offset="1" stop-color="#1C8F52"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="amber" x1="0" y1="0" x2="0.6" y2="1">
|
||||
<stop offset="0" stop-color="#F8DE95"/><stop offset="1" stop-color="#DFAC2B"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="teal" x1="0" y1="0" x2="0.6" y2="1">
|
||||
<stop offset="0" stop-color="#7FE0D6"/><stop offset="1" stop-color="#1FA79A"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="device" x1="0" y1="0" x2="0.5" y2="1">
|
||||
<stop offset="0" stop-color="#F3F5F9"/><stop offset="1" stop-color="#D9E0EA"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="glass" x1="0" y1="0" x2="0.4" y2="1">
|
||||
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.92"/>
|
||||
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0.62"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="gloss" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.55"/>
|
||||
<stop offset="0.55" stop-color="#FFFFFF" stop-opacity="0.04"/>
|
||||
</linearGradient>
|
||||
<filter id="sh" x="-45%" y="-45%" width="190%" height="190%">
|
||||
<feDropShadow dx="0" dy="18" stdDeviation="22" flood-color="#04220F" flood-opacity="0.30"/>
|
||||
</filter>
|
||||
<filter id="shS" x="-55%" y="-55%" width="210%" height="210%">
|
||||
<feDropShadow dx="0" dy="10" stdDeviation="13" flood-color="#04220F" flood-opacity="0.26"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect width="1600" height="900" fill="url(#bg)"/>
|
||||
<g filter="url(#blur)" opacity="0.4">
|
||||
<circle cx="1330" cy="190" r="250" fill="#6FE9AA"/>
|
||||
<circle cx="230" cy="730" r="250" fill="#F3CE72"/>
|
||||
<circle cx="800" cy="420" r="220" fill="#7FE0D6"/>
|
||||
<circle cx="180" cy="180" r="170" fill="#3FD68A"/>
|
||||
</g>
|
||||
|
||||
<ellipse cx="800" cy="748" rx="220" ry="32" fill="#031B0D" opacity="0.22"/>
|
||||
|
||||
<!-- device -->
|
||||
<g filter="url(#sh)">
|
||||
<rect x="660" y="190" width="280" height="530" rx="64" fill="url(#device)"/>
|
||||
<rect x="660" y="190" width="280" height="530" rx="64" fill="none" stroke="#FFFFFF" stroke-width="3" opacity="0.85"/>
|
||||
<rect x="682" y="212" width="236" height="486" rx="46" fill="#FFFFFF"/>
|
||||
<rect x="764" y="226" width="72" height="18" rx="9" fill="#E3E8EF"/>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="708" y="268" width="76" height="76" rx="24" fill="#DFF7EA"/>
|
||||
<rect x="816" y="268" width="76" height="76" rx="24" fill="#FDF2D6"/>
|
||||
<rect x="708" y="360" width="76" height="76" rx="24" fill="#DCF3EE"/>
|
||||
<rect x="816" y="360" width="76" height="76" rx="24" fill="#DFF7EA"/>
|
||||
<rect x="708" y="452" width="76" height="76" rx="24" fill="#FDF2D6"/>
|
||||
<rect x="816" y="452" width="76" height="76" rx="24" fill="#DCF3EE"/>
|
||||
<rect x="708" y="566" width="184" height="14" rx="7" fill="#E4EAF2"/>
|
||||
<rect x="708" y="598" width="124" height="12" rx="6" fill="#EDF1F6"/>
|
||||
<rect x="740" y="638" width="120" height="30" rx="15" fill="#DFF7EA"/>
|
||||
</g>
|
||||
|
||||
<!-- briefcase / diplomat tile -->
|
||||
<g transform="translate(318,234)" filter="url(#sh)">
|
||||
<rect x="-72" y="-72" width="144" height="144" rx="44" fill="url(#mintD)"/>
|
||||
<rect x="-72" y="-72" width="144" height="86" rx="44" fill="url(#gloss)"/>
|
||||
<rect x="-38" y="-14" width="76" height="54" rx="12" fill="#FFFFFF"/>
|
||||
<path d="M -18 -14 L -18 -26 Q -18 -36 -8 -36 L 8 -36 Q 18 -36 18 -26 L 18 -14" stroke="#FFFFFF" stroke-width="10" fill="none" stroke-linecap="round"/>
|
||||
<rect x="-10" y="8" width="20" height="12" rx="4" fill="#22B964"/>
|
||||
</g>
|
||||
|
||||
<!-- handshake tile -->
|
||||
<g transform="translate(272,598)" filter="url(#sh)">
|
||||
<rect x="-72" y="-72" width="144" height="144" rx="44" fill="url(#teal)"/>
|
||||
<rect x="-72" y="-72" width="144" height="86" rx="44" fill="url(#gloss)"/>
|
||||
<path d="M -40 4 Q -20 -20 -2 -2 Q 16 -22 40 -2" stroke="#FFFFFF" stroke-width="13" stroke-linecap="round" fill="none"/>
|
||||
<rect x="-20" y="4" width="40" height="20" rx="10" fill="#FFFFFF"/>
|
||||
</g>
|
||||
|
||||
<!-- contract / agreement tile -->
|
||||
<g transform="translate(1286,244)" filter="url(#sh)">
|
||||
<rect x="-72" y="-72" width="144" height="144" rx="44" fill="url(#amber)"/>
|
||||
<rect x="-72" y="-72" width="144" height="86" rx="44" fill="url(#gloss)"/>
|
||||
<path d="M -34 -46 L 12 -46 L 36 -22 L 36 46 Q 36 53 29 53 L -27 53 Q -34 53 -34 46 Z" fill="#FFFFFF"/>
|
||||
<path d="M 12 -46 L 36 -22 L 19 -22 Q 12 -22 12 -29 Z" fill="#F3D889"/>
|
||||
<path d="M -14 8 L -1 22 L 23 -10" stroke="#DFAC2B" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</g>
|
||||
|
||||
<!-- growth / partnership chart tile -->
|
||||
<g transform="translate(1330,612)" filter="url(#sh)">
|
||||
<rect x="-72" y="-72" width="144" height="144" rx="44" fill="url(#mint)"/>
|
||||
<rect x="-72" y="-72" width="144" height="86" rx="44" fill="url(#gloss)"/>
|
||||
<path d="M -38 34 L -14 6 L 8 22 L 38 -26" stroke="#FFFFFF" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
<path d="M 10 -26 L 38 -26 L 38 2" stroke="#FFFFFF" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</g>
|
||||
|
||||
<!-- small accent tiles -->
|
||||
<g transform="translate(500,122)" filter="url(#shS)">
|
||||
<rect x="-48" y="-48" width="96" height="96" rx="30" fill="url(#amber)"/>
|
||||
<rect x="-48" y="-48" width="96" height="58" rx="30" fill="url(#gloss)"/>
|
||||
<circle cx="0" cy="0" r="24" fill="none" stroke="#FFF6DE" stroke-width="7"/>
|
||||
</g>
|
||||
<g transform="translate(1112,764)" filter="url(#shS)">
|
||||
<rect x="-48" y="-48" width="96" height="96" rx="30" fill="url(#glass)"/>
|
||||
<rect x="-48" y="-48" width="96" height="96" rx="30" fill="none" stroke="#FFFFFF" stroke-width="2" opacity="0.9"/>
|
||||
<path d="M -20 4 L -7 18 L 20 -13" stroke="#22B964" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</g>
|
||||
<g transform="translate(508,778)" filter="url(#shS)">
|
||||
<rect x="-48" y="-48" width="96" height="96" rx="30" fill="url(#glass)"/>
|
||||
<rect x="-48" y="-48" width="96" height="96" rx="30" fill="none" stroke="#FFFFFF" stroke-width="2" opacity="0.9"/>
|
||||
<path d="M -38 4 Q -20 -16 -2 0 Q 14 -18 36 -2" stroke="#1FA79A" stroke-width="11" stroke-linecap="round" fill="none"/>
|
||||
</g>
|
||||
<g transform="translate(1104,126)" filter="url(#shS)">
|
||||
<rect x="-48" y="-48" width="96" height="96" rx="30" fill="url(#teal)"/>
|
||||
<rect x="-48" y="-48" width="96" height="58" rx="30" fill="url(#gloss)"/>
|
||||
<circle cx="-14" cy="0" r="9" fill="#FFFFFF"/>
|
||||
<circle cx="14" cy="0" r="9" fill="#FFFFFF"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,120 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" width="1600" height="900">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#2C1259"/><stop offset="1" stop-color="#C9B3FC"/>
|
||||
</linearGradient>
|
||||
<filter id="blur" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="90"/></filter>
|
||||
<linearGradient id="purple" x1="0" y1="0" x2="0.6" y2="1">
|
||||
<stop offset="0" stop-color="#B49CFC"/><stop offset="1" stop-color="#7C4DF4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="sky" x1="0" y1="0" x2="0.6" y2="1">
|
||||
<stop offset="0" stop-color="#8ACDF5"/><stop offset="1" stop-color="#3E9BE0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="device" x1="0" y1="0" x2="0.5" y2="1">
|
||||
<stop offset="0" stop-color="#F3F5F9"/><stop offset="1" stop-color="#D9E0EA"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="glass" x1="0" y1="0" x2="0.4" y2="1">
|
||||
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.92"/>
|
||||
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0.62"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="gloss" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.55"/>
|
||||
<stop offset="0.55" stop-color="#FFFFFF" stop-opacity="0.04"/>
|
||||
</linearGradient>
|
||||
<filter id="sh" x="-45%" y="-45%" width="190%" height="190%">
|
||||
<feDropShadow dx="0" dy="18" stdDeviation="22" flood-color="#1E1240" flood-opacity="0.24"/>
|
||||
</filter>
|
||||
<filter id="shS" x="-55%" y="-55%" width="210%" height="210%">
|
||||
<feDropShadow dx="0" dy="10" stdDeviation="13" flood-color="#1E1240" flood-opacity="0.20"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect width="1600" height="900" fill="url(#bg)"/>
|
||||
<g filter="url(#blur)" opacity="0.5">
|
||||
<circle cx="250" cy="180" r="250" fill="#C7B4FB"/>
|
||||
<circle cx="1360" cy="740" r="250" fill="#8ACDF5"/>
|
||||
<circle cx="800" cy="430" r="230" fill="#B9D5F7"/>
|
||||
<circle cx="1400" cy="170" r="160" fill="#8CEFBD"/>
|
||||
</g>
|
||||
|
||||
<ellipse cx="800" cy="744" rx="230" ry="34" fill="#150A2E" opacity="0.2"/>
|
||||
|
||||
<!-- device -->
|
||||
<g filter="url(#sh)">
|
||||
<rect x="654" y="180" width="292" height="546" rx="66" fill="url(#device)"/>
|
||||
<rect x="654" y="180" width="292" height="546" rx="66" fill="none" stroke="#FFFFFF" stroke-width="3" opacity="0.85"/>
|
||||
<rect x="676" y="202" width="248" height="502" rx="48" fill="#FFFFFF"/>
|
||||
<rect x="762" y="216" width="76" height="20" rx="10" fill="#E3E8EF"/>
|
||||
</g>
|
||||
|
||||
<!-- contact card (glass) -->
|
||||
<g>
|
||||
<rect x="700" y="262" width="200" height="164" rx="40" fill="#F2EDFE"/>
|
||||
<rect x="700" y="262" width="200" height="82" rx="40" fill="url(#gloss)"/>
|
||||
<circle cx="800" cy="320" r="32" fill="url(#purple)"/>
|
||||
<path d="M 776 356 Q 776 338 800 338 Q 824 338 824 356 Z" fill="url(#purple)"/>
|
||||
<rect x="746" y="372" width="108" height="14" rx="7" fill="#CDBCFB"/>
|
||||
<rect x="772" y="396" width="56" height="12" rx="6" fill="#E0D6FE"/>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="700" y="446" width="200" height="72" rx="28" fill="#F5F7FA"/>
|
||||
<circle cx="738" cy="482" r="20" fill="#DCEEFB"/>
|
||||
<rect x="770" y="470" width="106" height="13" rx="6.5" fill="#E4EAF2"/>
|
||||
<rect x="770" y="492" width="70" height="11" rx="5.5" fill="#EDF1F6"/>
|
||||
<rect x="700" y="534" width="200" height="72" rx="28" fill="#F5F7FA"/>
|
||||
<circle cx="738" cy="570" r="20" fill="#E9E2FE"/>
|
||||
<rect x="770" y="558" width="106" height="13" rx="6.5" fill="#E4EAF2"/>
|
||||
<rect x="770" y="580" width="82" height="11" rx="5.5" fill="#EDF1F6"/>
|
||||
<rect x="740" y="630" width="120" height="30" rx="15" fill="#E3F0FC"/>
|
||||
</g>
|
||||
|
||||
<!-- key tile -->
|
||||
<g transform="translate(330,266)" filter="url(#sh)">
|
||||
<rect x="-78" y="-78" width="156" height="156" rx="48" fill="url(#glass)"/>
|
||||
<rect x="-78" y="-78" width="156" height="156" rx="48" fill="none" stroke="#FFFFFF" stroke-width="2" opacity="0.9"/>
|
||||
<rect x="-78" y="-78" width="156" height="92" rx="48" fill="url(#gloss)"/>
|
||||
<g stroke="url(#sky)" stroke-width="13" stroke-linecap="round" fill="none">
|
||||
<circle cx="0" cy="-22" r="23"/>
|
||||
<path d="M 0 2 L 0 50"/><path d="M 0 22 L 21 22"/><path d="M 0 42 L 15 42"/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- document tile -->
|
||||
<g transform="translate(1270,244)" filter="url(#sh)">
|
||||
<rect x="-78" y="-78" width="156" height="156" rx="48" fill="url(#glass)"/>
|
||||
<rect x="-78" y="-78" width="156" height="156" rx="48" fill="none" stroke="#FFFFFF" stroke-width="2" opacity="0.9"/>
|
||||
<rect x="-78" y="-78" width="156" height="92" rx="48" fill="url(#gloss)"/>
|
||||
<path d="M -36 -48 L 14 -48 L 38 -24 L 38 48 Q 38 55 31 55 L -29 55 Q -36 55 -36 48 Z" fill="#EFE9FE"/>
|
||||
<path d="M 14 -48 L 38 -24 L 21 -24 Q 14 -24 14 -31 Z" fill="#CDBCFB"/>
|
||||
<path d="M -16 10 L -3 24 L 21 -8" stroke="url(#purple)" stroke-width="13" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</g>
|
||||
|
||||
<!-- stethoscope tile -->
|
||||
<g transform="translate(362,652)" filter="url(#sh)">
|
||||
<rect x="-78" y="-78" width="156" height="156" rx="48" fill="url(#glass)"/>
|
||||
<rect x="-78" y="-78" width="156" height="156" rx="48" fill="none" stroke="#FFFFFF" stroke-width="2" opacity="0.9"/>
|
||||
<rect x="-78" y="-78" width="156" height="92" rx="48" fill="url(#gloss)"/>
|
||||
<g stroke="url(#sky)" stroke-width="12" stroke-linecap="round" fill="none">
|
||||
<path d="M -36 -44 L -36 -10 A 23 23 0 0 0 10 -10 L 10 -44"/>
|
||||
<path d="M -13 12 Q -13 44 14 44"/>
|
||||
</g>
|
||||
<circle cx="-36" cy="-48" r="7.5" fill="#3E9BE0"/>
|
||||
<circle cx="10" cy="-48" r="7.5" fill="#3E9BE0"/>
|
||||
<circle cx="34" cy="44" r="19" fill="url(#purple)"/>
|
||||
<circle cx="34" cy="44" r="8" fill="#FFFFFF" opacity="0.7"/>
|
||||
</g>
|
||||
|
||||
<!-- scales tile -->
|
||||
<g transform="translate(1242,648)" filter="url(#sh)">
|
||||
<rect x="-78" y="-78" width="156" height="156" rx="48" fill="url(#glass)"/>
|
||||
<rect x="-78" y="-78" width="156" height="156" rx="48" fill="none" stroke="#FFFFFF" stroke-width="2" opacity="0.9"/>
|
||||
<rect x="-78" y="-78" width="156" height="92" rx="48" fill="url(#gloss)"/>
|
||||
<g stroke="url(#purple)" stroke-width="11" stroke-linecap="round" fill="none">
|
||||
<path d="M 0 -44 L 0 42"/><path d="M -26 46 L 26 46"/>
|
||||
<path d="M -42 -28 L 42 -28"/><path d="M -42 -28 L -42 -15"/><path d="M 42 -28 L 42 -15"/>
|
||||
<path d="M -65 -13 A 23 23 0 0 0 -19 -13"/>
|
||||
<path d="M 19 -13 A 23 23 0 0 0 65 -13"/>
|
||||
</g>
|
||||
<circle cx="0" cy="-50" r="9" fill="#7C4DF4"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,127 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" width="1600" height="900">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#0B2A52"/><stop offset="1" stop-color="#9FD3F7"/>
|
||||
</linearGradient>
|
||||
<filter id="blur" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="90"/></filter>
|
||||
<linearGradient id="sky" x1="0" y1="0" x2="0.6" y2="1">
|
||||
<stop offset="0" stop-color="#8ACDF5"/><stop offset="1" stop-color="#3E9BE0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="pink" x1="0" y1="0" x2="0.6" y2="1">
|
||||
<stop offset="0" stop-color="#FFA5A5"/><stop offset="1" stop-color="#F9585F"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="purple" x1="0" y1="0" x2="0.6" y2="1">
|
||||
<stop offset="0" stop-color="#B49CFC"/><stop offset="1" stop-color="#7C4DF4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="mint" x1="0" y1="0" x2="0.6" y2="1">
|
||||
<stop offset="0" stop-color="#6FE9AA"/><stop offset="1" stop-color="#22B964"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="amber" x1="0" y1="0" x2="0.6" y2="1">
|
||||
<stop offset="0" stop-color="#F8DE95"/><stop offset="1" stop-color="#DFAC2B"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="device" x1="0" y1="0" x2="0.5" y2="1">
|
||||
<stop offset="0" stop-color="#F3F5F9"/><stop offset="1" stop-color="#D9E0EA"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="glass" x1="0" y1="0" x2="0.4" y2="1">
|
||||
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.92"/>
|
||||
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0.62"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="gloss" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.5"/>
|
||||
<stop offset="0.55" stop-color="#FFFFFF" stop-opacity="0.04"/>
|
||||
</linearGradient>
|
||||
<filter id="sh" x="-45%" y="-45%" width="190%" height="190%">
|
||||
<feDropShadow dx="0" dy="18" stdDeviation="22" flood-color="#08203F" flood-opacity="0.28"/>
|
||||
</filter>
|
||||
<filter id="shS" x="-55%" y="-55%" width="210%" height="210%">
|
||||
<feDropShadow dx="0" dy="10" stdDeviation="13" flood-color="#08203F" flood-opacity="0.24"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect width="1600" height="900" fill="url(#bg)"/>
|
||||
<g filter="url(#blur)" opacity="0.45">
|
||||
<circle cx="1330" cy="190" r="250" fill="#8ACDF5"/>
|
||||
<circle cx="230" cy="730" r="250" fill="#FFB3B3"/>
|
||||
<circle cx="800" cy="420" r="220" fill="#BBD8F8"/>
|
||||
<circle cx="180" cy="180" r="170" fill="#C7B4FB"/>
|
||||
</g>
|
||||
|
||||
<ellipse cx="800" cy="748" rx="220" ry="32" fill="#051A34" opacity="0.22"/>
|
||||
|
||||
<!-- device -->
|
||||
<g filter="url(#sh)">
|
||||
<rect x="660" y="190" width="280" height="530" rx="64" fill="url(#device)"/>
|
||||
<rect x="660" y="190" width="280" height="530" rx="64" fill="none" stroke="#FFFFFF" stroke-width="3" opacity="0.85"/>
|
||||
<rect x="682" y="212" width="236" height="486" rx="46" fill="#FFFFFF"/>
|
||||
<rect x="764" y="226" width="72" height="18" rx="9" fill="#E3E8EF"/>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="708" y="268" width="76" height="76" rx="24" fill="#DCEEFB"/>
|
||||
<rect x="816" y="268" width="76" height="76" rx="24" fill="#FFE1E1"/>
|
||||
<rect x="708" y="360" width="76" height="76" rx="24" fill="#E9E2FE"/>
|
||||
<rect x="816" y="360" width="76" height="76" rx="24" fill="#DFF7EA"/>
|
||||
<rect x="708" y="452" width="76" height="76" rx="24" fill="#FDF2D6"/>
|
||||
<rect x="816" y="452" width="76" height="76" rx="24" fill="#DCEEFB"/>
|
||||
<rect x="708" y="566" width="184" height="14" rx="7" fill="#E4EAF2"/>
|
||||
<rect x="708" y="598" width="124" height="12" rx="6" fill="#EDF1F6"/>
|
||||
<rect x="740" y="638" width="120" height="30" rx="15" fill="#E3F0FC"/>
|
||||
</g>
|
||||
|
||||
<!-- flashcards tile -->
|
||||
<g transform="translate(318,234)" filter="url(#sh)">
|
||||
<rect x="-72" y="-72" width="144" height="144" rx="44" fill="url(#sky)"/>
|
||||
<rect x="-72" y="-72" width="144" height="86" rx="44" fill="url(#gloss)"/>
|
||||
<rect x="-44" y="-32" width="78" height="56" rx="16" fill="#FFFFFF" opacity="0.5" transform="rotate(-10 -5 -4)"/>
|
||||
<rect x="-32" y="-18" width="78" height="56" rx="16" fill="#FFFFFF"/>
|
||||
<rect x="-16" y="2" width="46" height="10" rx="5" fill="#A6D9F7"/>
|
||||
<rect x="-16" y="20" width="28" height="9" rx="4.5" fill="#CDE8FA"/>
|
||||
</g>
|
||||
|
||||
<!-- book tile -->
|
||||
<g transform="translate(272,598)" filter="url(#sh)">
|
||||
<rect x="-72" y="-72" width="144" height="144" rx="44" fill="url(#pink)"/>
|
||||
<rect x="-72" y="-72" width="144" height="86" rx="44" fill="url(#gloss)"/>
|
||||
<path d="M -44 -34 Q -13 -45 0 -28 L 0 36 Q -13 21 -44 32 Z" fill="#FFFFFF"/>
|
||||
<path d="M 44 -34 Q 13 -45 0 -28 L 0 36 Q 13 21 44 32 Z" fill="#FFFFFF" opacity="0.72"/>
|
||||
<path d="M 0 -28 L 0 36" stroke="#FFC9CB" stroke-width="6" stroke-linecap="round"/>
|
||||
</g>
|
||||
|
||||
<!-- chat tile -->
|
||||
<g transform="translate(1286,244)" filter="url(#sh)">
|
||||
<rect x="-72" y="-72" width="144" height="144" rx="44" fill="url(#purple)"/>
|
||||
<rect x="-72" y="-72" width="144" height="86" rx="44" fill="url(#gloss)"/>
|
||||
<path d="M -42 -28 Q -42 -41 -29 -41 L 29 -41 Q 42 -41 42 -28 L 42 11 Q 42 24 29 24 L -4 24 L -25 43 L -25 24 L -29 24 Q -42 24 -42 11 Z" fill="#FFFFFF"/>
|
||||
<g fill="#CDBCFB"><circle cx="-19" cy="-8" r="7"/><circle cx="0" cy="-8" r="7"/><circle cx="19" cy="-8" r="7"/></g>
|
||||
</g>
|
||||
|
||||
<!-- headphones tile -->
|
||||
<g transform="translate(1330,612)" filter="url(#sh)">
|
||||
<rect x="-72" y="-72" width="144" height="144" rx="44" fill="url(#mint)"/>
|
||||
<rect x="-72" y="-72" width="144" height="86" rx="44" fill="url(#gloss)"/>
|
||||
<path d="M -34 12 A 34 34 0 0 1 34 12" stroke="#FFFFFF" stroke-width="12" fill="none" stroke-linecap="round"/>
|
||||
<rect x="-45" y="8" width="24" height="40" rx="12" fill="#FFFFFF"/>
|
||||
<rect x="21" y="8" width="24" height="40" rx="12" fill="#FFFFFF"/>
|
||||
</g>
|
||||
|
||||
<!-- small glass tiles -->
|
||||
<g transform="translate(500,122)" filter="url(#shS)">
|
||||
<rect x="-48" y="-48" width="96" height="96" rx="30" fill="url(#amber)"/>
|
||||
<rect x="-48" y="-48" width="96" height="58" rx="30" fill="url(#gloss)"/>
|
||||
<path d="M -14 -17 L 22 0 L -14 17 Z" fill="#FFFFFF"/>
|
||||
</g>
|
||||
<g transform="translate(1112,764)" filter="url(#shS)">
|
||||
<rect x="-48" y="-48" width="96" height="96" rx="30" fill="url(#glass)"/>
|
||||
<rect x="-48" y="-48" width="96" height="96" rx="30" fill="none" stroke="#FFFFFF" stroke-width="2" opacity="0.9"/>
|
||||
<g fill="#3E9BE0"><circle cx="-15" cy="-15" r="7.5"/><circle cx="15" cy="-15" r="7.5"/><circle cx="-15" cy="15" r="7.5"/><circle cx="15" cy="15" r="7.5"/></g>
|
||||
</g>
|
||||
<g transform="translate(508,778)" filter="url(#shS)">
|
||||
<rect x="-48" y="-48" width="96" height="96" rx="30" fill="url(#glass)"/>
|
||||
<rect x="-48" y="-48" width="96" height="96" rx="30" fill="none" stroke="#FFFFFF" stroke-width="2" opacity="0.9"/>
|
||||
<path d="M -22 -5 L -22 5 M -9 -16 L -9 16 M 5 -22 L 5 22 M 19 -11 L 19 11" stroke="#F9585F" stroke-width="10" stroke-linecap="round"/>
|
||||
</g>
|
||||
<g transform="translate(1104,126)" filter="url(#shS)">
|
||||
<rect x="-48" y="-48" width="96" height="96" rx="30" fill="url(#glass)"/>
|
||||
<rect x="-48" y="-48" width="96" height="96" rx="30" fill="none" stroke="#FFFFFF" stroke-width="2" opacity="0.9"/>
|
||||
<path d="M -17 4 L -4 18 L 20 -13" stroke="#22B964" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,92 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" width="1600" height="900">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#5C2A05"/><stop offset="1" stop-color="#FBD79A"/>
|
||||
</linearGradient>
|
||||
<filter id="blur" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="90"/></filter>
|
||||
<linearGradient id="amber" x1="0" y1="0" x2="0.6" y2="1">
|
||||
<stop offset="0" stop-color="#F8DE95"/><stop offset="1" stop-color="#DFAC2B"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="amberD" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#EDC155"/><stop offset="1" stop-color="#CE9A1E"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="cone" x1="0" y1="0" x2="1" y2="0.3">
|
||||
<stop offset="0" stop-color="#FAE7B2"/><stop offset="1" stop-color="#E2B238"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="pink" x1="0" y1="0" x2="0.6" y2="1">
|
||||
<stop offset="0" stop-color="#FFA5A5"/><stop offset="1" stop-color="#F9585F"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="glass" x1="0" y1="0" x2="0.4" y2="1">
|
||||
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.94"/>
|
||||
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0.66"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="gloss" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.5"/>
|
||||
<stop offset="0.55" stop-color="#FFFFFF" stop-opacity="0.04"/>
|
||||
</linearGradient>
|
||||
<filter id="sh" x="-45%" y="-45%" width="190%" height="190%">
|
||||
<feDropShadow dx="0" dy="18" stdDeviation="22" flood-color="#3B1A03" flood-opacity="0.30"/>
|
||||
</filter>
|
||||
<filter id="shS" x="-55%" y="-55%" width="210%" height="210%">
|
||||
<feDropShadow dx="0" dy="10" stdDeviation="13" flood-color="#3B1A03" flood-opacity="0.26"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect width="1600" height="900" fill="url(#bg)"/>
|
||||
<g filter="url(#blur)" opacity="0.45">
|
||||
<circle cx="1300" cy="200" r="250" fill="#F7D686"/>
|
||||
<circle cx="220" cy="720" r="250" fill="#FFB3B3"/>
|
||||
<circle cx="800" cy="420" r="220" fill="#FAE3AE"/>
|
||||
<circle cx="1420" cy="760" r="170" fill="#9CCFF5"/>
|
||||
</g>
|
||||
|
||||
<ellipse cx="790" cy="744" rx="280" ry="36" fill="#2E1402" opacity="0.24"/>
|
||||
|
||||
<!-- megaphone -->
|
||||
<g transform="translate(700,440) rotate(-14)">
|
||||
<g filter="url(#sh)">
|
||||
<path d="M -6 46 L 34 128" stroke="url(#amberD)" stroke-width="32" stroke-linecap="round" fill="none"/>
|
||||
<rect x="18" y="112" width="78" height="36" rx="18" fill="url(#amberD)" transform="rotate(64 57 130)"/>
|
||||
<rect x="-106" y="-50" width="68" height="100" rx="33" fill="url(#amberD)"/>
|
||||
<path d="M -46 -48 L 150 -134 Q 178 -147 178 -118 L 178 118 Q 178 147 150 134 L -46 48 Q -58 42 -58 28 L -58 -28 Q -58 -42 -46 -48 Z" fill="url(#cone)"/>
|
||||
<path d="M -46 -48 L 150 -134 Q 178 -147 178 -118 L 178 -86 Q 100 -60 -58 -12 L -58 -28 Q -58 -42 -46 -48 Z" fill="#FFFFFF" opacity="0.32"/>
|
||||
<ellipse cx="172" cy="0" rx="27" ry="129" fill="#FFF3D4"/>
|
||||
<ellipse cx="172" cy="0" rx="14" ry="105" fill="#E2B238" opacity="0.45"/>
|
||||
</g>
|
||||
<g stroke="url(#pink)" stroke-width="16" stroke-linecap="round" fill="none">
|
||||
<path d="M 238 -56 A 74 74 0 0 1 238 56"/>
|
||||
<path d="M 294 -98 A 124 124 0 0 1 294 98"/>
|
||||
<path d="M 350 -140 A 174 174 0 0 1 350 140" opacity="0.75"/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- chat bubbles -->
|
||||
<g filter="url(#shS)">
|
||||
<path d="M 1114 178 Q 1114 148 1144 148 L 1304 148 Q 1334 148 1334 178 L 1334 252 Q 1334 282 1304 282 L 1208 282 L 1176 316 L 1176 282 L 1144 282 Q 1114 282 1114 252 Z" fill="url(#glass)"/>
|
||||
<path d="M 1114 178 Q 1114 148 1144 148 L 1304 148 Q 1334 148 1334 178 L 1334 206 L 1114 206 Z" fill="url(#gloss)"/>
|
||||
<g fill="#FFC2C4"><circle cx="1178" cy="216" r="12"/><circle cx="1224" cy="216" r="12"/><circle cx="1270" cy="216" r="12"/></g>
|
||||
</g>
|
||||
<g filter="url(#shS)">
|
||||
<path d="M 1202 392 Q 1202 364 1230 364 L 1388 364 Q 1416 364 1416 392 L 1416 460 Q 1416 488 1388 488 L 1302 488 L 1276 518 L 1276 488 L 1230 488 Q 1202 488 1202 460 Z" fill="url(#pink)"/>
|
||||
<path d="M 1202 392 Q 1202 364 1230 364 L 1388 364 Q 1416 364 1416 392 L 1416 414 L 1202 414 Z" fill="url(#gloss)"/>
|
||||
<rect x="1236" y="404" width="136" height="14" rx="7" fill="#FFFFFF" opacity="0.9"/>
|
||||
<rect x="1236" y="432" width="88" height="12" rx="6" fill="#FFFFFF" opacity="0.62"/>
|
||||
</g>
|
||||
<g filter="url(#shS)">
|
||||
<path d="M 1072 600 Q 1072 574 1098 574 L 1226 574 Q 1252 574 1252 600 L 1252 662 Q 1252 688 1226 688 L 1152 688 L 1128 716 L 1128 688 L 1098 688 Q 1072 688 1072 662 Z" fill="url(#glass)"/>
|
||||
<path d="M 1072 600 Q 1072 574 1098 574 L 1226 574 Q 1252 574 1252 600 L 1252 620 L 1072 620 Z" fill="url(#gloss)"/>
|
||||
<rect x="1100" y="610" width="112" height="13" rx="6.5" fill="#FBE4AE"/>
|
||||
<rect x="1100" y="636" width="70" height="11" rx="5.5" fill="#F4EEDD"/>
|
||||
</g>
|
||||
|
||||
<!-- bell tile -->
|
||||
<g transform="translate(314,238)" filter="url(#sh)">
|
||||
<rect x="-80" y="-80" width="160" height="160" rx="50" fill="url(#glass)"/>
|
||||
<rect x="-80" y="-80" width="160" height="160" rx="50" fill="none" stroke="#FFFFFF" stroke-width="2" opacity="0.9"/>
|
||||
<rect x="-80" y="-80" width="160" height="94" rx="50" fill="url(#gloss)"/>
|
||||
<path d="M -36 16 Q -36 -30 0 -36 Q 36 -30 36 16 Z" fill="url(#amber)"/>
|
||||
<rect x="-46" y="16" width="92" height="17" rx="8.5" fill="url(#amberD)"/>
|
||||
<circle cx="0" cy="-42" r="9" fill="url(#amberD)"/>
|
||||
<circle cx="0" cy="45" r="11" fill="url(#amber)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,184 @@
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { BrowserRouter, Routes, Route, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { LanguageProvider, useLanguage } from './i18n/LanguageContext';
|
||||
import LanguageSelectPage from './pages/LanguageSelectPage';
|
||||
import MenuPage from './pages/MenuPage';
|
||||
import WordSearchPage from './pages/WordSearchPage';
|
||||
import LeaderboardPage from './pages/LeaderboardPage';
|
||||
import SentenceBuilderPage from './pages/SentenceBuilderPage';
|
||||
import FlashcardsPage from './pages/FlashcardsPage';
|
||||
import WordQuizPage from './pages/WordQuizPage';
|
||||
import BubbleWordsPage from './pages/BubbleWordsPage';
|
||||
import PronunciationPage from './pages/PronunciationPage';
|
||||
import LessonsPage from './pages/LessonsPage';
|
||||
import TopicLessonsPage from './pages/TopicLessonsPage';
|
||||
import LessonViewPage from './pages/LessonViewPage';
|
||||
import TestViewPage from './pages/TestViewPage';
|
||||
import PracticeViewPage from './pages/PracticeViewPage';
|
||||
import TeacherChatPage from './pages/TeacherChatPage';
|
||||
import TypingPage from './pages/TypingPage';
|
||||
import AdminDashboardModal from './components/AdminDashboardModal';
|
||||
import { registerUser, trackDailyLogin, getIsAdmin } from './api';
|
||||
import { getUserId } from './telegram';
|
||||
|
||||
function AppContent() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { setLang } = useLanguage();
|
||||
const [showDashboard, setShowDashboard] = useState(false);
|
||||
const [isAdmin, setIsAdmin] = useState(() => {
|
||||
// Начальное значение из localStorage, чтобы не мигало при загрузке
|
||||
return localStorage.getItem('filwords_is_admin') === '1';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const tg = window.Telegram?.WebApp;
|
||||
if (tg) {
|
||||
tg.ready();
|
||||
tg.expand();
|
||||
|
||||
if (typeof tg.disableVerticalSwipes === 'function') {
|
||||
tg.disableVerticalSwipes();
|
||||
}
|
||||
}
|
||||
|
||||
const userId = getUserId();
|
||||
if (userId) {
|
||||
registerUser()
|
||||
.then((data) => {
|
||||
trackDailyLogin().catch(() => {});
|
||||
|
||||
// 🔧 ИСПРАВЛЕНО: получаем is_admin с бэка, а не из хардкода
|
||||
if (typeof data.is_admin === 'boolean') {
|
||||
setIsAdmin(data.is_admin);
|
||||
localStorage.setItem('filwords_is_admin', data.is_admin ? '1' : '0');
|
||||
} else {
|
||||
// Fallback: если бэк по какой-то причине не вернул флаг — спрашиваем отдельно
|
||||
getIsAdmin()
|
||||
.then((adminData) => {
|
||||
setIsAdmin(adminData.is_admin);
|
||||
localStorage.setItem('filwords_is_admin', adminData.is_admin ? '1' : '0');
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
const langFromStorage = localStorage.getItem('filwords_lang');
|
||||
|
||||
if (!langFromStorage) {
|
||||
// Первый визит — показываем выбор языка
|
||||
navigate('/language');
|
||||
} else if (data.app_language && data.app_language !== langFromStorage) {
|
||||
// Синхронизируем язык с сервером
|
||||
setLang(data.app_language);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 🔧 Слушаем событие "open-dashboard" из MenuPage — открывает модалку дашборда
|
||||
useEffect(() => {
|
||||
const handleOpenDashboard = () => {
|
||||
if (isAdmin) {
|
||||
setShowDashboard(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('open-dashboard', handleOpenDashboard);
|
||||
return () => {
|
||||
window.removeEventListener('open-dashboard', handleOpenDashboard);
|
||||
};
|
||||
}, [isAdmin]);
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
document.documentElement.scrollTop = 0;
|
||||
document.body.scrollTop = 0;
|
||||
|
||||
const raf = requestAnimationFrame(() => {
|
||||
window.scrollTo(0, 0);
|
||||
document.documentElement.scrollTop = 0;
|
||||
document.body.scrollTop = 0;
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
window.scrollTo(0, 0);
|
||||
document.documentElement.scrollTop = 0;
|
||||
document.body.scrollTop = 0;
|
||||
}, 50);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Routes>
|
||||
<Route path="/" element={<MenuPage />} />
|
||||
<Route path="/language" element={<LanguageSelectPage />} />
|
||||
<Route path="/menu" element={<MenuPage />} />
|
||||
<Route path="/play/:levelNumber" element={<WordSearchPage />} />
|
||||
<Route path="/leaderboard" element={<LeaderboardPage />} />
|
||||
<Route path="/sentence" element={<SentenceBuilderPage />} />
|
||||
<Route path="/flashcards" element={<FlashcardsPage />} />
|
||||
<Route path="/wordquiz" element={<WordQuizPage />} />
|
||||
<Route path="/bubble" element={<BubbleWordsPage />} />
|
||||
<Route path="/pronunciation" element={<PronunciationPage />} />
|
||||
<Route path="/typing" element={<TypingPage />} />
|
||||
<Route path="/lessons" element={<LessonsPage />} />
|
||||
<Route path="/lessons/topic/:topicId" element={<TopicLessonsPage />} />
|
||||
<Route path="/lessons/topic/:topicId/lesson/:lessonId" element={<LessonViewPage />} />
|
||||
<Route path="/lessons/test/:topicId" element={<TestViewPage />} />
|
||||
<Route path="/lessons/practice" element={<PracticeViewPage />} />
|
||||
<Route path="/teacher" element={<TeacherChatPage />} />
|
||||
</Routes>
|
||||
|
||||
{/* Дашборд админа — открывается через событие "open-dashboard" из MenuPage */}
|
||||
{showDashboard && isAdmin && (
|
||||
<AdminDashboardModal
|
||||
isOpen={showDashboard}
|
||||
onClose={() => setShowDashboard(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<LanguageProvider>
|
||||
<BrowserRouter>
|
||||
<AppContent />
|
||||
</BrowserRouter>
|
||||
</LanguageProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,973 @@
|
||||
import { getInitData } from './telegram';
|
||||
|
||||
const API_BASE = 'https://api.filwordspl.com';
|
||||
|
||||
const fetchAPI = async (endpoint: string, options: RequestInit = {}) => {
|
||||
const initData = getInitData();
|
||||
|
||||
const url = `${API_BASE}${endpoint}${endpoint.includes('?') ? '&' : '?'}initData=${encodeURIComponent(initData)}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
throw new Error(`API Error: ${res.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return data;
|
||||
};
|
||||
|
||||
// ============ Пользователь ============
|
||||
export const registerUser = (): Promise<{ status: string; app_language: string; current_level: string; user_id: number; is_admin: boolean }> =>
|
||||
fetchAPI('/users/register', { method: 'POST' });
|
||||
|
||||
export const getIsAdmin = (): Promise<{ is_admin: boolean }> =>
|
||||
fetchAPI('/users/me/is-admin');
|
||||
|
||||
export const setLanguage = (language: string) =>
|
||||
fetchAPI('/users/set-language', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ language }),
|
||||
});
|
||||
|
||||
export const setUserLevel = (level: string) =>
|
||||
fetchAPI('/users/set-level', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ level }),
|
||||
});
|
||||
|
||||
// ============ Админ-панель ============
|
||||
export const getAdminDashboard = (): Promise<{
|
||||
total_users: number;
|
||||
new_today: number;
|
||||
online_now: number;
|
||||
active_24h: number;
|
||||
level_counts: Record<string, number>;
|
||||
}> =>
|
||||
fetchAPI('/admin/dashboard');
|
||||
|
||||
export const getSentryErrors = (): Promise<{
|
||||
errors: Array<{
|
||||
id: string;
|
||||
short_id: string;
|
||||
title: string;
|
||||
culprit: string;
|
||||
level: string;
|
||||
count: number;
|
||||
user_count: number;
|
||||
first_seen: string;
|
||||
last_seen: string;
|
||||
permalink: string;
|
||||
priority: string;
|
||||
}>;
|
||||
count?: number;
|
||||
error?: string;
|
||||
}> =>
|
||||
fetchAPI('/admin/sentry-errors');
|
||||
|
||||
// ============ Прогресс ============
|
||||
export const fetchProgress = (): Promise<{ last_completed_level: number; total_score: number; current_level?: string }> =>
|
||||
fetchAPI('/game/progress');
|
||||
|
||||
// ============ Игра ============
|
||||
export const getLevel = (levelNumber: number) =>
|
||||
fetchAPI(`/game/level/${levelNumber}`);
|
||||
|
||||
export const checkWord = (levelId: number, cells: number[][]) =>
|
||||
fetchAPI('/game/check-word', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ level_id: levelId, cells }),
|
||||
});
|
||||
|
||||
export const completeLevel = (levelId: number, hintsUsed: number, completionTime?: number, perfectStreak?: number) =>
|
||||
fetchAPI('/game/complete-level', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
level_id: levelId,
|
||||
hints_used: hintsUsed,
|
||||
completion_time: completionTime || undefined,
|
||||
perfect_streak: perfectStreak || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
// ============ Подсказки ============
|
||||
export const getFirstLetterHint = (levelId: number) =>
|
||||
fetchAPI(`/hints/first-letter/${levelId}`);
|
||||
|
||||
export const getWordList = (levelId: number) =>
|
||||
fetchAPI(`/hints/word-list/${levelId}`);
|
||||
|
||||
// ============ Рейтинг ============
|
||||
export const getLeaderboard = (limit: number = 50) =>
|
||||
fetchAPI(`/leaderboard?limit=${limit}`);
|
||||
|
||||
export const getAdminStats = () =>
|
||||
fetchAPI('/leaderboard/admin/stats');
|
||||
|
||||
// ============ Предложения ============
|
||||
export const getNextSentence = () =>
|
||||
fetchAPI('/sentences/next');
|
||||
|
||||
export const checkSentence = (sentenceId: number, words: string[], completionTime?: number) =>
|
||||
fetchAPI('/sentences/check', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sentence_id: sentenceId,
|
||||
words: words,
|
||||
completion_time: completionTime || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
export const getSentenceHint = (sentenceId: number) =>
|
||||
fetchAPI(`/sentences/${sentenceId}/hint`);
|
||||
|
||||
// ============ Word Quiz ============
|
||||
export const getNextQuiz = () =>
|
||||
fetchAPI('/wordquiz/next');
|
||||
|
||||
export const checkQuizAnswer = (correctPolish: string, answer: string, mistakes: number, completionTime?: number) =>
|
||||
fetchAPI('/wordquiz/check', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
correct_polish: correctPolish,
|
||||
answer: answer,
|
||||
mistakes: mistakes,
|
||||
completion_time: completionTime || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
export const getQuizProgress = () =>
|
||||
fetchAPI('/wordquiz/progress');
|
||||
|
||||
// ============ Bubble Words ============
|
||||
export const getBubbleLevel = () =>
|
||||
fetchAPI('/bubblewords/next');
|
||||
|
||||
export const completeBubbleLevel = (hintsUsed: number, wordsCount: number, completionTime?: number) =>
|
||||
fetchAPI('/bubblewords/complete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
hints_used: hintsUsed,
|
||||
words_count: wordsCount,
|
||||
completion_time: completionTime || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
export const getBubbleProgress = () =>
|
||||
fetchAPI('/bubblewords/progress');
|
||||
|
||||
// ============ Произношение ============
|
||||
export const getNextPronunciationWord = () =>
|
||||
fetchAPI('/pronunciation/next');
|
||||
|
||||
// ============ TTS (Edge TTS) ============
|
||||
export const getTTS = (text: string, voice: string = 'female'): Promise<Blob> => {
|
||||
const initData = getInitData();
|
||||
|
||||
return fetch(`${API_BASE}/tts?text=${encodeURIComponent(text)}&voice=${voice}&initData=${encodeURIComponent(initData)}`, {
|
||||
method: 'GET',
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
throw new Error(`API Error: ${res.status} - ${errorText}`);
|
||||
}
|
||||
return res.blob();
|
||||
});
|
||||
};
|
||||
|
||||
// ============ AI Assistant ============
|
||||
export const sendMessageToAssistant = (message: string): Promise<{ response: string; limit_exceeded?: boolean; daily_limit?: number; used?: number; remaining?: number }> =>
|
||||
fetchAPI('/assistant/chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
|
||||
export const getAssistantLimit = (): Promise<{ is_admin: boolean; daily_limit: number | null; used: number; remaining: number | null }> =>
|
||||
fetchAPI('/assistant/limit');
|
||||
|
||||
export const getAssistantHistory = (): Promise<{ history: Array<{ message: string; is_user: boolean; created_at: string | null }> }> =>
|
||||
fetchAPI('/assistant/history');
|
||||
|
||||
export const clearAssistantHistory = (): Promise<{ status: string }> =>
|
||||
fetchAPI('/assistant/history/clear', { method: 'POST' });
|
||||
|
||||
export const saveAnalyticsToHistory = (message: string): Promise<{ status: string }> =>
|
||||
fetchAPI('/assistant/save-analytics', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
|
||||
export const getPersonalStats = (): Promise<any> =>
|
||||
fetchAPI('/assistant/stats/personal');
|
||||
|
||||
export const getAdminStatsFromAssistant = (): Promise<any> =>
|
||||
fetchAPI('/assistant/stats/admin');
|
||||
|
||||
// ============ Уроки (Learning) ============
|
||||
export const getLearningLevels = () =>
|
||||
fetchAPI('/learning/levels');
|
||||
|
||||
export const getLearningTopics = (level: string) =>
|
||||
fetchAPI(`/learning/topics?level=${level}`);
|
||||
|
||||
export const getLearningTopic = (topicId: string) =>
|
||||
fetchAPI(`/learning/topics/${topicId}`);
|
||||
|
||||
export const getLearningLesson = (lessonId: string) =>
|
||||
fetchAPI(`/learning/lessons/${lessonId}`);
|
||||
|
||||
export const getTopicTest = (topicId: string) =>
|
||||
fetchAPI(`/learning/test/${topicId}`);
|
||||
|
||||
export const startLearningSession = (levelCode: string, topicId: string, sessionType: string = 'lesson') =>
|
||||
fetchAPI('/learning/session/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ level_code: levelCode, topic_id: topicId, session_type: sessionType }),
|
||||
});
|
||||
|
||||
export const saveLearningAnswer = (sessionId: number, questionId: string, answer: string, timeTakenMs: number = 0, isFinalAnswer: boolean = true, hintUsed: boolean = false) =>
|
||||
fetchAPI('/learning/answer', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
question_id: questionId,
|
||||
answer: answer,
|
||||
time_taken_ms: timeTakenMs,
|
||||
is_final_answer: isFinalAnswer,
|
||||
hint_used: hintUsed,
|
||||
}),
|
||||
});
|
||||
|
||||
export const completeLearningSession = (sessionId: number) =>
|
||||
fetchAPI('/learning/session/complete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ session_id: sessionId }),
|
||||
});
|
||||
|
||||
export const getLearningProgress = () =>
|
||||
fetchAPI('/learning/progress');
|
||||
|
||||
export const getLearningAnalytics = (): Promise<{ analytics_enabled: boolean; response?: string; message?: string; weak_skills?: any[]; has_practice_available?: boolean; cta_label?: string }> =>
|
||||
fetchAPI('/learning/analytics', { method: 'POST' });
|
||||
|
||||
export const getAIContext = () =>
|
||||
fetchAPI('/learning/ai/context');
|
||||
|
||||
// ============ Практика слабых навыков ============
|
||||
export const startWeakSkillsPractice = (): Promise<{ has_weak_skills: boolean; message?: string; session_id?: number; explanations?: any[]; questions?: any[] }> =>
|
||||
fetchAPI('/learning/practice/start', { method: 'POST' });
|
||||
|
||||
// ============ Печать (Typing) ============
|
||||
export const getNextTypingSentence = (): Promise<{ completed: boolean; message?: string; sentence_id?: number; translation?: string; level?: string }> =>
|
||||
fetchAPI('/typing/next');
|
||||
|
||||
export const checkTypingSentence = (sentenceId: number, text: string): Promise<{ correct: boolean; correct_answer: string }> =>
|
||||
fetchAPI('/typing/check', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sentence_id: sentenceId, text }),
|
||||
});
|
||||
|
||||
export const getTypingHint = (sentenceId: number): Promise<{ correct_answer: string }> =>
|
||||
fetchAPI(`/typing/hint/${sentenceId}`);
|
||||
|
||||
export const getTypingProgress = (): Promise<{ total: number; completed: number; remaining: number }> =>
|
||||
fetchAPI('/typing/progress');
|
||||
|
||||
// ============ Достижения ============
|
||||
export const getAllAchievements = () =>
|
||||
fetchAPI('/achievements');
|
||||
|
||||
export const getUserAchievements = () =>
|
||||
fetchAPI('/achievements/user');
|
||||
|
||||
export const trackLogin = () =>
|
||||
fetchAPI('/achievements/track-login', { method: 'POST' });
|
||||
|
||||
export const trackTeacherMenuOpened = () =>
|
||||
fetchAPI('/achievements/track-teacher-menu', { method: 'POST' });
|
||||
|
||||
export const trackProgressOpened = () =>
|
||||
fetchAPI('/achievements/track-progress', { method: 'POST' });
|
||||
|
||||
export const trackCheatsheetsOpened = () =>
|
||||
fetchAPI('/achievements/track-cheatsheets', { method: 'POST' });
|
||||
|
||||
export const trackPronunciationOpened = () =>
|
||||
fetchAPI('/achievements/track-pronunciation', { method: 'POST' });
|
||||
|
||||
export const trackAIAssistantOpened = () =>
|
||||
fetchAPI('/achievements/track-ai-assistant', { method: 'POST' });
|
||||
|
||||
export const trackAlphabetOpened = () =>
|
||||
fetchAPI('/achievements/track-alphabet', { method: 'POST' });
|
||||
|
||||
export const trackRulesOpened = () =>
|
||||
fetchAPI('/achievements/track-rules', { method: 'POST' });
|
||||
|
||||
// ============ Дневной вход ============
|
||||
export const trackDailyLogin = async () => {
|
||||
try {
|
||||
const data = await trackLogin();
|
||||
return data;
|
||||
} catch (e) {
|
||||
console.error('Error tracking login:', e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// ============ Teacher (Чат-учитель) ============
|
||||
export const teacherStart = (): Promise<{
|
||||
message_type: string;
|
||||
session_id?: number;
|
||||
lesson_id?: string;
|
||||
topic_id?: string;
|
||||
topic_name?: string;
|
||||
text: string;
|
||||
question?: any;
|
||||
question_index?: number;
|
||||
total_questions?: number;
|
||||
has_explain_command?: boolean;
|
||||
has_translate_command?: boolean;
|
||||
}> =>
|
||||
fetchAPI('/teacher/start', { method: 'POST' });
|
||||
|
||||
export const teacherResume = (): Promise<{
|
||||
has_active_session: boolean;
|
||||
session_id?: number;
|
||||
session_type?: string;
|
||||
topic_id?: string;
|
||||
question?: any;
|
||||
question_index?: number;
|
||||
total_questions?: number;
|
||||
session_completed?: boolean;
|
||||
}> =>
|
||||
fetchAPI('/teacher/resume', { method: 'POST' });
|
||||
|
||||
export const teacherAnswer = (sessionId: number, questionId: string, answer: string, timeTakenMs?: number): Promise<{
|
||||
message_type: string;
|
||||
reaction: string;
|
||||
explanation?: string;
|
||||
is_correct: boolean;
|
||||
correct_answer?: string;
|
||||
wrong_because?: string;
|
||||
question?: any;
|
||||
question_index?: number;
|
||||
total_questions?: number;
|
||||
session_completed?: boolean;
|
||||
session_stats?: {
|
||||
total: number;
|
||||
correct_count: number;
|
||||
};
|
||||
session_type?: string;
|
||||
has_explain_command?: boolean;
|
||||
has_translate_command?: boolean;
|
||||
}> =>
|
||||
fetchAPI('/teacher/answer', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
question_id: questionId,
|
||||
answer: answer,
|
||||
time_taken_ms: timeTakenMs || 0,
|
||||
}),
|
||||
});
|
||||
|
||||
export const teacherExplain = (lessonId?: string, questionId?: string, topic?: string): Promise<{ response: string; limit_exceeded?: boolean; message?: string }> =>
|
||||
fetchAPI('/teacher/explain', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
lesson_id: lessonId || '',
|
||||
question_id: questionId || '',
|
||||
topic: topic || '',
|
||||
}),
|
||||
});
|
||||
|
||||
export const teacherTranslate = (text: string): Promise<{ response: string; limit_exceeded?: boolean; message?: string }> =>
|
||||
fetchAPI('/teacher/translate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
|
||||
export const teacherExamples = (topic: string, lessonId?: string, topicId?: string): Promise<{ response: string; limit_exceeded?: boolean; message?: string }> =>
|
||||
fetchAPI('/teacher/examples', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
topic: topic || '',
|
||||
lesson_id: lessonId || '',
|
||||
topic_id: topicId || '',
|
||||
}),
|
||||
});
|
||||
|
||||
// ============ Teacher Free Message (свободный ввод) ============
|
||||
export const teacherFreeMessage = (message: string, lessonId?: string, topicId?: string, topicName?: string): Promise<{
|
||||
response: string;
|
||||
limit_exceeded?: boolean;
|
||||
message?: string;
|
||||
}> =>
|
||||
fetchAPI('/teacher/free-message', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
lesson_id: lessonId || '',
|
||||
topic_id: topicId || '',
|
||||
topic_name: topicName || '',
|
||||
}),
|
||||
});
|
||||
|
||||
// ============ Teacher Diagnostic Test (узнать уровень) ============
|
||||
export const teacherDiagnosticTest = (): Promise<{
|
||||
session_id: number;
|
||||
question?: any;
|
||||
question_index?: number;
|
||||
total_questions?: number;
|
||||
}> =>
|
||||
fetchAPI('/teacher/diagnostic-test', { method: 'POST' });
|
||||
|
||||
// ============ Teacher Translate To Polish (переведи на польский) ============
|
||||
export const teacherTranslateToPolish = (text: string): Promise<{
|
||||
response: string;
|
||||
limit_exceeded?: boolean;
|
||||
message?: string;
|
||||
}> =>
|
||||
fetchAPI('/teacher/translate-to-polish', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
|
||||
// ============ Teacher History ============
|
||||
export const saveTeacherHistory = (messages: any[]): Promise<{ status: string }> =>
|
||||
fetchAPI('/teacher/history/save', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ messages }),
|
||||
});
|
||||
|
||||
export const loadTeacherHistory = (): Promise<{ messages: any[] }> =>
|
||||
fetchAPI('/teacher/history/load');
|
||||
|
||||
export const clearTeacherHistory = (): Promise<{ status: string }> =>
|
||||
fetchAPI('/teacher/history/clear', { method: 'POST' });
|
||||
|
||||
// ============ Прогресс по уровням для меню учителя ============
|
||||
export const getTeacherProgress = (): Promise<{
|
||||
levels: Array<{
|
||||
code: string;
|
||||
name_pl: string;
|
||||
name_ru: string;
|
||||
name_uk: string;
|
||||
name_en: string;
|
||||
topics_total: number;
|
||||
topics_completed: number;
|
||||
}>;
|
||||
}> =>
|
||||
fetchAPI('/teacher/progress');
|
||||
|
||||
// ============ Фраза дня ============
|
||||
export const getPhraseOfDay = (): Promise<{
|
||||
phrase_pl: string;
|
||||
date: string | null;
|
||||
translation?: string;
|
||||
grammar_breakdown: string;
|
||||
}> =>
|
||||
fetchAPI('/teacher/phrase-of-day');
|
||||
|
||||
// ============ ПОДДЕРЖКА ============
|
||||
export const sendSupportMessage = (message: string): Promise<{ status: string; ticket_id: number }> =>
|
||||
fetchAPI('/support/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
|
||||
export const getUserTickets = (): Promise<{
|
||||
tickets: Array<{
|
||||
id: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}>;
|
||||
}> =>
|
||||
fetchAPI('/support/user-tickets');
|
||||
|
||||
export const getUserMessages = (ticketId: number): Promise<{
|
||||
messages: Array<{
|
||||
id: number;
|
||||
sender_id: number;
|
||||
message: string;
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
}>;
|
||||
}> =>
|
||||
fetchAPI(`/support/user-messages/${ticketId}`);
|
||||
|
||||
export const deleteTicket = (ticketId: number): Promise<{ status: string }> =>
|
||||
fetchAPI(`/support/delete-ticket/${ticketId}`, { method: 'POST' });
|
||||
|
||||
export const getAdminTickets = (): Promise<{
|
||||
tickets: Array<{
|
||||
id: number;
|
||||
user_id: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
username: string | null;
|
||||
app_language: string;
|
||||
unread_count: number;
|
||||
last_message: string | null;
|
||||
}>;
|
||||
}> =>
|
||||
fetchAPI('/support/admin-tickets');
|
||||
|
||||
export const getAdminMessages = (ticketId: number): Promise<{
|
||||
messages: Array<{
|
||||
id: number;
|
||||
sender_id: number;
|
||||
message: string;
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
}>;
|
||||
}> =>
|
||||
fetchAPI(`/support/admin-messages/${ticketId}`);
|
||||
|
||||
export const adminReply = (ticketId: number, message: string): Promise<{ status: string }> =>
|
||||
fetchAPI('/support/admin-reply', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ticket_id: ticketId, message }),
|
||||
});
|
||||
|
||||
export const getUnreadCount = (): Promise<{ unread_count: number }> =>
|
||||
fetchAPI('/support/unread-count');
|
||||
|
||||
export const getUserUnreadCount = (): Promise<{ unread_count: number }> =>
|
||||
fetchAPI('/support/user-unread-count');
|
||||
|
||||
export const translateMessage = (text: string, targetLang: string): Promise<{ translation: string }> =>
|
||||
fetchAPI('/support/translate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ text, target_lang: targetLang }),
|
||||
});
|
||||
|
||||
// ============ 🔧 FEED (карусель) ============
|
||||
export type FeedSection = 'news' | 'partners' | 'contacts' | 'apps';
|
||||
|
||||
export interface FeedPostResponse {
|
||||
id: number;
|
||||
section: FeedSection;
|
||||
title: string;
|
||||
body: string;
|
||||
images: string[];
|
||||
linkUrl: string | null;
|
||||
telegramUrl: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
isPinned: boolean;
|
||||
}
|
||||
|
||||
export interface FeedPostPayload {
|
||||
section?: FeedSection;
|
||||
source_language?: 'pl' | 'ru' | 'uk' | 'en';
|
||||
title?: string;
|
||||
body?: string;
|
||||
link_url?: string | null;
|
||||
telegram_url?: string | null;
|
||||
is_pinned?: boolean;
|
||||
is_published?: boolean;
|
||||
expires_at?: string | null;
|
||||
translate_again?: boolean;
|
||||
}
|
||||
|
||||
export interface FeedListResponse {
|
||||
posts: FeedPostResponse[];
|
||||
section: FeedSection;
|
||||
lang: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить ленту постов для секции.
|
||||
* lang — опционально, если не передан — бэк возьмёт язык из профиля юзера.
|
||||
*/
|
||||
export const getFeedSection = (section: FeedSection, lang?: string): Promise<FeedListResponse> => {
|
||||
const langParam = lang ? `?lang=${encodeURIComponent(lang)}` : '';
|
||||
return fetchAPI(`/feed/${section}${langParam}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Получить один пост по ID.
|
||||
*/
|
||||
export const getFeedPost = (postId: number, lang?: string): Promise<FeedPostResponse> => {
|
||||
const langParam = lang ? `?lang=${encodeURIComponent(lang)}` : '';
|
||||
return fetchAPI(`/feed/post/${postId}${langParam}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Создать пост (только админ).
|
||||
* Бэк автоматически переведёт title и body на 4 языка через MiniMax-M3.
|
||||
*/
|
||||
export const createFeedPost = (payload: FeedPostPayload): Promise<{ status: string; post_id: number }> =>
|
||||
fetchAPI('/admin/feed/post', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
/**
|
||||
* Обновить пост (только админ).
|
||||
* Если translate_again = true — перевод будет пересоздан.
|
||||
*/
|
||||
export const updateFeedPost = (postId: number, payload: FeedPostPayload): Promise<{ status: string; post_id: number }> =>
|
||||
fetchAPI(`/admin/feed/post/${postId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
/**
|
||||
* Удалить пост (только админ).
|
||||
*
|
||||
* 🔧 ФИКС (18.09.2026): POST вместо DELETE.
|
||||
*
|
||||
* Причина: Telegram WebView (iOS / macOS) не пропускает DELETE —
|
||||
* браузер обязан отправить CORS preflight (OPTIONS), а Cloudflare
|
||||
* кеширует его на 20 дней и отдаёт устаревший ответ без DELETE в
|
||||
* allow-methods. В итоге WebView кидает "Load failed".
|
||||
*
|
||||
* POST без Content-Type и без кастомных заголовков — simple request,
|
||||
* preflight не отправляется, Cloudflare не вмешивается.
|
||||
*/
|
||||
export const deleteFeedPost = async (postId: number): Promise<{ status: string }> => {
|
||||
const initData = getInitData();
|
||||
const url = `${API_BASE}/admin/feed/post/${postId}/delete?initData=${encodeURIComponent(initData)}`;
|
||||
|
||||
const res = await fetch(url, { method: 'POST' });
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
throw new Error(`API Error: ${res.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* Перевести title+body без сохранения (для превью в админ-UI).
|
||||
*/
|
||||
export const translateFeedPreview = (payload: {
|
||||
title: string;
|
||||
body: string;
|
||||
source_language: 'pl' | 'ru' | 'uk' | 'en';
|
||||
}): Promise<{
|
||||
title_pl: string; title_ru: string; title_uk: string; title_en: string;
|
||||
body_pl: string; body_ru: string; body_uk: string; body_en: string;
|
||||
}> =>
|
||||
fetchAPI('/admin/feed/translate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
/**
|
||||
* Загрузить картинку к посту (только админ).
|
||||
* ⚠️ НЕ через fetchAPI — потому что там жёстко Content-Type: application/json,
|
||||
* а для файлов нужен multipart/form-data (boundary ставит браузер).
|
||||
*
|
||||
* 🔧 ФИКС (18.09.2026, вечер): принимает готовый Blob, а не File.
|
||||
*
|
||||
* Причина: в Telegram WebView (iOS/macOS) File-объект «протухает» через
|
||||
* несколько сотен мс после выбора из галереи. Если читать file.arrayBuffer()
|
||||
* здесь, второй раз — fetch падает с TypeError: Failed to fetch ДО отправки
|
||||
* запроса. В uvicorn это не видно.
|
||||
*
|
||||
* Решение: вызывающий код (FeedPostComposer) читает байты ОДИН РАЗ в
|
||||
* handleSlotFile, создаёт Blob и хранит его в state. Мы просто кладём
|
||||
* Blob в FormData и отправляем — WebView его не «съест», потому что это
|
||||
* уже in-memory объект.
|
||||
*
|
||||
* @param postId ID поста, к которому привязать картинку
|
||||
* @param blob Готовый Blob с байтами (создан из arrayBuffer() в UI)
|
||||
* @param originalName Оригинальное имя файла (для расширения)
|
||||
* @param mimeType MIME-тип (image/jpeg, image/png, ...)
|
||||
*/
|
||||
export const uploadFeedImage = async (
|
||||
postId: number,
|
||||
blob: Blob,
|
||||
originalName: string,
|
||||
mimeType: string,
|
||||
): Promise<{
|
||||
status: string;
|
||||
file_path: string;
|
||||
order_number: number;
|
||||
}> => {
|
||||
const initData = getInitData();
|
||||
const url = `${API_BASE}/admin/feed/upload-image?initData=${encodeURIComponent(initData)}`;
|
||||
|
||||
// Определяем имя с правильным расширением
|
||||
const extFromType = (mimeType || 'image/jpeg').split('/')[1] || 'jpg';
|
||||
const cleanExt = extFromType === 'jpeg' ? 'jpg' : extFromType;
|
||||
const cleanName = (originalName || 'image').replace(/\.[^.]+$/, '') + '.' + cleanExt;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('post_id', String(postId));
|
||||
formData.append('file', blob, cleanName);
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
// ❌ НЕ ставим Content-Type — браузер сам поставит multipart/form-data + boundary
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
throw new Error(`API Error: ${res.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
};
|
||||
|
||||
// ============ ADMIN CONTENT (контент-менеджер) ============
|
||||
|
||||
export interface ContentSummaryLevel {
|
||||
code: string;
|
||||
name_ru: string;
|
||||
topics_total: number;
|
||||
lessons_total: number;
|
||||
words_total: number;
|
||||
texts_total: number;
|
||||
last_edited: string | null;
|
||||
}
|
||||
|
||||
export interface ContentSummary {
|
||||
levels: ContentSummaryLevel[];
|
||||
}
|
||||
|
||||
export interface ContentWordLevel {
|
||||
code: string;
|
||||
name_ru: string;
|
||||
words_count: number;
|
||||
last_edited: string | null;
|
||||
}
|
||||
|
||||
export interface ContentWord {
|
||||
id: number;
|
||||
word_pl: string;
|
||||
translation_ru: string;
|
||||
translation_uk: string;
|
||||
translation_en: string;
|
||||
edited_at: string | null;
|
||||
}
|
||||
|
||||
export interface ContentWordsList {
|
||||
level: string;
|
||||
count: number;
|
||||
words: ContentWord[];
|
||||
}
|
||||
|
||||
export interface ContentTextLevel {
|
||||
code: string;
|
||||
name_ru: string;
|
||||
texts_count: number;
|
||||
last_edited: string | null;
|
||||
}
|
||||
|
||||
export interface ContentText {
|
||||
id: number;
|
||||
sentence_number: number;
|
||||
sentence_pl: string;
|
||||
translation_ru: string;
|
||||
translation_uk: string;
|
||||
translation_en: string;
|
||||
difficulty: number;
|
||||
edited_at: string | null;
|
||||
}
|
||||
|
||||
export interface ContentTextsList {
|
||||
level: string;
|
||||
count: number;
|
||||
texts: ContentText[];
|
||||
}
|
||||
|
||||
// Сводка по всем уровням
|
||||
export const getContentSummary = (): Promise<ContentSummary> =>
|
||||
fetchAPI('/admin/content/summary');
|
||||
|
||||
// Слова
|
||||
export const getWordLevels = (): Promise<{ levels: ContentWordLevel[] }> =>
|
||||
fetchAPI('/admin/content/words/levels');
|
||||
|
||||
export const getLevelWords = (level: string, q?: string): Promise<ContentWordsList> => {
|
||||
const params = new URLSearchParams();
|
||||
if (q) params.set('q', q);
|
||||
const qs = params.toString();
|
||||
return fetchAPI(`/admin/content/words/level/${level}${qs ? '?' + qs : ''}`);
|
||||
};
|
||||
|
||||
export const getWord = (wordId: number): Promise<ContentWord & { level: string }> =>
|
||||
fetchAPI(`/admin/content/words/word/${wordId}`);
|
||||
|
||||
export const updateWord = (wordId: number, payload: {
|
||||
word_pl: string;
|
||||
translation_ru: string;
|
||||
translation_uk: string;
|
||||
translation_en: string;
|
||||
}): Promise<{ status: string; word_id: number }> =>
|
||||
fetchAPI(`/admin/content/words/word/${wordId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
export const createWord = (level: string, payload: {
|
||||
word_pl: string;
|
||||
translation_ru: string;
|
||||
translation_uk: string;
|
||||
translation_en: string;
|
||||
}): Promise<{ status: string; word_id: number }> =>
|
||||
fetchAPI(`/admin/content/words/level/${level}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
// Тексты
|
||||
export const getTextLevels = (): Promise<{ levels: ContentTextLevel[] }> =>
|
||||
fetchAPI('/admin/content/texts/levels');
|
||||
|
||||
export const getLevelTexts = (level: string, q?: string): Promise<ContentTextsList> => {
|
||||
const params = new URLSearchParams();
|
||||
if (q) params.set('q', q);
|
||||
const qs = params.toString();
|
||||
return fetchAPI(`/admin/content/texts/level/${level}${qs ? '?' + qs : ''}`);
|
||||
};
|
||||
|
||||
export const getText = (textId: number): Promise<ContentText & { level: string }> =>
|
||||
fetchAPI(`/admin/content/texts/text/${textId}`);
|
||||
|
||||
export const updateText = (textId: number, payload: {
|
||||
sentence_pl: string;
|
||||
translation_ru: string;
|
||||
translation_uk: string;
|
||||
translation_en: string;
|
||||
}): Promise<{ status: string; text_id: number; difficulty: number }> =>
|
||||
fetchAPI(`/admin/content/texts/text/${textId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
export const createText = (level: string, payload: {
|
||||
sentence_pl: string;
|
||||
translation_ru: string;
|
||||
translation_uk: string;
|
||||
translation_en: string;
|
||||
}): Promise<{ status: string; text_id: number; sentence_number: number }> =>
|
||||
fetchAPI(`/admin/content/texts/level/${level}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
// ============ ADMIN CONTENT: LESSONS ============
|
||||
|
||||
export interface ContentLessonLevel {
|
||||
code: string;
|
||||
name_ru: string;
|
||||
topics_count: number;
|
||||
lessons_count: number;
|
||||
last_edited: string | null;
|
||||
}
|
||||
|
||||
export interface ContentLessonTopic {
|
||||
topic_id: string;
|
||||
order_number: number;
|
||||
name_pl: string;
|
||||
name_ru: string;
|
||||
name_uk: string;
|
||||
name_en: string;
|
||||
lessons_count: number;
|
||||
test_questions_count: number;
|
||||
}
|
||||
|
||||
export interface ContentLessonItem {
|
||||
lesson_id: string;
|
||||
order_number: number;
|
||||
skill_tag: string;
|
||||
title_pl: string;
|
||||
title_ru: string;
|
||||
title_uk: string;
|
||||
title_en: string;
|
||||
}
|
||||
|
||||
export interface ContentTopicDetail {
|
||||
topic: {
|
||||
topic_id: string;
|
||||
level_code: string;
|
||||
order_number: number;
|
||||
name_pl: string;
|
||||
name_ru: string;
|
||||
name_uk: string;
|
||||
name_en: string;
|
||||
description_pl: string | null;
|
||||
description_ru: string | null;
|
||||
description_uk: string | null;
|
||||
description_en: string | null;
|
||||
};
|
||||
lessons: ContentLessonItem[];
|
||||
has_test: boolean;
|
||||
test_questions_count: number;
|
||||
}
|
||||
|
||||
// Уровни уроков
|
||||
export const getLessonLevels = (): Promise<{ levels: ContentLessonLevel[] }> =>
|
||||
fetchAPI('/admin/content/lessons/levels');
|
||||
|
||||
// Темы уровня
|
||||
export const getLessonTopics = (level: string): Promise<{ level: string; topics: ContentLessonTopic[] }> =>
|
||||
fetchAPI(`/admin/content/lessons/level/${level}/topics`);
|
||||
|
||||
// Тема целиком (с уроками)
|
||||
export const getLessonTopic = (topicId: string): Promise<ContentTopicDetail> =>
|
||||
fetchAPI(`/admin/content/lessons/topic/${topicId}`);
|
||||
|
||||
// Урок целиком (для WYSIWYG)
|
||||
export const getLessonFull = (lessonId: string): Promise<{
|
||||
lesson: any;
|
||||
examples: any[];
|
||||
questions: any[];
|
||||
}> =>
|
||||
fetchAPI(`/admin/content/lessons/lesson/${lessonId}`);
|
||||
|
||||
// Сохранить урок
|
||||
export const updateLessonFull = (
|
||||
lessonId: string,
|
||||
payload: {
|
||||
title_pl: string; title_ru: string; title_uk: string; title_en: string;
|
||||
objective_pl: string; objective_ru: string; objective_uk: string; objective_en: string;
|
||||
explanation_pl: string; explanation_ru: string; explanation_uk: string; explanation_en: string;
|
||||
examples: Array<{ text_pl: string; text_ru: string; text_uk: string; text_en: string }>;
|
||||
questions: Array<{
|
||||
question_id: string;
|
||||
prompt_pl: string; prompt_ru: string; prompt_uk: string; prompt_en: string;
|
||||
hint_type: string | null;
|
||||
hint_value: string | null;
|
||||
options: Array<{
|
||||
text_pl: string; text_ru: string; text_uk: string; text_en: string;
|
||||
is_correct: boolean;
|
||||
wrong_because: string | null;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
): Promise<{ status: string; lesson_id: string; examples_count: number; questions_count: number }> =>
|
||||
fetchAPI(`/admin/content/lessons/lesson/${lessonId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
// Справочник wrong_because
|
||||
export interface WrongBecauseItem {
|
||||
wrong_because: string;
|
||||
explanation_pl: string;
|
||||
explanation_ru: string;
|
||||
explanation_uk: string;
|
||||
explanation_en: string;
|
||||
}
|
||||
|
||||
export const getWrongBecause = (): Promise<{ items: WrongBecauseItem[] }> =>
|
||||
fetchAPI('/admin/content/wrong-because');
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -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="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,621 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useLanguage } from '../i18n/LanguageContext';
|
||||
import { radius } from '../styles/theme';
|
||||
import { getAdminDashboard, getSentryErrors } from '../api';
|
||||
import ContentEditorMenu from './ContentEditorMenu';
|
||||
import WordsEditor from './WordsEditor';
|
||||
import TextsEditor from './TextsEditor';
|
||||
import LessonsEditor from './LessonsEditor';
|
||||
import LessonEditorModal from './LessonEditorModal';
|
||||
|
||||
interface AdminDashboardModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface DashboardData {
|
||||
total_users: number;
|
||||
new_today: number;
|
||||
online_now: number;
|
||||
active_24h: number;
|
||||
level_counts: Record<string, number>;
|
||||
}
|
||||
|
||||
interface SentryError {
|
||||
id: string;
|
||||
short_id: string;
|
||||
title: string;
|
||||
culprit: string;
|
||||
level: string;
|
||||
count: number;
|
||||
user_count: number;
|
||||
first_seen: string;
|
||||
last_seen: string;
|
||||
permalink: string;
|
||||
priority: string;
|
||||
}
|
||||
|
||||
const AdminDashboardModal: React.FC<AdminDashboardModalProps> = ({ isOpen, onClose }) => {
|
||||
const { language } = useLanguage();
|
||||
const [dashboard, setDashboard] = useState<DashboardData | null>(null);
|
||||
const [errors, setErrors] = useState<SentryError[]>([]);
|
||||
const [errorsLoading, setErrorsLoading] = useState(false);
|
||||
const [errorsError, setErrorsError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
|
||||
|
||||
// 🔧 НОВОЕ (19.09.2026): контент-менеджер
|
||||
const [showContentEditor, setShowContentEditor] = useState(false);
|
||||
const [showWordsEditor, setShowWordsEditor] = useState(false);
|
||||
const [showTextsEditor, setShowTextsEditor] = useState(false);
|
||||
const [showLessonsEditor, setShowLessonsEditor] = useState(false);
|
||||
|
||||
// 🔧 НОВОЕ (19.09.2026): модалка урока (WYSIWYG)
|
||||
const [editingLessonId, setEditingLessonId] = useState<string | null>(null);
|
||||
const [editingTopicPath, setEditingTopicPath] = useState<{
|
||||
levelCode: string;
|
||||
topicOrder: number;
|
||||
topicName: string;
|
||||
} | null>(null);
|
||||
|
||||
const brandColor = '#2ECC71';
|
||||
const dashColor = '#E8B93F';
|
||||
const errorColor = '#E74C3C';
|
||||
const textColor = '#1A1A2E';
|
||||
const subColor = '#6B7280';
|
||||
const cardBg = 'rgba(255,255,255,0.55)';
|
||||
|
||||
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
||||
background: cardBg,
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 8px 32px rgba(31,38,73,0.10), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
const getLabel = (pl: string, ru: string, uk: string, en: string): string => {
|
||||
const labels: Record<string, string> = { pl, ru, uk, en };
|
||||
return labels[language] || labels.en;
|
||||
};
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setErrorsLoading(true);
|
||||
setErrorsError(null);
|
||||
|
||||
getAdminDashboard()
|
||||
.then((data: DashboardData) => {
|
||||
setDashboard(data);
|
||||
setLastUpdate(new Date());
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('Dashboard error:', e);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
|
||||
getSentryErrors()
|
||||
.then((data: any) => {
|
||||
setErrors(data.errors || []);
|
||||
if (data.error) {
|
||||
setErrorsError(data.error);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('Sentry errors error:', e);
|
||||
setErrorsError('Failed to load errors');
|
||||
})
|
||||
.finally(() => setErrorsLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadData();
|
||||
}
|
||||
}, [isOpen, loadData]);
|
||||
|
||||
const formatDate = (isoStr: string): string => {
|
||||
try {
|
||||
const date = new Date(isoStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
|
||||
if (diffMin < 1) return getLabel('teraz', 'только что', 'щойно', 'just now');
|
||||
if (diffMin < 60) return `${diffMin} ${getLabel('min temu', 'мин назад', 'хв тому', 'min ago')}`;
|
||||
if (diffHr < 24) return `${diffHr} ${getLabel('godz. temu', 'ч назад', 'год тому', 'h ago')}`;
|
||||
if (diffDay < 30) return `${diffDay} ${getLabel('dni temu', 'дн назад', 'дн тому', 'd ago')}`;
|
||||
|
||||
return date.toLocaleDateString();
|
||||
} catch {
|
||||
return isoStr;
|
||||
}
|
||||
};
|
||||
|
||||
const getLevelColor = (level: string): string => {
|
||||
const colors: Record<string, string> = {
|
||||
A1: '#2ECC71',
|
||||
A2: '#27AE60',
|
||||
B1: '#F39C12',
|
||||
B2: '#E67E22',
|
||||
C1: '#E74C3C',
|
||||
C2: '#C0392B',
|
||||
};
|
||||
return colors[level] || brandColor;
|
||||
};
|
||||
|
||||
const maxLevelCount = dashboard
|
||||
? Math.max(...Object.values(dashboard.level_counts), 1)
|
||||
: 1;
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(20,22,35,0.35)', zIndex: 1000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
animation: 'modalFadeIn 0.2s ease', padding: '20px',
|
||||
backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)',
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes modalFadeIn {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
@keyframes modalSlideUp {
|
||||
0% { transform: translateY(20px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes fadeInUp {
|
||||
0% { transform: translateY(8px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div
|
||||
style={{
|
||||
...glass({
|
||||
borderRadius: radius.lg,
|
||||
padding: '20px',
|
||||
width: '100%',
|
||||
maxWidth: '440px',
|
||||
maxHeight: '90vh',
|
||||
overflowY: 'auto',
|
||||
boxShadow: '0 24px 60px rgba(31,38,73,0.25), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
}),
|
||||
animation: 'modalSlideUp 0.3s ease',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Заголовок */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '16px',
|
||||
paddingBottom: '12px',
|
||||
borderBottom: '1px solid rgba(0,0,0,0.06)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '24px' }}>📊</span>
|
||||
<h3 style={{
|
||||
margin: 0,
|
||||
fontSize: '18px',
|
||||
fontWeight: 800,
|
||||
color: textColor,
|
||||
}}>
|
||||
{getLabel('Panel admina', 'Админ-панель', 'Адмін-панель', 'Admin Panel')}
|
||||
</h3>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<button
|
||||
onClick={loadData}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
...glass({ padding: '8px 12px', borderRadius: radius.sm }),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
cursor: isLoading ? 'wait' : 'pointer',
|
||||
opacity: isLoading ? 0.5 : 1,
|
||||
animation: isLoading ? 'pulse 1s ease infinite' : 'none',
|
||||
}}
|
||||
>
|
||||
🔄
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
...glass({ padding: '8px 14px', borderRadius: radius.sm }),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && !dashboard ? (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '40px 20px',
|
||||
color: subColor,
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
{getLabel('Ładowanie...', 'Загрузка...', 'Завантаження...', 'Loading...')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 🔧 НОВОЕ (19.09.2026): кнопка Контент-менеджер — сверху, зелёная */}
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<button
|
||||
onClick={() => setShowContentEditor(true)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '14px 16px',
|
||||
borderRadius: radius.md,
|
||||
border: 'none',
|
||||
background: brandColor,
|
||||
color: '#FFFFFF',
|
||||
fontSize: '14px',
|
||||
fontWeight: 800,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: `0 4px 12px ${brandColor}40`,
|
||||
}}
|
||||
>
|
||||
✏️ {getLabel('Edytor treści', 'Контент-менеджер', 'Контент-менеджер', 'Content Editor')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ============ СЕКЦИЯ 1: СТАТИСТИКА ============ */}
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 700,
|
||||
color: dashColor,
|
||||
marginBottom: '10px',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
👥 {getLabel('Statystyki', 'Статистика', 'Статистика', 'Statistics')}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '8px',
|
||||
}}>
|
||||
<div style={{
|
||||
...glass({ padding: '14px', borderRadius: radius.md }),
|
||||
border: `1px solid ${brandColor}30`,
|
||||
}}>
|
||||
<div style={{ fontSize: '11px', color: subColor, marginBottom: '4px', fontWeight: 600 }}>
|
||||
{getLabel('Wszyscy', 'Всего', 'Всього', 'Total')}
|
||||
</div>
|
||||
<div style={{ fontSize: '26px', fontWeight: 900, color: textColor, lineHeight: 1 }}>
|
||||
{dashboard?.total_users ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
...glass({ padding: '14px', borderRadius: radius.md }),
|
||||
border: `1px solid ${brandColor}30`,
|
||||
}}>
|
||||
<div style={{ fontSize: '11px', color: subColor, marginBottom: '4px', fontWeight: 600 }}>
|
||||
🆕 {getLabel('Nowi dziś', 'Новых сегодня', 'Нових сьогодні', 'New today')}
|
||||
</div>
|
||||
<div style={{ fontSize: '26px', fontWeight: 900, color: dashboard?.new_today ? brandColor : textColor, lineHeight: 1 }}>
|
||||
{dashboard?.new_today ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
...glass({ padding: '14px', borderRadius: radius.md }),
|
||||
border: `1px solid ${brandColor}30`,
|
||||
}}>
|
||||
<div style={{ fontSize: '11px', color: subColor, marginBottom: '4px', fontWeight: 600 }}>
|
||||
🟢 {getLabel('Online', 'Онлайн', 'Онлайн', 'Online')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<div style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: dashboard?.online_now ? '#2ECC71' : '#BDBDBD',
|
||||
boxShadow: dashboard?.online_now ? '0 0 8px #2ECC71' : 'none',
|
||||
}} />
|
||||
<div style={{ fontSize: '26px', fontWeight: 900, color: textColor, lineHeight: 1 }}>
|
||||
{dashboard?.online_now ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
...glass({ padding: '14px', borderRadius: radius.md }),
|
||||
border: `1px solid ${brandColor}30`,
|
||||
}}>
|
||||
<div style={{ fontSize: '11px', color: subColor, marginBottom: '4px', fontWeight: 600 }}>
|
||||
📈 {getLabel('Aktywni 24h', 'Активных 24ч', 'Активних 24г', 'Active 24h')}
|
||||
</div>
|
||||
<div style={{ fontSize: '26px', fontWeight: 900, color: textColor, lineHeight: 1 }}>
|
||||
{dashboard?.active_24h ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ============ СЕКЦИЯ 2: ПО УРОВНЯМ ============ */}
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 700,
|
||||
color: dashColor,
|
||||
marginBottom: '10px',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
🎯 {getLabel('Poziomy', 'Уровни', 'Рівні', 'Levels')}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
...glass({ padding: '14px', borderRadius: radius.md }),
|
||||
}}>
|
||||
{dashboard && Object.entries(dashboard.level_counts).map(([level, count]) => {
|
||||
const percent = count > 0 ? (count / maxLevelCount) * 100 : 0;
|
||||
return (
|
||||
<div key={level} style={{ marginBottom: '10px' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
<span style={{ color: getLevelColor(level) }}>{level}</span>
|
||||
<span style={{ color: count > 0 ? textColor : subColor }}>{count}</span>
|
||||
</div>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '6px',
|
||||
background: 'rgba(0,0,0,0.05)',
|
||||
borderRadius: '3px',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${percent}%`,
|
||||
height: '100%',
|
||||
background: getLevelColor(level),
|
||||
borderRadius: '3px',
|
||||
transition: 'width 0.5s ease',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ============ СЕКЦИЯ 3: ОШИБКИ SENTRY ============ */}
|
||||
<div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '10px',
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 700,
|
||||
color: errors.length > 0 ? errorColor : dashColor,
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
⚠️ {getLabel('Błędy', 'Ошибки', 'Помилки', 'Errors')} ({errors.length})
|
||||
</div>
|
||||
<a
|
||||
href="https://drobysh.sentry.io/issues/?project=4512063274090576"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: dashColor,
|
||||
textDecoration: 'none',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{getLabel('Otwórz Sentry', 'Открыть Sentry', 'Відкрити Sentry', 'Open Sentry')} ↗
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{errorsLoading ? (
|
||||
<div style={{
|
||||
...glass({ padding: '20px', borderRadius: radius.md }),
|
||||
textAlign: 'center',
|
||||
color: subColor,
|
||||
fontSize: '13px',
|
||||
}}>
|
||||
{getLabel('Ładowanie...', 'Загрузка...', 'Завантаження...', 'Loading...')}
|
||||
</div>
|
||||
) : errorsError ? (
|
||||
<div style={{
|
||||
...glass({ padding: '16px', borderRadius: radius.md }),
|
||||
background: `${errorColor}10`,
|
||||
border: `1px solid ${errorColor}30`,
|
||||
color: errorColor,
|
||||
fontSize: '12px',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
{errorsError}
|
||||
</div>
|
||||
) : errors.length === 0 ? (
|
||||
<div style={{
|
||||
...glass({ padding: '20px', borderRadius: radius.md }),
|
||||
background: `${brandColor}10`,
|
||||
border: `1px solid ${brandColor}30`,
|
||||
textAlign: 'center',
|
||||
color: brandColor,
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
✅ {getLabel('Brak błędów', 'Ошибок нет', 'Помилок немає', 'No errors')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{errors.slice(0, 5).map((err, index) => (
|
||||
<a
|
||||
key={err.id}
|
||||
href={err.permalink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
...glass({
|
||||
padding: '12px 14px',
|
||||
borderRadius: radius.md,
|
||||
textDecoration: 'none',
|
||||
display: 'block',
|
||||
}),
|
||||
border: `1px solid ${errorColor}30`,
|
||||
background: `${errorColor}08`,
|
||||
animation: `fadeInUp 0.3s ease ${index * 0.05}s both`,
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
gap: '8px',
|
||||
marginBottom: '4px',
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: 700,
|
||||
color: textColor,
|
||||
flex: 1,
|
||||
wordBreak: 'break-word',
|
||||
}}>
|
||||
{err.title}
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: '10px',
|
||||
padding: '2px 6px',
|
||||
background: errorColor,
|
||||
color: '#FFFFFF',
|
||||
borderRadius: '8px',
|
||||
fontWeight: 700,
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{err.count}×
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: subColor,
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
<span>🕐 {formatDate(err.last_seen)}</span>
|
||||
{err.user_count > 0 && (
|
||||
<span>👤 {err.user_count}</span>
|
||||
)}
|
||||
<span>📍 {err.culprit}</span>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Футер */}
|
||||
<div style={{
|
||||
marginTop: '16px',
|
||||
paddingTop: '12px',
|
||||
borderTop: '1px solid rgba(0,0,0,0.06)',
|
||||
textAlign: 'center',
|
||||
fontSize: '11px',
|
||||
color: subColor,
|
||||
}}>
|
||||
{getLabel('Aktualizacja', 'Обновлено', 'Оновлено', 'Updated')}: {lastUpdate.toLocaleTimeString()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 🔧 НОВОЕ (19.09.2026): модалка контент-менеджера */}
|
||||
<ContentEditorMenu
|
||||
isOpen={showContentEditor}
|
||||
onClose={() => setShowContentEditor(false)}
|
||||
onNavigate={(section) => {
|
||||
setShowContentEditor(false);
|
||||
if (section === 'words') {
|
||||
setShowWordsEditor(true);
|
||||
} else if (section === 'texts') {
|
||||
setShowTextsEditor(true);
|
||||
} else if (section === 'lessons') {
|
||||
setShowLessonsEditor(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<WordsEditor
|
||||
isOpen={showWordsEditor}
|
||||
onClose={() => setShowWordsEditor(false)}
|
||||
/>
|
||||
|
||||
<TextsEditor
|
||||
isOpen={showTextsEditor}
|
||||
onClose={() => setShowTextsEditor(false)}
|
||||
/>
|
||||
|
||||
{/* 🔧 НОВОЕ (19.09.2026): Уроки — иерархия */}
|
||||
<LessonsEditor
|
||||
isOpen={showLessonsEditor}
|
||||
onClose={() => {
|
||||
setShowLessonsEditor(false);
|
||||
setEditingLessonId(null);
|
||||
setEditingTopicPath(null);
|
||||
}}
|
||||
onOpenLesson={(lessonId, topicPath) => {
|
||||
setEditingLessonId(lessonId);
|
||||
setEditingTopicPath(topicPath);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 🔧 НОВОЕ (19.09.2026): WYSIWYG-модалка урока */}
|
||||
<LessonEditorModal
|
||||
isOpen={!!editingLessonId}
|
||||
lessonId={editingLessonId}
|
||||
topicPath={editingTopicPath}
|
||||
onClose={() => {
|
||||
setEditingLessonId(null);
|
||||
setEditingTopicPath(null);
|
||||
}}
|
||||
onSaved={() => {
|
||||
setEditingLessonId(null);
|
||||
setEditingTopicPath(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminDashboardModal;
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
|
||||
interface AnimatedCatProps {
|
||||
size?: number;
|
||||
onClick?: () => void;
|
||||
place?: 1 | 2 | 3;
|
||||
}
|
||||
|
||||
interface Particle {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
rotation: number;
|
||||
delay: number;
|
||||
duration: number;
|
||||
driftX: number;
|
||||
driftY: number;
|
||||
}
|
||||
|
||||
const AnimatedCat: React.FC<AnimatedCatProps> = ({ size = 48, onClick, place = 1 }) => {
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const [particles, setParticles] = useState<Particle[]>([]);
|
||||
const idRef = useRef(0);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setIsPressed(true);
|
||||
setTimeout(() => setIsPressed(false), 200);
|
||||
|
||||
const newParticles: Particle[] = [];
|
||||
|
||||
// Золотые звёздочки разных размеров, разлетаются во все стороны
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const angle = (Math.PI * 2 * i) / 20 + (Math.random() - 0.5) * 0.5; // Равномерно по кругу + случайность
|
||||
const distance = 30 + Math.random() * 50; // Расстояние разлёта
|
||||
|
||||
newParticles.push({
|
||||
id: idRef.current++,
|
||||
x: Math.cos(angle) * distance,
|
||||
y: Math.sin(angle) * distance,
|
||||
size: 6 + Math.random() * 14, // Разные размеры: 6-20px
|
||||
rotation: Math.random() * 360,
|
||||
delay: Math.random() * 0.2,
|
||||
duration: 0.8 + Math.random() * 0.6, // Разная скорость
|
||||
driftX: (Math.random() - 0.5) * 30,
|
||||
driftY: -10 - Math.random() * 30, // Улетают вверх
|
||||
});
|
||||
}
|
||||
|
||||
setParticles(prev => [...prev, ...newParticles]);
|
||||
setTimeout(() => {
|
||||
setParticles(prev => prev.filter(p => !newParticles.find(np => np.id === p.id)));
|
||||
}, 2000);
|
||||
|
||||
if (onClick) onClick();
|
||||
}, [onClick]);
|
||||
|
||||
const medal = place === 1 ? '🥇' : place === 2 ? '🥈' : '🥉';
|
||||
const glowColor = place === 1 ? '255,215,0' : place === 2 ? '192,192,192' : '205,127,50';
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'transform 0.2s ease',
|
||||
transform: isPressed ? 'scale(0.85)' : 'scale(1)',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes medalGlow {
|
||||
0%, 100% { filter: drop-shadow(0 0 4px rgba(${glowColor},0.5)); }
|
||||
50% { filter: drop-shadow(0 0 12px rgba(${glowColor},0.9)); }
|
||||
}
|
||||
@keyframes starBurst {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(0) rotate(0deg);
|
||||
opacity: 0;
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translate(var(--drift-x), var(--drift-y)) scale(1) rotate(var(--rotation));
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{particles.map(p => (
|
||||
<span
|
||||
key={p.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
marginLeft: p.x,
|
||||
marginTop: p.y,
|
||||
fontSize: `${p.size}px`,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10,
|
||||
animation: `starBurst ${p.duration}s ease-out ${p.delay}s forwards`,
|
||||
'--drift-x': `${p.driftX}px`,
|
||||
'--drift-y': `${p.driftY}px`,
|
||||
'--rotation': `${p.rotation}deg`,
|
||||
filter: 'drop-shadow(0 0 3px rgba(255, 215, 0, 0.8))',
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
⭐
|
||||
</span>
|
||||
))}
|
||||
|
||||
<span
|
||||
style={{
|
||||
fontSize: size * 0.75,
|
||||
animation: 'medalGlow 2s ease-in-out infinite',
|
||||
transition: 'transform 0.2s ease',
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
{medal}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnimatedCat;
|
||||
@@ -0,0 +1,258 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { radius } from '../styles/theme';
|
||||
import { getContentSummary, type ContentSummary } from '../api';
|
||||
|
||||
interface ContentEditorMenuProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onNavigate: (section: 'lessons' | 'words' | 'texts') => void;
|
||||
}
|
||||
|
||||
const ContentEditorMenu: React.FC<ContentEditorMenuProps> = ({ isOpen, onClose, onNavigate }) => {
|
||||
const [summary, setSummary] = useState<ContentSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getContentSummary()
|
||||
.then((data) => setSummary(data))
|
||||
.catch((e) => {
|
||||
console.error('[ContentEditorMenu] load failed:', e);
|
||||
setError('Не удалось загрузить сводку');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const textColor = '#1A1A2E';
|
||||
const subColor = '#6B7280';
|
||||
const cardBg = 'rgba(255,255,255,0.55)';
|
||||
|
||||
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
||||
background: cardBg,
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 8px 32px rgba(31,38,73,0.10), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(20,22,35,0.55)', zIndex: 2100,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '20px',
|
||||
backdropFilter: 'blur(8px)', WebkitBackdropFilter: 'blur(8px)',
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
...glass({
|
||||
borderRadius: radius.lg,
|
||||
padding: '20px',
|
||||
width: '100%',
|
||||
maxWidth: '480px',
|
||||
maxHeight: '90vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{/* Шапка */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '16px',
|
||||
paddingBottom: '12px',
|
||||
borderBottom: '1px solid rgba(0,0,0,0.06)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<h3 style={{ margin: 0, fontSize: '17px', fontWeight: 800, color: textColor }}>
|
||||
✏️ Контент-менеджер
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
...glass({ padding: '8px 14px', borderRadius: radius.sm }),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Контент */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
paddingBottom: '12px',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
minHeight: 0,
|
||||
}}>
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: subColor, fontSize: '14px' }}>
|
||||
Загрузка...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '10px 14px',
|
||||
background: 'rgba(231,76,60,0.08)',
|
||||
borderRadius: radius.md,
|
||||
color: '#E74C3C',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
⚠️ {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && summary && (
|
||||
<>
|
||||
{/* Три карточки разделов */}
|
||||
<button
|
||||
onClick={() => onNavigate('lessons')}
|
||||
style={{
|
||||
...glass({ padding: '16px', borderRadius: radius.md }),
|
||||
border: '1px solid rgba(139, 92, 246, 0.3)',
|
||||
background: 'rgba(139, 92, 246, 0.08)',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '28px' }}>🎓</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '15px', fontWeight: 800, color: textColor, marginBottom: '2px' }}>
|
||||
Уроки
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: subColor }}>
|
||||
Уровни → Темы → Уроки → Вопросы и примеры
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontSize: '20px', color: subColor }}>›</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onNavigate('words')}
|
||||
style={{
|
||||
...glass({ padding: '16px', borderRadius: radius.md }),
|
||||
border: '1px solid rgba(46, 204, 113, 0.3)',
|
||||
background: 'rgba(46, 204, 113, 0.08)',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '28px' }}>📝</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '15px', fontWeight: 800, color: textColor, marginBottom: '2px' }}>
|
||||
Слова
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: subColor }}>
|
||||
PL, EN, RU, UK — редактирование и добавление
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontSize: '20px', color: subColor }}>›</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onNavigate('texts')}
|
||||
style={{
|
||||
...glass({ padding: '16px', borderRadius: radius.md }),
|
||||
border: '1px solid rgba(79, 169, 232, 0.3)',
|
||||
background: 'rgba(79, 169, 232, 0.08)',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '28px' }}>📄</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '15px', fontWeight: 800, color: textColor, marginBottom: '2px' }}>
|
||||
Тексты
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: subColor }}>
|
||||
Предложения для игр «Собери предложение» и «Печать»
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontSize: '20px', color: subColor }}>›</span>
|
||||
</button>
|
||||
|
||||
{/* Сводка по уровням */}
|
||||
<div style={{
|
||||
marginTop: '8px',
|
||||
padding: '12px',
|
||||
background: 'rgba(0,0,0,0.02)',
|
||||
borderRadius: radius.md,
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 700,
|
||||
color: subColor,
|
||||
marginBottom: '8px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
}}>
|
||||
📊 Сводка по уровням
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{summary.levels.map((lvl) => {
|
||||
const isReady = lvl.topics_total > 0 && lvl.words_total > 0 && lvl.texts_total > 0;
|
||||
const icon = isReady ? '✅' : (lvl.topics_total > 0 ? '🟡' : '⚪');
|
||||
return (
|
||||
<div
|
||||
key={lvl.code}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '6px 10px',
|
||||
background: 'rgba(255,255,255,0.6)',
|
||||
borderRadius: radius.sm,
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
<span>{icon}</span>
|
||||
<span style={{ fontWeight: 800, color: textColor, minWidth: '30px' }}>
|
||||
{lvl.code}
|
||||
</span>
|
||||
<span style={{ color: subColor, flex: 1 }}>
|
||||
{lvl.topics_total} тем · {lvl.lessons_total} уроков · {lvl.words_total} слов · {lvl.texts_total} текстов
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContentEditorMenu;
|
||||
@@ -0,0 +1,955 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useLanguage } from '../i18n/LanguageContext';
|
||||
import { radius } from '../styles/theme';
|
||||
import { getFeedSection, deleteFeedPost, type FeedPostResponse } from '../api';
|
||||
import FeedPostComposer from './FeedPostComposer';
|
||||
import ImageLightbox from './ImageLightbox';
|
||||
|
||||
export type FeedSection = 'news' | 'partners' | 'contacts' | 'apps';
|
||||
|
||||
interface FeedModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
section: FeedSection;
|
||||
/** Флаг админа — для админ-строки ввода */
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
type FeedPost = FeedPostResponse;
|
||||
|
||||
const SECTION_CONFIG: Record<FeedSection, {
|
||||
icon: string;
|
||||
titleKey: string;
|
||||
accent: string;
|
||||
}> = {
|
||||
news: { icon: '📢', titleKey: 'carouselNews', accent: '#E8B93F' },
|
||||
partners: { icon: '💼', titleKey: 'carouselPartners', accent: '#2ECC71' },
|
||||
contacts: { icon: '📇', titleKey: 'carouselContacts', accent: '#8B5CF6' },
|
||||
apps: { icon: '📲', titleKey: 'carouselApps', accent: '#4FA9E8' },
|
||||
};
|
||||
|
||||
// 🔧 URL поддержки (единый аккаунт для заявок)
|
||||
const SUPPORT_URL = 'https://t.me/fwpl_support';
|
||||
|
||||
// 🔧 API base для склейки относительных URL картинок (fallback для старых постов)
|
||||
const API_BASE = 'https://api.filwordspl.com';
|
||||
|
||||
// 🔧 Внутренний отступ карточки поста (текстовый блок под картинкой)
|
||||
const POST_PADDING = '16px';
|
||||
|
||||
const FeedModal: React.FC<FeedModalProps> = ({ isOpen, onClose, section, isAdmin = false }) => {
|
||||
const { t, language } = useLanguage();
|
||||
const [posts, setPosts] = useState<FeedPost[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
// 🔧 Шаг 6: состояния админ-режима
|
||||
const [showComposer, setShowComposer] = useState(false);
|
||||
const [editingPost, setEditingPost] = useState<FeedPost | null>(null);
|
||||
const [deletingPost, setDeletingPost] = useState<FeedPost | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
// 🔧 Лайтбокс: какие картинки открыть + с какого индекса
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [lightboxImages, setLightboxImages] = useState<string[]>([]);
|
||||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||||
|
||||
const config = SECTION_CONFIG[section];
|
||||
const textColor = '#1A1A2E';
|
||||
const subColor = '#6B7280';
|
||||
const cardBg = 'rgba(255,255,255,0.55)';
|
||||
|
||||
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
||||
background: cardBg,
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 8px 32px rgba(31,38,73,0.10), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
// 🔧 Утилита: превращает относительный путь в полный URL
|
||||
const fullImageUrl = (path: string): string =>
|
||||
path.startsWith('http') ? path : `${API_BASE}${path}`;
|
||||
|
||||
const loadFeed = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
getFeedSection(section, language)
|
||||
.then((data) => {
|
||||
if (!isMountedRef.current) return;
|
||||
setPosts(data.posts || []);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!isMountedRef.current) return;
|
||||
console.error('Failed to load feed:', e);
|
||||
setError('Failed to load feed');
|
||||
setPosts([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isMountedRef.current) return;
|
||||
setLoading(false);
|
||||
});
|
||||
}, [section, language]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
isMountedRef.current = true;
|
||||
loadFeed();
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, [isOpen, loadFeed]);
|
||||
|
||||
// Скролл вверх при открытии
|
||||
useEffect(() => {
|
||||
if (isOpen && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = 0;
|
||||
}
|
||||
}, [isOpen, section]);
|
||||
|
||||
// Escape для закрытия (только если не открыт composer/lightbox/delete)
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !showComposer && !deletingPost && !lightboxOpen) onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
}, [isOpen, onClose, showComposer, deletingPost, lightboxOpen]);
|
||||
|
||||
// ============ Обработчики админа ============
|
||||
const handleCreate = () => {
|
||||
setEditingPost(null);
|
||||
setShowComposer(true);
|
||||
};
|
||||
|
||||
const handleEdit = (post: FeedPost) => {
|
||||
setEditingPost(post);
|
||||
setShowComposer(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deletingPost) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
setDeleteError(null);
|
||||
|
||||
try {
|
||||
await deleteFeedPost(deletingPost.id);
|
||||
setDeletingPost(null);
|
||||
loadFeed();
|
||||
} catch (e: any) {
|
||||
const realMessage = e?.message || String(e);
|
||||
setDeleteError(realMessage);
|
||||
console.error('[handleDeleteConfirm] FAILED', realMessage);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleComposerSuccess = () => {
|
||||
loadFeed();
|
||||
};
|
||||
|
||||
// ============ Открытие лайтбокса ============
|
||||
const openLightbox = (images: string[], startIndex: number = 0) => {
|
||||
setLightboxImages(images.map(fullImageUrl));
|
||||
setLightboxIndex(startIndex);
|
||||
setLightboxOpen(true);
|
||||
};
|
||||
|
||||
// ============ Автолинк ============
|
||||
const renderTextWithLinks = (text: string) => {
|
||||
const linkRegex = /(https?:\/\/[^\s]+)/g;
|
||||
const phoneRegex = /(\+[\d\s\-()]{7,})/g;
|
||||
|
||||
type Match = { start: number; end: number; text: string; href: string; type: 'link' | 'phone' };
|
||||
const matches: Match[] = [];
|
||||
|
||||
let m: RegExpExecArray | null;
|
||||
|
||||
const linkRegexGlobal = new RegExp(linkRegex.source, 'g');
|
||||
while ((m = linkRegexGlobal.exec(text)) !== null) {
|
||||
matches.push({
|
||||
start: m.index,
|
||||
end: m.index + m[0].length,
|
||||
text: m[0],
|
||||
href: m[0],
|
||||
type: 'link',
|
||||
});
|
||||
}
|
||||
|
||||
const phoneRegexGlobal = new RegExp(phoneRegex.source, 'g');
|
||||
while ((m = phoneRegexGlobal.exec(text)) !== null) {
|
||||
const overlaps = matches.some(
|
||||
(existing) => m!.index < existing.end && m!.index + m![0].length > existing.start
|
||||
);
|
||||
if (!overlaps) {
|
||||
matches.push({
|
||||
start: m.index,
|
||||
end: m.index + m[0].length,
|
||||
text: m[0],
|
||||
href: `tel:${m[0].replace(/[\s\-()]/g, '')}`,
|
||||
type: 'phone',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
matches.sort((a, b) => a.start - b.start);
|
||||
|
||||
const result: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
matches.forEach((match, i) => {
|
||||
if (match.start > lastIndex) {
|
||||
result.push(text.slice(lastIndex, match.start));
|
||||
}
|
||||
result.push(
|
||||
<a
|
||||
key={`${match.type}-${i}`}
|
||||
href={match.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
color: config.accent,
|
||||
textDecoration: 'underline',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{match.text}
|
||||
</a>
|
||||
);
|
||||
lastIndex = match.end;
|
||||
});
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
result.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const formatDate = (iso: string | null): string => {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
const date = new Date(iso);
|
||||
const locales: Record<string, string> = {
|
||||
pl: 'pl-PL',
|
||||
ru: 'ru-RU',
|
||||
uk: 'uk-UA',
|
||||
en: 'en-US',
|
||||
};
|
||||
return date.toLocaleDateString(locales[language] || 'en-US', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
};
|
||||
|
||||
const formatExpires = (iso: string | null | undefined): string | null => {
|
||||
if (!iso) return null;
|
||||
try {
|
||||
const date = new Date(iso);
|
||||
const locales: Record<string, string> = {
|
||||
pl: 'pl-PL',
|
||||
ru: 'ru-RU',
|
||||
uk: 'uk-UA',
|
||||
en: 'en-US',
|
||||
};
|
||||
return date.toLocaleDateString(locales[language] || 'en-US', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const showPartnersBanner = section === 'partners';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(20,22,35,0.35)', zIndex: 1000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '20px',
|
||||
backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)',
|
||||
animation: 'feedModalFadeIn 0.2s ease',
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes feedModalFadeIn {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
@keyframes feedModalSlideUp {
|
||||
0% { transform: translateY(20px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes feedPostIn {
|
||||
0% { transform: translateY(8px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
...glass({
|
||||
borderRadius: radius.lg,
|
||||
padding: '20px',
|
||||
width: '100%',
|
||||
maxWidth: '440px',
|
||||
height: '85vh',
|
||||
maxHeight: '720px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: '0 24px 60px rgba(31,38,73,0.25), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
}),
|
||||
animation: 'feedModalSlideUp 0.3s ease',
|
||||
}}
|
||||
>
|
||||
{/* ============ Шапка ============ */}
|
||||
<div style={{
|
||||
marginBottom: '16px',
|
||||
paddingBottom: '12px',
|
||||
borderBottom: '1px solid rgba(0,0,0,0.06)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '12px',
|
||||
gap: '8px',
|
||||
}}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
...glass({ padding: '8px 14px', borderRadius: radius.sm }),
|
||||
color: textColor,
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.95)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
← {t('back')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
style={{
|
||||
...glass({ padding: '8px 14px', borderRadius: radius.sm }),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.9)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
}}>
|
||||
<h3 style={{
|
||||
margin: 0,
|
||||
fontSize: '18px',
|
||||
fontWeight: 800,
|
||||
color: textColor,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{config.icon} {t(config.titleKey)}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ============ Баннер «По вопросам рекламы» (только Партнёры) ============ */}
|
||||
{showPartnersBanner && (
|
||||
<div
|
||||
onClick={() => window.open(SUPPORT_URL, '_blank')}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '14px 16px',
|
||||
borderRadius: radius.md,
|
||||
marginBottom: '12px',
|
||||
}),
|
||||
border: `1px solid ${config.accent}40`,
|
||||
background: `${config.accent}10`,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
flexShrink: 0,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.98)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
<span style={{ fontSize: '24px' }}>📩</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 700,
|
||||
color: config.accent,
|
||||
marginBottom: '2px',
|
||||
}}>
|
||||
{t('feedPartnersBannerTitle') || 'По вопросам рекламы'}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: subColor,
|
||||
}}>
|
||||
{t('feedPartnersBannerSubtitle') || 'Написать нам →'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ============ Лента постов ============ */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
paddingBottom: '8px',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '40px',
|
||||
color: subColor,
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
{t('loading')}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '40px 20px',
|
||||
color: subColor,
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '12px' }}>⚠️</div>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
{t('feedError') || 'Не удалось загрузить. Попробуйте ещё раз.'}
|
||||
</div>
|
||||
<button
|
||||
onClick={loadFeed}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '10px 20px',
|
||||
borderRadius: radius.md,
|
||||
}),
|
||||
color: textColor,
|
||||
fontSize: '13px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.96)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
↻ {t('retry') || 'Повторить'}
|
||||
</button>
|
||||
</div>
|
||||
) : posts.length === 0 ? (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '40px 20px',
|
||||
color: subColor,
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '12px' }}>
|
||||
{config.icon}
|
||||
</div>
|
||||
{t('feedEmpty') || 'Пока нет постов'}
|
||||
</div>
|
||||
) : (
|
||||
posts.map((post, index) => {
|
||||
const expiresLabel = formatExpires(post.expiresAt);
|
||||
const hasImages = post.images.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={post.id}
|
||||
style={{
|
||||
...glass({
|
||||
borderRadius: radius.md,
|
||||
// 🔧 PADDING УБРАН отсюда — он теперь на внутренних блоках,
|
||||
// чтобы картинка могла занять всю ширину карточки edge-to-edge
|
||||
padding: 0,
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
border: post.isPinned
|
||||
? `2px solid ${config.accent}50`
|
||||
: '1px solid rgba(255,255,255,0.7)',
|
||||
animation: `feedPostIn 0.3s ease ${index * 0.05}s both`,
|
||||
position: 'relative',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{/* ============ КАРТИНКА (всегда одна на превью, edge-to-edge) ============ */}
|
||||
{/* 🔧 ФИКС (18.09.2026): на посте видна ТОЛЬКО первая картинка.
|
||||
По клику открывается лайтбокс со ВСЕМИ картинками поста —
|
||||
там можно свайпать влево/вправо (ImageLightbox). */}
|
||||
{hasImages && (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openLightbox(post.images, 0);
|
||||
}}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
aspectRatio: '16 / 9',
|
||||
background: '#f0f0f5',
|
||||
cursor: 'pointer',
|
||||
borderTopLeftRadius: radius.md,
|
||||
borderTopRightRadius: radius.md,
|
||||
overflow: 'hidden',
|
||||
transition: 'transform 0.15s ease',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.99)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
{/* Всегда рендерим ТОЛЬКО первую картинку как превью */}
|
||||
<img
|
||||
src={fullImageUrl(post.images[0])}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
draggable={false}
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
display: 'block',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 🔧 Бейдж «📷 N» — если картинок больше 1.
|
||||
Показывает юзеру, что внутри ещё есть фото. */}
|
||||
{post.images.length > 1 && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '10px',
|
||||
right: '10px',
|
||||
padding: '4px 10px',
|
||||
borderRadius: '12px',
|
||||
background: 'rgba(0, 0, 0, 0.55)',
|
||||
color: '#FFFFFF',
|
||||
fontSize: '12px',
|
||||
fontWeight: 700,
|
||||
backdropFilter: 'blur(10px)',
|
||||
WebkitBackdropFilter: 'blur(10px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 2,
|
||||
}}>
|
||||
📷 {post.images.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ============ ТЕКСТОВЫЙ БЛОК (с padding) ============ */}
|
||||
<div style={{ padding: POST_PADDING, position: 'relative' }}>
|
||||
|
||||
{/* Бейдж «Закреплено» */}
|
||||
{post.isPinned && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: POST_PADDING,
|
||||
right: isAdmin ? '80px' : POST_PADDING,
|
||||
fontSize: '10px',
|
||||
fontWeight: 700,
|
||||
color: '#FFFFFF',
|
||||
background: config.accent,
|
||||
padding: '3px 8px',
|
||||
borderRadius: '8px',
|
||||
letterSpacing: '0.3px',
|
||||
zIndex: 2,
|
||||
}}>
|
||||
📌
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 🔧 Шаг 6: админ-иконки ✏️ / 🗑️ */}
|
||||
{isAdmin && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: POST_PADDING,
|
||||
right: POST_PADDING,
|
||||
display: 'flex',
|
||||
gap: '6px',
|
||||
zIndex: 2,
|
||||
}}>
|
||||
<button
|
||||
onClick={() => handleEdit(post)}
|
||||
title={t('feedAdminEdit') || 'Edit'}
|
||||
style={{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '50%',
|
||||
border: `1px solid ${config.accent}40`,
|
||||
background: `${config.accent}15`,
|
||||
color: config.accent,
|
||||
fontSize: '13px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 0,
|
||||
backdropFilter: 'blur(10px)',
|
||||
WebkitBackdropFilter: 'blur(10px)',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.9)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeletingPost(post)}
|
||||
title={t('feedAdminDelete') || 'Delete'}
|
||||
style={{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '50%',
|
||||
border: '1px solid rgba(231,76,60,0.4)',
|
||||
background: 'rgba(231,76,60,0.1)',
|
||||
color: '#E74C3C',
|
||||
fontSize: '13px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 0,
|
||||
backdropFilter: 'blur(10px)',
|
||||
WebkitBackdropFilter: 'blur(10px)',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.9)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Заголовок */}
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: 800,
|
||||
color: textColor,
|
||||
lineHeight: '1.3',
|
||||
marginBottom: '8px',
|
||||
paddingRight: isAdmin ? '80px' : (post.isPinned ? '36px' : '0'),
|
||||
}}>
|
||||
{post.title}
|
||||
</div>
|
||||
|
||||
{/* Текст поста */}
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: textColor,
|
||||
lineHeight: '1.6',
|
||||
whiteSpace: 'pre-line',
|
||||
marginBottom: '12px',
|
||||
wordBreak: 'break-word',
|
||||
}}>
|
||||
{renderTextWithLinks(post.body)}
|
||||
</div>
|
||||
|
||||
{/* Ссылка */}
|
||||
{post.linkUrl && (
|
||||
<a
|
||||
href={post.linkUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '8px 14px',
|
||||
borderRadius: radius.sm,
|
||||
background: `${config.accent}15`,
|
||||
border: `1px solid ${config.accent}40`,
|
||||
color: config.accent,
|
||||
fontSize: '13px',
|
||||
fontWeight: 700,
|
||||
textDecoration: 'none',
|
||||
marginBottom: '8px',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.96)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
🔗 {t('feedOpenLink') || 'Открыть ссылку'}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Telegram-ссылка */}
|
||||
{post.telegramUrl && (
|
||||
<a
|
||||
href={post.telegramUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '8px 14px',
|
||||
borderRadius: radius.sm,
|
||||
background: `#0088cc15`,
|
||||
border: `1px solid #0088cc40`,
|
||||
color: '#0088cc',
|
||||
fontSize: '13px',
|
||||
fontWeight: 700,
|
||||
textDecoration: 'none',
|
||||
marginBottom: '8px',
|
||||
marginLeft: post.linkUrl ? '8px' : '0',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.96)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
✈️ Telegram
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Футер: дата + таймер */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '8px',
|
||||
fontSize: '11px',
|
||||
color: subColor,
|
||||
marginTop: '4px',
|
||||
}}>
|
||||
<span>🕐 {formatDate(post.createdAt)}</span>
|
||||
{expiresLabel && (
|
||||
<span style={{
|
||||
color: '#E74C3C',
|
||||
fontWeight: 700,
|
||||
}}>
|
||||
⏰ до {expiresLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ============ Админ-кнопка «Написать пост» ============ */}
|
||||
{isAdmin && (
|
||||
<div style={{
|
||||
paddingTop: '12px',
|
||||
marginTop: '4px',
|
||||
borderTop: '1px solid rgba(0,0,0,0.06)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
borderRadius: radius.md,
|
||||
border: `2px dashed ${config.accent}80`,
|
||||
background: `${config.accent}22`,
|
||||
color: '#1A1A2E',
|
||||
fontSize: '14px',
|
||||
fontWeight: 800,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
transition: 'all 0.2s ease',
|
||||
textShadow: '0 1px 0 rgba(255,255,255,0.6)',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.97)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
➕ {t('feedAdminSend') || 'Написать пост'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ============ Composer (создание / редактирование) ============ */}
|
||||
<FeedPostComposer
|
||||
isOpen={showComposer}
|
||||
onClose={() => {
|
||||
setShowComposer(false);
|
||||
setEditingPost(null);
|
||||
}}
|
||||
onSuccess={handleComposerSuccess}
|
||||
section={section}
|
||||
editingPost={editingPost}
|
||||
/>
|
||||
|
||||
{/* ============ 🔧 Лайтбокс для просмотра картинок ============ */}
|
||||
<ImageLightbox
|
||||
isOpen={lightboxOpen}
|
||||
images={lightboxImages}
|
||||
initialIndex={lightboxIndex}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
|
||||
{/* ============ Модалка подтверждения удаления ============ */}
|
||||
{deletingPost && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(20,22,35,0.55)', zIndex: 2000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '20px',
|
||||
backdropFilter: 'blur(8px)', WebkitBackdropFilter: 'blur(8px)',
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget && !isDeleting) setDeletingPost(null); }}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
...glass({
|
||||
borderRadius: radius.lg,
|
||||
padding: '24px',
|
||||
width: '100%',
|
||||
maxWidth: '360px',
|
||||
textAlign: 'center',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '48px', marginBottom: '12px' }}>🗑️</div>
|
||||
<h3 style={{
|
||||
margin: '0 0 8px 0',
|
||||
fontSize: '17px',
|
||||
fontWeight: 800,
|
||||
color: textColor,
|
||||
}}>
|
||||
{t('feedAdminDeleteConfirm') || 'Удалить этот пост?'}
|
||||
</h3>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
color: subColor,
|
||||
marginBottom: '20px',
|
||||
padding: '8px 12px',
|
||||
background: 'rgba(0,0,0,0.03)',
|
||||
borderRadius: radius.sm,
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
{deletingPost.title}
|
||||
</div>
|
||||
|
||||
{deleteError && (
|
||||
<div style={{
|
||||
padding: '8px 12px',
|
||||
background: 'rgba(231,76,60,0.08)',
|
||||
borderRadius: radius.sm,
|
||||
color: '#E74C3C',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
marginBottom: '12px',
|
||||
wordBreak: 'break-word',
|
||||
}}>
|
||||
⚠️ {deleteError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={() => setDeletingPost(null)}
|
||||
disabled={isDeleting}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
borderRadius: radius.md,
|
||||
border: '1px solid rgba(0,0,0,0.08)',
|
||||
background: 'rgba(255,255,255,0.8)',
|
||||
color: textColor,
|
||||
fontSize: '13px',
|
||||
fontWeight: 700,
|
||||
cursor: isDeleting ? 'default' : 'pointer',
|
||||
opacity: isDeleting ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{t('feedAdminDeleteNo') || 'Нет'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={isDeleting}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
borderRadius: radius.md,
|
||||
border: 'none',
|
||||
background: isDeleting ? 'rgba(231,76,60,0.4)' : '#E74C3C',
|
||||
color: '#FFFFFF',
|
||||
fontSize: '13px',
|
||||
fontWeight: 700,
|
||||
cursor: isDeleting ? 'default' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{isDeleting
|
||||
? '...'
|
||||
: (t('feedAdminDeleteNo') ? (t('feedAdminDeleteYes') || 'Да, удалить') : 'Да, удалить')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeedModal;
|
||||
@@ -0,0 +1,264 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useLanguage } from '../i18n/LanguageContext';
|
||||
import { radius } from '../styles/theme';
|
||||
|
||||
interface SlideConfig {
|
||||
id: 'partners' | 'contacts' | 'apps' | 'news';
|
||||
image: string;
|
||||
titleKey: string;
|
||||
accent: string;
|
||||
}
|
||||
|
||||
// 🔧 ВАРИАНТ A: версионирование картинок для сброса кэша.
|
||||
// Когда меняешь SVG — бампай версию (v=2 → v=3 и т.д.)
|
||||
// 🔧 ОБНОВЛЕНО (18.09.2026): порядок слайдов изменён —
|
||||
// Новости → Партнёры → Контакты → Приложения.
|
||||
// SVG-файлы НЕ переименовываем — они привязаны к разделам, не к порядку.
|
||||
const SLIDES: SlideConfig[] = [
|
||||
{
|
||||
id: 'news',
|
||||
image: '/carousel/04-news@2x.png?v=3',
|
||||
titleKey: 'carouselNews',
|
||||
accent: '#E8B93F',
|
||||
},
|
||||
{
|
||||
id: 'partners',
|
||||
image: '/carousel/01-partners@2x.png?v=3',
|
||||
titleKey: 'carouselPartners',
|
||||
accent: '#2ECC71',
|
||||
},
|
||||
{
|
||||
id: 'contacts',
|
||||
image: '/carousel/02-contacts@2x.png?v=3',
|
||||
titleKey: 'carouselContacts',
|
||||
accent: '#8B5CF6',
|
||||
},
|
||||
{
|
||||
id: 'apps',
|
||||
image: '/carousel/03-apps@2x.png?v=3',
|
||||
titleKey: 'carouselApps',
|
||||
accent: '#4FA9E8',
|
||||
},
|
||||
];
|
||||
|
||||
// 🔧 Ширина слайда в % от контейнера (для расчёта спейсеров и центрирования)
|
||||
const SLIDE_WIDTH_PERCENT = 88;
|
||||
// 🔧 Спейсеры по бокам: (100 - 88) / 2 = 6% с каждой стороны.
|
||||
// Компенсируем gap (10px) — 5px с каждой стороны, чтобы формула сходилась.
|
||||
const SPACER_FLEX = `calc(${(100 - SLIDE_WIDTH_PERCENT) / 2}% - 5px)`;
|
||||
|
||||
interface HighlightsCarouselProps {
|
||||
onSlideClick: (slideId: SlideConfig['id']) => void;
|
||||
}
|
||||
|
||||
const HighlightsCarousel: React.FC<HighlightsCarouselProps> = ({ onSlideClick }) => {
|
||||
const { t } = useLanguage();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [pressingId, setPressingId] = useState<string | null>(null);
|
||||
|
||||
// ============ Следим за скроллом — обновляем активную точку МГНОВЕННО ============
|
||||
const handleScroll = useCallback(() => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const slideWidth = container.clientWidth;
|
||||
if (slideWidth === 0) return;
|
||||
|
||||
const scrollLeft = container.scrollLeft;
|
||||
const spacerPx = (slideWidth * (100 - SLIDE_WIDTH_PERCENT)) / 200 - 5;
|
||||
const slideWidthPx = (slideWidth * SLIDE_WIDTH_PERCENT) / 100;
|
||||
const gapPx = 10;
|
||||
|
||||
// Ищем ближайший слайд
|
||||
let newIndex = 0;
|
||||
let minDistance = Infinity;
|
||||
for (let i = 0; i < SLIDES.length; i++) {
|
||||
const slideCenter = spacerPx + i * (slideWidthPx + gapPx) + slideWidthPx / 2;
|
||||
const viewportCenter = scrollLeft + slideWidth / 2;
|
||||
const distance = Math.abs(slideCenter - viewportCenter);
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
newIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (newIndex !== activeIndex && newIndex >= 0 && newIndex < SLIDES.length) {
|
||||
setActiveIndex(newIndex);
|
||||
}
|
||||
}, [activeIndex]);
|
||||
|
||||
// ============ Клик по слайду — открываем модалку ============
|
||||
const handleSlideClick = useCallback((slideId: SlideConfig['id']) => {
|
||||
if (window.Telegram?.WebApp?.HapticFeedback) {
|
||||
window.Telegram.WebApp.HapticFeedback.impactOccurred('light');
|
||||
}
|
||||
onSlideClick(slideId);
|
||||
}, [onSlideClick]);
|
||||
|
||||
// ============ Очистка при размонтировании ============
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setPressingId(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: '20px', position: 'relative' }}>
|
||||
<style>{`
|
||||
@keyframes carouselFadeIn {
|
||||
0% { transform: translateY(8px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* ============ Обёртка со скролл-контейнером ============ */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
style={{
|
||||
display: 'flex',
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden',
|
||||
scrollSnapType: 'x mandatory',
|
||||
scrollPaddingInline: `${(100 - SLIDE_WIDTH_PERCENT) / 2}%`,
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
gap: '10px',
|
||||
paddingBottom: '4px',
|
||||
animation: 'carouselFadeIn 0.4s ease',
|
||||
}}
|
||||
>
|
||||
{/* 🔧 Спейсер слева — чтобы первый слайд встал по центру */}
|
||||
<div style={{ flex: `0 0 ${SPACER_FLEX}`, pointerEvents: 'none' }} aria-hidden="true" />
|
||||
|
||||
{SLIDES.map((slide) => {
|
||||
const isPressing = pressingId === slide.id;
|
||||
return (
|
||||
<div
|
||||
key={slide.id}
|
||||
onClick={() => handleSlideClick(slide.id)}
|
||||
onTouchStart={() => setPressingId(slide.id)}
|
||||
onTouchEnd={() => setPressingId(null)}
|
||||
onTouchCancel={() => setPressingId(null)}
|
||||
style={{
|
||||
flex: `0 0 ${SLIDE_WIDTH_PERCENT}%`,
|
||||
aspectRatio: '16 / 9',
|
||||
scrollSnapAlign: 'center',
|
||||
borderRadius: radius.lg,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
transition: 'transform 0.2s ease',
|
||||
transform: isPressing ? 'scale(0.98)' : 'scale(1)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
background: '#FFFFFF',
|
||||
}}
|
||||
>
|
||||
{/* ============ Картинка ============ */}
|
||||
<img
|
||||
src={slide.image}
|
||||
alt=""
|
||||
draggable={false}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
display: 'block',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ============ Заголовок — по центру по горизонтали, опущен ниже центра ============ */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingTop: '22%',
|
||||
paddingBottom: '4%',
|
||||
paddingLeft: '16px',
|
||||
paddingRight: '16px',
|
||||
pointerEvents: 'none',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.55)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.7)',
|
||||
boxShadow: '0 6px 20px rgba(31, 38, 73, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.85)',
|
||||
borderRadius: '999px',
|
||||
padding: '6px 16px',
|
||||
maxWidth: '85%',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: 800,
|
||||
letterSpacing: '-0.3px',
|
||||
color: '#1A1A2E',
|
||||
lineHeight: '1.2',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{t(slide.titleKey)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 🔧 Спейсер справа — симметрично левому, чтобы последний слайд тоже центрировался */}
|
||||
<div style={{ flex: `0 0 ${SPACER_FLEX}`, pointerEvents: 'none' }} aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
{/* ============ Точки-индикаторы ============ */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginTop: '12px',
|
||||
}}
|
||||
>
|
||||
{SLIDES.map((slide, index) => {
|
||||
const isActive = index === activeIndex;
|
||||
return (
|
||||
<div
|
||||
key={slide.id}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: isActive ? '24px' : '8px',
|
||||
height: '8px',
|
||||
borderRadius: '4px',
|
||||
background: isActive ? slide.accent : 'rgba(0, 0, 0, 0.15)',
|
||||
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: isActive ? `0 0 8px ${slide.accent}60` : 'none',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HighlightsCarousel;
|
||||
@@ -0,0 +1,197 @@
|
||||
import { colors, radius, shadows } from '../styles/theme';
|
||||
|
||||
interface HintPanelProps {
|
||||
onFirstLetter: () => void;
|
||||
onShowWords: () => void;
|
||||
showWordList: boolean;
|
||||
firstLetterHintsUsed: number;
|
||||
maxFirstLetterHints: number;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
const HintPanel: React.FC<HintPanelProps> = ({
|
||||
onFirstLetter,
|
||||
onShowWords,
|
||||
showWordList,
|
||||
firstLetterHintsUsed,
|
||||
maxFirstLetterHints,
|
||||
t,
|
||||
}) => {
|
||||
const remainingHints = maxFirstLetterHints - firstLetterHintsUsed;
|
||||
const hasFirstLetterHints = remainingHints > 0;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
justifyContent: 'center',
|
||||
padding: '0 8px',
|
||||
}}>
|
||||
<style>{`
|
||||
@keyframes hintDotActive {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.4); opacity: 0.7; }
|
||||
}
|
||||
@keyframes hintDotFadeOut {
|
||||
0% { transform: scale(1); opacity: 1; }
|
||||
100% { transform: scale(0); opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Кнопка "Первая буква" с индикаторами-огоньками */}
|
||||
<button
|
||||
onClick={onFirstLetter}
|
||||
disabled={!hasFirstLetterHints}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '14px 12px',
|
||||
borderRadius: radius.md,
|
||||
border: `1px solid ${hasFirstLetterHints ? colors.border : colors.border}`,
|
||||
background: hasFirstLetterHints ? colors.card : 'rgba(0,0,0,0.03)',
|
||||
color: hasFirstLetterHints ? colors.text : colors.textSecondary,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: hasFirstLetterHints ? 'pointer' : 'default',
|
||||
transition: 'all 0.3s ease',
|
||||
opacity: hasFirstLetterHints ? 1 : 0.5,
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
if (hasFirstLetterHints) {
|
||||
e.currentTarget.style.transform = 'scale(0.95)';
|
||||
e.currentTarget.style.boxShadow = shadows.glow;
|
||||
}
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span>💡</span>
|
||||
<span>{t('firstLetter')}</span>
|
||||
</span>
|
||||
|
||||
{/* Индикаторы-огоньки */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '6px',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '12px',
|
||||
}}>
|
||||
{Array.from({ length: maxFirstLetterHints }, (_, i) => {
|
||||
const isActive = i < remainingHints;
|
||||
const isJustUsed = i === remainingHints && firstLetterHintsUsed > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
borderRadius: '50%',
|
||||
background: isActive
|
||||
? `radial-gradient(circle at 30% 30%, ${colors.success}, #1a7a3a)`
|
||||
: 'rgba(0,0,0,0.1)',
|
||||
border: isActive
|
||||
? `1px solid ${colors.success}`
|
||||
: '1px solid rgba(0,0,0,0.08)',
|
||||
boxShadow: isActive
|
||||
? `0 0 6px ${colors.success}80, inset 0 1px 0 rgba(255,255,255,0.5)`
|
||||
: 'inset 0 1px 2px rgba(0,0,0,0.1)',
|
||||
animation: isActive
|
||||
? 'hintDotActive 2s ease infinite'
|
||||
: isJustUsed
|
||||
? 'hintDotFadeOut 0.4s ease forwards'
|
||||
: 'none',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Подпись с количеством */}
|
||||
<div style={{
|
||||
fontSize: '10px',
|
||||
color: hasFirstLetterHints ? colors.textSecondary : colors.danger,
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
{hasFirstLetterHints
|
||||
? `${remainingHints} ${t('remaining') || 'left'}`
|
||||
: t('noMoreHints') || 'Used up'}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Кнопка "Показать слова" — безлимитная */}
|
||||
<button
|
||||
onClick={onShowWords}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '14px 12px',
|
||||
borderRadius: radius.md,
|
||||
border: `1px solid ${showWordList ? colors.accent : colors.border}`,
|
||||
background: showWordList ? colors.cellSelected : colors.card,
|
||||
color: showWordList ? colors.accent : colors.text,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(0.95)';
|
||||
e.currentTarget.style.boxShadow = shadows.glow;
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span>📖</span>
|
||||
<span>{t('showWords')}</span>
|
||||
<span style={{ fontSize: '10px', marginLeft: '2px' }}>{showWordList ? '▲' : '▼'}</span>
|
||||
</span>
|
||||
|
||||
{/* Индикатор "безлимитно" */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '4px',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '12px',
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: '18px',
|
||||
color: showWordList ? colors.accent : colors.success,
|
||||
lineHeight: '1',
|
||||
animation: showWordList ? 'none' : 'hintDotActive 2s ease infinite',
|
||||
}}>
|
||||
∞
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Подпись */}
|
||||
<div style={{
|
||||
fontSize: '10px',
|
||||
color: showWordList ? colors.accent : colors.success,
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
{showWordList ? t('tapToClose') || 'Tap to close' : t('freeToUse') || 'Free to use'}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HintPanel;
|
||||
@@ -0,0 +1,343 @@
|
||||
// frontend/src/components/ImageLightbox.tsx
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { radius } from '../styles/theme';
|
||||
|
||||
interface ImageLightboxProps {
|
||||
isOpen: boolean;
|
||||
images: string[];
|
||||
initialIndex?: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ImageLightbox: React.FC<ImageLightboxProps> = ({
|
||||
isOpen,
|
||||
images,
|
||||
initialIndex = 0,
|
||||
onClose,
|
||||
}) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const [offsetX, setOffsetX] = useState(0);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
const touchStartX = useRef(0);
|
||||
const touchStartY = useRef(0);
|
||||
const isHorizontal = useRef<boolean | null>(null);
|
||||
|
||||
// Сброс индекса при открытии
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setCurrentIndex(initialIndex);
|
||||
setOffsetX(0);
|
||||
setIsAnimating(false);
|
||||
}
|
||||
}, [isOpen, initialIndex]);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
setCurrentIndex(prev => (prev < images.length - 1 ? prev + 1 : prev));
|
||||
}, [images.length]);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
setCurrentIndex(prev => (prev > 0 ? prev - 1 : prev));
|
||||
}, []);
|
||||
|
||||
// Escape + стрелки
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'ArrowLeft') goPrev();
|
||||
if (e.key === 'ArrowRight') goNext();
|
||||
};
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
}, [isOpen, onClose, goPrev, goNext]);
|
||||
|
||||
// Блокируем скролл body
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const original = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = original;
|
||||
};
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// ============ Свайп: следим за пальцем ============
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
if (isAnimating) return;
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
touchStartY.current = e.touches[0].clientY;
|
||||
isHorizontal.current = null;
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
if (isAnimating) return;
|
||||
|
||||
const dx = e.touches[0].clientX - touchStartX.current;
|
||||
const dy = e.touches[0].clientY - touchStartY.current;
|
||||
|
||||
// Определяем ось на первом значимом движении
|
||||
if (isHorizontal.current === null) {
|
||||
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) {
|
||||
isHorizontal.current = Math.abs(dx) > Math.abs(dy);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Если жест горизонтальный — следим за пальцем
|
||||
if (isHorizontal.current) {
|
||||
let newOffset = dx;
|
||||
|
||||
// Сопротивление на краях (первая/последняя картинка)
|
||||
if (currentIndex === 0 && dx > 0) {
|
||||
newOffset = dx * 0.3;
|
||||
}
|
||||
if (currentIndex === images.length - 1 && dx < 0) {
|
||||
newOffset = dx * 0.3;
|
||||
}
|
||||
|
||||
setOffsetX(newOffset);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (isAnimating) return;
|
||||
|
||||
// Порог срабатывания — 25% ширины экрана, но не меньше 80px
|
||||
const threshold = Math.max(80, window.innerWidth * 0.25);
|
||||
|
||||
if (offsetX > threshold) {
|
||||
// Свайп вправо → предыдущая
|
||||
if (currentIndex > 0) {
|
||||
setIsAnimating(true);
|
||||
setOffsetX(window.innerWidth); // улетаем вправо
|
||||
setTimeout(() => {
|
||||
goPrev();
|
||||
setOffsetX(0);
|
||||
setIsAnimating(false);
|
||||
}, 200);
|
||||
} else {
|
||||
// Отскок назад
|
||||
setIsAnimating(true);
|
||||
setOffsetX(0);
|
||||
setTimeout(() => setIsAnimating(false), 250);
|
||||
}
|
||||
} else if (offsetX < -threshold) {
|
||||
// Свайп влево → следующая
|
||||
if (currentIndex < images.length - 1) {
|
||||
setIsAnimating(true);
|
||||
setOffsetX(-window.innerWidth); // улетаем влево
|
||||
setTimeout(() => {
|
||||
goNext();
|
||||
setOffsetX(0);
|
||||
setIsAnimating(false);
|
||||
}, 200);
|
||||
} else {
|
||||
// Отскок назад
|
||||
setIsAnimating(true);
|
||||
setOffsetX(0);
|
||||
setTimeout(() => setIsAnimating(false), 250);
|
||||
}
|
||||
} else {
|
||||
// Не дотянули — плавно возвращаем на место
|
||||
setIsAnimating(true);
|
||||
setOffsetX(0);
|
||||
setTimeout(() => setIsAnimating(false), 250);
|
||||
}
|
||||
|
||||
isHorizontal.current = null;
|
||||
};
|
||||
|
||||
if (!isOpen || images.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.95)',
|
||||
zIndex: 5000,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
touchAction: 'pan-y',
|
||||
overflow: 'hidden',
|
||||
animation: 'lightboxFadeIn 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes lightboxFadeIn {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
@keyframes imageSlideIn {
|
||||
0% { transform: scale(0.95); opacity: 0; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Кнопка ✕ */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(20px + env(safe-area-inset-top, 0px))',
|
||||
right: '20px',
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
background: 'rgba(255,255,255,0.15)',
|
||||
color: '#FFFFFF',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 10,
|
||||
backdropFilter: 'blur(10px)',
|
||||
WebkitBackdropFilter: 'blur(10px)',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
{/* Счётчик */}
|
||||
{images.length > 1 && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(20px + env(safe-area-inset-top, 0px))',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
padding: '6px 14px',
|
||||
borderRadius: '20px',
|
||||
background: 'rgba(255,255,255,0.15)',
|
||||
color: '#FFFFFF',
|
||||
fontSize: '13px',
|
||||
fontWeight: 700,
|
||||
backdropFilter: 'blur(10px)',
|
||||
WebkitBackdropFilter: 'blur(10px)',
|
||||
zIndex: 10,
|
||||
}}>
|
||||
{currentIndex + 1} / {images.length}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Стрелка ← */}
|
||||
{images.length > 1 && currentIndex > 0 && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); goPrev(); }}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '16px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
background: 'rgba(255,255,255,0.15)',
|
||||
color: '#FFFFFF',
|
||||
fontSize: '22px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 10,
|
||||
backdropFilter: 'blur(10px)',
|
||||
WebkitBackdropFilter: 'blur(10px)',
|
||||
}}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Картинка — плавно следует за пальцем */}
|
||||
<img
|
||||
key={currentIndex}
|
||||
src={images[currentIndex]}
|
||||
alt=""
|
||||
draggable={false}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
maxWidth: '95vw',
|
||||
maxHeight: '85vh',
|
||||
objectFit: 'contain',
|
||||
borderRadius: radius.sm,
|
||||
transform: `translateX(${offsetX}px)`,
|
||||
transition: isAnimating ? 'transform 0.2s ease-out' : 'none',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
pointerEvents: 'auto',
|
||||
animation: offsetX === 0 && !isAnimating ? 'imageSlideIn 0.25s ease' : 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Стрелка → */}
|
||||
{images.length > 1 && currentIndex < images.length - 1 && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); goNext(); }}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '16px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
background: 'rgba(255,255,255,0.15)',
|
||||
color: '#FFFFFF',
|
||||
fontSize: '22px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 10,
|
||||
backdropFilter: 'blur(10px)',
|
||||
WebkitBackdropFilter: 'blur(10px)',
|
||||
}}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Точки-индикаторы */}
|
||||
{images.length > 1 && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: 'calc(30px + env(safe-area-inset-bottom, 0px))',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
zIndex: 10,
|
||||
}}>
|
||||
{images.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
onClick={(e) => { e.stopPropagation(); setCurrentIndex(i); }}
|
||||
style={{
|
||||
width: i === currentIndex ? '24px' : '8px',
|
||||
height: '8px',
|
||||
borderRadius: '4px',
|
||||
background: i === currentIndex ? '#FFFFFF' : 'rgba(255,255,255,0.4)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.25s ease',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageLightbox;
|
||||
@@ -0,0 +1,952 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { radius } from '../styles/theme';
|
||||
import {
|
||||
getLessonFull,
|
||||
updateLessonFull,
|
||||
getWrongBecause,
|
||||
type WrongBecauseItem,
|
||||
} from '../api';
|
||||
|
||||
interface LessonEditorModalProps {
|
||||
isOpen: boolean;
|
||||
lessonId: string | null;
|
||||
topicPath: {
|
||||
levelCode: string;
|
||||
topicOrder: number;
|
||||
topicName: string;
|
||||
} | null;
|
||||
onClose: () => void;
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
interface EditableExample {
|
||||
key: string;
|
||||
text_pl: string;
|
||||
text_ru: string;
|
||||
text_uk: string;
|
||||
text_en: string;
|
||||
}
|
||||
|
||||
interface EditableOption {
|
||||
key: string;
|
||||
text_pl: string;
|
||||
text_ru: string;
|
||||
text_uk: string;
|
||||
text_en: string;
|
||||
is_correct: boolean;
|
||||
wrong_because: string | null;
|
||||
}
|
||||
|
||||
interface EditableQuestion {
|
||||
key: string;
|
||||
question_id: string;
|
||||
prompt_pl: string;
|
||||
prompt_ru: string;
|
||||
prompt_uk: string;
|
||||
prompt_en: string;
|
||||
hint_type: string | null;
|
||||
hint_value: string | null;
|
||||
options: EditableOption[];
|
||||
}
|
||||
|
||||
type TabType = 'explanation' | 'examples' | 'questions';
|
||||
|
||||
let _keyCounter = 0;
|
||||
const genKey = (): string => `k_${Date.now()}_${_keyCounter++}`;
|
||||
|
||||
const LessonEditorModal: React.FC<LessonEditorModalProps> = ({
|
||||
isOpen,
|
||||
lessonId,
|
||||
topicPath,
|
||||
onClose,
|
||||
onSaved,
|
||||
}) => {
|
||||
// ============ Состояние ============
|
||||
const [activeTab, setActiveTab] = useState<TabType>('explanation');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Основные поля
|
||||
const [titlePl, setTitlePl] = useState('');
|
||||
const [titleEn, setTitleEn] = useState('');
|
||||
const [titleRu, setTitleRu] = useState('');
|
||||
const [titleUk, setTitleUk] = useState('');
|
||||
|
||||
const [objectivePl, setObjectivePl] = useState('');
|
||||
const [objectiveEn, setObjectiveEn] = useState('');
|
||||
const [objectiveRu, setObjectiveRu] = useState('');
|
||||
const [objectiveUk, setObjectiveUk] = useState('');
|
||||
|
||||
const [explanationPl, setExplanationPl] = useState('');
|
||||
const [explanationEn, setExplanationEn] = useState('');
|
||||
const [explanationRu, setExplanationRu] = useState('');
|
||||
const [explanationUk, setExplanationUk] = useState('');
|
||||
|
||||
// Примеры
|
||||
const [examples, setExamples] = useState<EditableExample[]>([]);
|
||||
|
||||
// Вопросы
|
||||
const [questions, setQuestions] = useState<EditableQuestion[]>([]);
|
||||
|
||||
// Справочник wrong_because
|
||||
const [wrongBecauseList, setWrongBecauseList] = useState<WrongBecauseItem[]>([]);
|
||||
|
||||
// Метаданные урока (для пути)
|
||||
const [lessonMeta, setLessonMeta] = useState<{
|
||||
order_number: number;
|
||||
title_ru: string;
|
||||
} | null>(null);
|
||||
|
||||
// ============ Загрузка при открытии ============
|
||||
useEffect(() => {
|
||||
if (!isOpen || !lessonId) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setActiveTab('explanation');
|
||||
|
||||
Promise.all([
|
||||
getLessonFull(lessonId),
|
||||
getWrongBecause().catch(() => ({ items: [] })),
|
||||
])
|
||||
.then(([lessonData, wbData]) => {
|
||||
const l = lessonData.lesson;
|
||||
|
||||
setTitlePl(l.title_pl || '');
|
||||
setTitleEn(l.title_en || '');
|
||||
setTitleRu(l.title_ru || '');
|
||||
setTitleUk(l.title_uk || '');
|
||||
|
||||
setObjectivePl(l.objective_pl || '');
|
||||
setObjectiveEn(l.objective_en || '');
|
||||
setObjectiveRu(l.objective_ru || '');
|
||||
setObjectiveUk(l.objective_uk || '');
|
||||
|
||||
setExplanationPl(l.explanation_pl || '');
|
||||
setExplanationEn(l.explanation_en || '');
|
||||
setExplanationRu(l.explanation_ru || '');
|
||||
setExplanationUk(l.explanation_uk || '');
|
||||
|
||||
setLessonMeta({
|
||||
order_number: l.order_number,
|
||||
title_ru: l.title_ru || '',
|
||||
});
|
||||
|
||||
// Примеры
|
||||
const exs: EditableExample[] = (lessonData.examples || []).map((e: any) => ({
|
||||
key: genKey(),
|
||||
text_pl: e.text_pl || '',
|
||||
text_ru: e.text_ru || '',
|
||||
text_uk: e.text_uk || '',
|
||||
text_en: e.text_en || '',
|
||||
}));
|
||||
setExamples(exs);
|
||||
|
||||
// Вопросы
|
||||
const qs: EditableQuestion[] = (lessonData.questions || []).map((q: any) => ({
|
||||
key: genKey(),
|
||||
question_id: q.question_id,
|
||||
prompt_pl: q.prompt_pl || '',
|
||||
prompt_ru: q.prompt_ru || '',
|
||||
prompt_uk: q.prompt_uk || '',
|
||||
prompt_en: q.prompt_en || '',
|
||||
hint_type: q.hint_type || null,
|
||||
hint_value: q.hint_value || null,
|
||||
options: (q.options || []).map((o: any) => ({
|
||||
key: genKey(),
|
||||
text_pl: o.text_pl || '',
|
||||
text_ru: o.text_ru || '',
|
||||
text_uk: o.text_uk || '',
|
||||
text_en: o.text_en || '',
|
||||
is_correct: !!o.is_correct,
|
||||
wrong_because: o.wrong_because || null,
|
||||
})),
|
||||
}));
|
||||
setQuestions(qs);
|
||||
|
||||
setWrongBecauseList(wbData.items || []);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('[LessonEditorModal] load failed:', e);
|
||||
setError('Не удалось загрузить урок');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [isOpen, lessonId]);
|
||||
|
||||
// ============ Примеры: add/remove ============
|
||||
const addExample = () => {
|
||||
setExamples(prev => [
|
||||
...prev,
|
||||
{ key: genKey(), text_pl: '', text_ru: '', text_uk: '', text_en: '' },
|
||||
]);
|
||||
};
|
||||
|
||||
const removeExample = (key: string) => {
|
||||
setExamples(prev => prev.filter(e => e.key !== key));
|
||||
};
|
||||
|
||||
const updateExample = (key: string, field: keyof EditableExample, value: string) => {
|
||||
setExamples(prev =>
|
||||
prev.map(e => (e.key === key ? { ...e, [field]: value } : e))
|
||||
);
|
||||
};
|
||||
|
||||
// ============ Вопросы: helpers ============
|
||||
const addOption = (questionKey: string) => {
|
||||
setQuestions(prev =>
|
||||
prev.map(q => {
|
||||
if (q.key !== questionKey) return q;
|
||||
return {
|
||||
...q,
|
||||
options: [
|
||||
...q.options,
|
||||
{
|
||||
key: genKey(),
|
||||
text_pl: '',
|
||||
text_ru: '',
|
||||
text_uk: '',
|
||||
text_en: '',
|
||||
is_correct: false,
|
||||
wrong_because: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const removeOption = (questionKey: string, optionKey: string) => {
|
||||
setQuestions(prev =>
|
||||
prev.map(q => {
|
||||
if (q.key !== questionKey) return q;
|
||||
return { ...q, options: q.options.filter(o => o.key !== optionKey) };
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const moveOptionUp = (questionKey: string, optionKey: string) => {
|
||||
setQuestions(prev =>
|
||||
prev.map(q => {
|
||||
if (q.key !== questionKey) return q;
|
||||
const idx = q.options.findIndex(o => o.key === optionKey);
|
||||
if (idx <= 0) return q;
|
||||
const next = [...q.options];
|
||||
[next[idx - 1], next[idx]] = [next[idx], next[idx - 1]];
|
||||
return { ...q, options: next };
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const moveOptionDown = (questionKey: string, optionKey: string) => {
|
||||
setQuestions(prev =>
|
||||
prev.map(q => {
|
||||
if (q.key !== questionKey) return q;
|
||||
const idx = q.options.findIndex(o => o.key === optionKey);
|
||||
if (idx === -1 || idx >= q.options.length - 1) return q;
|
||||
const next = [...q.options];
|
||||
[next[idx], next[idx + 1]] = [next[idx + 1], next[idx]];
|
||||
return { ...q, options: next };
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const setCorrectOption = (questionKey: string, optionKey: string) => {
|
||||
setQuestions(prev =>
|
||||
prev.map(q => {
|
||||
if (q.key !== questionKey) return q;
|
||||
return {
|
||||
...q,
|
||||
options: q.options.map(o => ({
|
||||
...o,
|
||||
is_correct: o.key === optionKey,
|
||||
})),
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const updateOption = (
|
||||
questionKey: string,
|
||||
optionKey: string,
|
||||
field: keyof EditableOption,
|
||||
value: any
|
||||
) => {
|
||||
setQuestions(prev =>
|
||||
prev.map(q => {
|
||||
if (q.key !== questionKey) return q;
|
||||
return {
|
||||
...q,
|
||||
options: q.options.map(o =>
|
||||
o.key === optionKey ? { ...o, [field]: value } : o
|
||||
),
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const updateQuestion = (
|
||||
questionKey: string,
|
||||
field: keyof EditableQuestion,
|
||||
value: any
|
||||
) => {
|
||||
setQuestions(prev =>
|
||||
prev.map(q => (q.key === questionKey ? { ...q, [field]: value } : q))
|
||||
);
|
||||
};
|
||||
|
||||
// ============ Сохранение ============
|
||||
const handleSave = async () => {
|
||||
if (!lessonId) return;
|
||||
|
||||
// Валидация на фронте
|
||||
if (!titlePl.trim()) {
|
||||
setError('Title (PL) обязателен');
|
||||
setActiveTab('explanation');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const q of questions) {
|
||||
if (!q.prompt_pl.trim()) {
|
||||
setError(`Вопрос ${q.question_id}: prompt (PL) обязателен`);
|
||||
setActiveTab('questions');
|
||||
return;
|
||||
}
|
||||
if (q.options.length === 0) {
|
||||
setError(`Вопрос ${q.question_id}: нужно минимум 1 вариант`);
|
||||
setActiveTab('questions');
|
||||
return;
|
||||
}
|
||||
const correctCount = q.options.filter(o => o.is_correct).length;
|
||||
if (correctCount !== 1) {
|
||||
setError(`Вопрос ${q.question_id}: должен быть ровно 1 правильный вариант`);
|
||||
setActiveTab('questions');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await updateLessonFull(lessonId, {
|
||||
title_pl: titlePl.trim(),
|
||||
title_ru: titleRu.trim(),
|
||||
title_uk: titleUk.trim(),
|
||||
title_en: titleEn.trim(),
|
||||
objective_pl: objectivePl.trim(),
|
||||
objective_ru: objectiveRu.trim(),
|
||||
objective_uk: objectiveUk.trim(),
|
||||
objective_en: objectiveEn.trim(),
|
||||
explanation_pl: explanationPl.trim(),
|
||||
explanation_ru: explanationRu.trim(),
|
||||
explanation_uk: explanationUk.trim(),
|
||||
explanation_en: explanationEn.trim(),
|
||||
examples: examples
|
||||
.filter(e => e.text_pl.trim()) // пустые не отправляем
|
||||
.map(e => ({
|
||||
text_pl: e.text_pl.trim(),
|
||||
text_ru: e.text_ru.trim(),
|
||||
text_uk: e.text_uk.trim(),
|
||||
text_en: e.text_en.trim(),
|
||||
})),
|
||||
questions: questions.map(q => ({
|
||||
question_id: q.question_id,
|
||||
prompt_pl: q.prompt_pl.trim(),
|
||||
prompt_ru: q.prompt_ru.trim(),
|
||||
prompt_uk: q.prompt_uk.trim(),
|
||||
prompt_en: q.prompt_en.trim(),
|
||||
hint_type: q.hint_type,
|
||||
hint_value: q.hint_value,
|
||||
options: q.options.map(o => ({
|
||||
text_pl: o.text_pl.trim(),
|
||||
text_ru: o.text_ru.trim(),
|
||||
text_uk: o.text_uk.trim(),
|
||||
text_en: o.text_en.trim(),
|
||||
is_correct: o.is_correct,
|
||||
wrong_because: o.is_correct ? null : o.wrong_because,
|
||||
})),
|
||||
})),
|
||||
});
|
||||
|
||||
if (onSaved) onSaved();
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
console.error('[LessonEditorModal] save failed:', e);
|
||||
setError(e?.message || 'Не удалось сохранить');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen || !lessonId) return null;
|
||||
|
||||
// ============ Стили ============
|
||||
const textColor = '#1A1A2E';
|
||||
const subColor = '#6B7280';
|
||||
const cardBg = 'rgba(255,255,255,0.55)';
|
||||
const brand = '#8B5CF6';
|
||||
|
||||
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
||||
background: cardBg,
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 8px 32px rgba(31,38,73,0.10), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
borderRadius: radius.sm,
|
||||
border: '1px solid rgba(0,0,0,0.08)',
|
||||
background: 'rgba(255,255,255,0.9)',
|
||||
color: textColor,
|
||||
fontSize: '13px',
|
||||
fontFamily: 'inherit',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
const textareaStyle: React.CSSProperties = {
|
||||
...inputStyle,
|
||||
minHeight: '70px',
|
||||
resize: 'vertical',
|
||||
lineHeight: '1.5',
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
color: subColor,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
marginBottom: '4px',
|
||||
display: 'block',
|
||||
};
|
||||
|
||||
// 🔧 ФИКС (19.09.2026): табы больше не растягиваются (flex: 1) и не вылезают за экран.
|
||||
// Теперь flexShrink: 0 + горизонтальный скролл у контейнера (overflowX: auto).
|
||||
const tabBtnStyle = (isActive: boolean): React.CSSProperties => ({
|
||||
flexShrink: 0,
|
||||
padding: '10px 14px',
|
||||
borderRadius: radius.md,
|
||||
border: isActive ? `2px solid ${brand}` : '1px solid rgba(0,0,0,0.06)',
|
||||
background: isActive ? `${brand}15` : 'rgba(255,255,255,0.6)',
|
||||
color: isActive ? brand : textColor,
|
||||
fontSize: '13px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'all 0.2s ease',
|
||||
});
|
||||
|
||||
// ============ Path сверху ============
|
||||
const pathStr = topicPath && lessonMeta
|
||||
? `🎓 ${topicPath.levelCode} → Тема ${topicPath.topicOrder}: ${topicPath.topicName} → Урок ${lessonMeta.order_number}`
|
||||
: '🎓 Урок';
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(20,22,35,0.6)', zIndex: 2300,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '20px',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget && !saving) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
...glass({
|
||||
borderRadius: radius.lg,
|
||||
padding: '20px',
|
||||
width: '100%',
|
||||
maxWidth: '640px',
|
||||
maxHeight: '92vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{/* ============ Шапка ============ */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '12px',
|
||||
paddingBottom: '12px',
|
||||
borderBottom: '1px solid rgba(0,0,0,0.06)',
|
||||
flexShrink: 0,
|
||||
gap: '8px',
|
||||
}}>
|
||||
<h3 style={{
|
||||
margin: 0,
|
||||
fontSize: '14px',
|
||||
fontWeight: 800,
|
||||
color: textColor,
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{pathStr}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
style={{
|
||||
...glass({ padding: '8px 14px', borderRadius: radius.sm }),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: saving ? 'default' : 'pointer',
|
||||
opacity: saving ? 0.5 : 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ============ Tabs ============ */}
|
||||
{/* 🔧 ФИКС (19.09.2026): горизонтальный скролл, если табы не влезают */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '6px',
|
||||
marginBottom: '12px',
|
||||
flexShrink: 0,
|
||||
overflowX: 'auto',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
paddingBottom: '2px',
|
||||
}}>
|
||||
<button onClick={() => setActiveTab('explanation')} style={tabBtnStyle(activeTab === 'explanation')}>
|
||||
📖 Объяснение
|
||||
</button>
|
||||
<button onClick={() => setActiveTab('examples')} style={tabBtnStyle(activeTab === 'examples')}>
|
||||
💡 Примеры ({examples.length})
|
||||
</button>
|
||||
<button onClick={() => setActiveTab('questions')} style={tabBtnStyle(activeTab === 'questions')}>
|
||||
❓ Вопросы ({questions.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ============ Контент ============ */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
paddingBottom: '12px',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
minHeight: 0,
|
||||
}}>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: subColor, fontSize: '13px' }}>
|
||||
Загрузка...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* ==================== ОБЪЯСНЕНИЕ ==================== */}
|
||||
{activeTab === 'explanation' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label style={labelStyle}>Title (PL, EN, RU, UK)</label>
|
||||
<input style={{ ...inputStyle, marginBottom: '6px' }} placeholder="PL" value={titlePl} onChange={(e) => setTitlePl(e.target.value)} disabled={saving} />
|
||||
<input style={{ ...inputStyle, marginBottom: '6px' }} placeholder="EN" value={titleEn} onChange={(e) => setTitleEn(e.target.value)} disabled={saving} />
|
||||
<input style={{ ...inputStyle, marginBottom: '6px' }} placeholder="RU" value={titleRu} onChange={(e) => setTitleRu(e.target.value)} disabled={saving} />
|
||||
<input style={inputStyle} placeholder="UK" value={titleUk} onChange={(e) => setTitleUk(e.target.value)} disabled={saving} />
|
||||
</div>
|
||||
|
||||
{/* Objective */}
|
||||
<div>
|
||||
<label style={labelStyle}>Objective (PL, EN, RU, UK)</label>
|
||||
<textarea style={{ ...textareaStyle, marginBottom: '6px' }} placeholder="PL" value={objectivePl} onChange={(e) => setObjectivePl(e.target.value)} disabled={saving} />
|
||||
<textarea style={{ ...textareaStyle, marginBottom: '6px' }} placeholder="EN" value={objectiveEn} onChange={(e) => setObjectiveEn(e.target.value)} disabled={saving} />
|
||||
<textarea style={{ ...textareaStyle, marginBottom: '6px' }} placeholder="RU" value={objectiveRu} onChange={(e) => setObjectiveRu(e.target.value)} disabled={saving} />
|
||||
<textarea style={textareaStyle} placeholder="UK" value={objectiveUk} onChange={(e) => setObjectiveUk(e.target.value)} disabled={saving} />
|
||||
</div>
|
||||
|
||||
{/* Explanation */}
|
||||
<div>
|
||||
<label style={labelStyle}>Explanation (PL, EN, RU, UK)</label>
|
||||
<textarea style={{ ...textareaStyle, minHeight: '100px', marginBottom: '6px' }} placeholder="PL" value={explanationPl} onChange={(e) => setExplanationPl(e.target.value)} disabled={saving} />
|
||||
<textarea style={{ ...textareaStyle, minHeight: '100px', marginBottom: '6px' }} placeholder="EN" value={explanationEn} onChange={(e) => setExplanationEn(e.target.value)} disabled={saving} />
|
||||
<textarea style={{ ...textareaStyle, minHeight: '100px', marginBottom: '6px' }} placeholder="RU" value={explanationRu} onChange={(e) => setExplanationRu(e.target.value)} disabled={saving} />
|
||||
<textarea style={{ ...textareaStyle, minHeight: '100px' }} placeholder="UK" value={explanationUk} onChange={(e) => setExplanationUk(e.target.value)} disabled={saving} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ==================== ПРИМЕРЫ ==================== */}
|
||||
{activeTab === 'examples' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{examples.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '20px', color: subColor, fontSize: '13px' }}>
|
||||
Примеров пока нет
|
||||
</div>
|
||||
)}
|
||||
|
||||
{examples.map((ex, idx) => (
|
||||
<div
|
||||
key={ex.key}
|
||||
style={{
|
||||
...glass({ padding: '12px 14px', borderRadius: radius.md }),
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px' }}>
|
||||
<span style={{ fontSize: '12px', fontWeight: 700, color: brand }}>
|
||||
Пример {idx + 1}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => removeExample(ex.key)}
|
||||
disabled={saving}
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
borderRadius: radius.sm,
|
||||
border: '1px solid rgba(231,76,60,0.3)',
|
||||
background: 'rgba(231,76,60,0.08)',
|
||||
color: '#E74C3C',
|
||||
fontSize: '12px',
|
||||
fontWeight: 700,
|
||||
cursor: saving ? 'default' : 'pointer',
|
||||
}}
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
<input style={inputStyle} placeholder="PL *" value={ex.text_pl} onChange={(e) => updateExample(ex.key, 'text_pl', e.target.value)} disabled={saving} />
|
||||
<input style={inputStyle} placeholder="EN" value={ex.text_en} onChange={(e) => updateExample(ex.key, 'text_en', e.target.value)} disabled={saving} />
|
||||
<input style={inputStyle} placeholder="RU" value={ex.text_ru} onChange={(e) => updateExample(ex.key, 'text_ru', e.target.value)} disabled={saving} />
|
||||
<input style={inputStyle} placeholder="UK" value={ex.text_uk} onChange={(e) => updateExample(ex.key, 'text_uk', e.target.value)} disabled={saving} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={addExample}
|
||||
disabled={saving}
|
||||
style={{
|
||||
padding: '12px',
|
||||
borderRadius: radius.md,
|
||||
border: `2px dashed ${brand}60`,
|
||||
background: `${brand}10`,
|
||||
color: brand,
|
||||
fontSize: '13px',
|
||||
fontWeight: 700,
|
||||
cursor: saving ? 'default' : 'pointer',
|
||||
}}
|
||||
>
|
||||
➕ Добавить пример
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ==================== ВОПРОСЫ ==================== */}
|
||||
{activeTab === 'questions' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{questions.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '20px', color: subColor, fontSize: '13px' }}>
|
||||
Вопросов пока нет
|
||||
</div>
|
||||
)}
|
||||
|
||||
{questions.map((q, qIdx) => (
|
||||
<div
|
||||
key={q.key}
|
||||
style={{
|
||||
...glass({ padding: '14px', borderRadius: radius.md }),
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px',
|
||||
}}
|
||||
>
|
||||
{/* Заголовок вопроса */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{
|
||||
padding: '4px 10px',
|
||||
borderRadius: radius.sm,
|
||||
background: `${brand}15`,
|
||||
border: `1px solid ${brand}40`,
|
||||
color: brand,
|
||||
fontSize: '11px',
|
||||
fontWeight: 800,
|
||||
}}>
|
||||
Вопрос {qIdx + 1}
|
||||
</span>
|
||||
<span style={{ fontSize: '10px', color: subColor, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
|
||||
{q.question_id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Prompt */}
|
||||
<div>
|
||||
<label style={labelStyle}>Prompt (PL, EN, RU, UK)</label>
|
||||
<textarea style={{ ...textareaStyle, marginBottom: '6px' }} placeholder="PL" value={q.prompt_pl} onChange={(e) => updateQuestion(q.key, 'prompt_pl', e.target.value)} disabled={saving} />
|
||||
<textarea style={{ ...textareaStyle, marginBottom: '6px' }} placeholder="EN" value={q.prompt_en} onChange={(e) => updateQuestion(q.key, 'prompt_en', e.target.value)} disabled={saving} />
|
||||
<textarea style={{ ...textareaStyle, marginBottom: '6px' }} placeholder="RU" value={q.prompt_ru} onChange={(e) => updateQuestion(q.key, 'prompt_ru', e.target.value)} disabled={saving} />
|
||||
<textarea style={textareaStyle} placeholder="UK" value={q.prompt_uk} onChange={(e) => updateQuestion(q.key, 'prompt_uk', e.target.value)} disabled={saving} />
|
||||
</div>
|
||||
|
||||
{/* Hint */}
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>Hint type</label>
|
||||
<select
|
||||
style={inputStyle}
|
||||
value={q.hint_type || ''}
|
||||
onChange={(e) => updateQuestion(q.key, 'hint_type', e.target.value || null)}
|
||||
disabled={saving}
|
||||
>
|
||||
<option value="">— нет —</option>
|
||||
<option value="first_letter">first_letter</option>
|
||||
<option value="word_length">word_length</option>
|
||||
<option value="translation">translation</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>Hint value</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
placeholder="напр. j"
|
||||
value={q.hint_value || ''}
|
||||
onChange={(e) => updateQuestion(q.key, 'hint_value', e.target.value || null)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div>
|
||||
<label style={labelStyle}>Варианты ответа</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{q.options.map((o, oIdx) => (
|
||||
<div
|
||||
key={o.key}
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
background: o.is_correct ? 'rgba(46,204,113,0.08)' : 'rgba(0,0,0,0.02)',
|
||||
borderRadius: radius.sm,
|
||||
border: o.is_correct ? '2px solid #2ECC7150' : '1px solid rgba(0,0,0,0.06)',
|
||||
}}
|
||||
>
|
||||
{/* Строка управления вариантом */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '6px' }}>
|
||||
{/* Кружок = правильный */}
|
||||
<button
|
||||
onClick={() => setCorrectOption(q.key, o.key)}
|
||||
disabled={saving}
|
||||
title="Отметить как правильный"
|
||||
style={{
|
||||
width: '22px',
|
||||
height: '22px',
|
||||
borderRadius: '50%',
|
||||
border: o.is_correct ? '2px solid #2ECC71' : '2px solid rgba(0,0,0,0.2)',
|
||||
background: o.is_correct ? '#2ECC71' : 'transparent',
|
||||
cursor: saving ? 'default' : 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{o.is_correct && (
|
||||
<span style={{ width: '8px', height: '8px', borderRadius: '50%', background: '#FFF' }} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Метка A/B/C/D */}
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 800,
|
||||
color: brand,
|
||||
minWidth: '14px',
|
||||
}}>
|
||||
{String.fromCharCode(65 + oIdx)}
|
||||
</span>
|
||||
|
||||
{/* ↑↓ */}
|
||||
<button
|
||||
onClick={() => moveOptionUp(q.key, o.key)}
|
||||
disabled={saving || oIdx === 0}
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
borderRadius: radius.sm,
|
||||
border: '1px solid rgba(0,0,0,0.08)',
|
||||
background: 'rgba(255,255,255,0.8)',
|
||||
color: textColor,
|
||||
fontSize: '11px',
|
||||
cursor: (saving || oIdx === 0) ? 'default' : 'pointer',
|
||||
opacity: oIdx === 0 ? 0.4 : 1,
|
||||
}}
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => moveOptionDown(q.key, o.key)}
|
||||
disabled={saving || oIdx === q.options.length - 1}
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
borderRadius: radius.sm,
|
||||
border: '1px solid rgba(0,0,0,0.08)',
|
||||
background: 'rgba(255,255,255,0.8)',
|
||||
color: textColor,
|
||||
fontSize: '11px',
|
||||
cursor: (saving || oIdx === q.options.length - 1) ? 'default' : 'pointer',
|
||||
opacity: oIdx === q.options.length - 1 ? 0.4 : 1,
|
||||
}}
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
|
||||
{/* spacer */}
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* 🗑️ (нельзя удалить если это единственный вариант) */}
|
||||
{q.options.length > 1 && (
|
||||
<button
|
||||
onClick={() => removeOption(q.key, o.key)}
|
||||
disabled={saving}
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: radius.sm,
|
||||
border: '1px solid rgba(231,76,60,0.3)',
|
||||
background: 'rgba(231,76,60,0.08)',
|
||||
color: '#E74C3C',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
cursor: saving ? 'default' : 'pointer',
|
||||
}}
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Тексты варианта (PL, EN, RU, UK) */}
|
||||
<input style={{ ...inputStyle, marginBottom: '4px' }} placeholder="PL *" value={o.text_pl} onChange={(e) => updateOption(q.key, o.key, 'text_pl', e.target.value)} disabled={saving} />
|
||||
<input style={{ ...inputStyle, marginBottom: '4px' }} placeholder="EN" value={o.text_en} onChange={(e) => updateOption(q.key, o.key, 'text_en', e.target.value)} disabled={saving} />
|
||||
<input style={{ ...inputStyle, marginBottom: '4px' }} placeholder="RU" value={o.text_ru} onChange={(e) => updateOption(q.key, o.key, 'text_ru', e.target.value)} disabled={saving} />
|
||||
<input style={inputStyle} placeholder="UK" value={o.text_uk} onChange={(e) => updateOption(q.key, o.key, 'text_uk', e.target.value)} disabled={saving} />
|
||||
|
||||
{/* wrong_because — только для неправильных */}
|
||||
{!o.is_correct && (
|
||||
<div style={{ marginTop: '6px' }}>
|
||||
<label style={labelStyle}>Wrong because</label>
|
||||
<select
|
||||
style={inputStyle}
|
||||
value={o.wrong_because || ''}
|
||||
onChange={(e) => updateOption(q.key, o.key, 'wrong_because', e.target.value || null)}
|
||||
disabled={saving}
|
||||
>
|
||||
<option value="">— не указано —</option>
|
||||
{wrongBecauseList.map((wb) => (
|
||||
<option key={wb.wrong_because} value={wb.wrong_because}>
|
||||
{wb.wrong_because} — {wb.explanation_ru}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={() => addOption(q.key)}
|
||||
disabled={saving}
|
||||
style={{
|
||||
padding: '8px',
|
||||
borderRadius: radius.sm,
|
||||
border: `1px dashed ${brand}60`,
|
||||
background: `${brand}08`,
|
||||
color: brand,
|
||||
fontSize: '12px',
|
||||
fontWeight: 700,
|
||||
cursor: saving ? 'default' : 'pointer',
|
||||
}}
|
||||
>
|
||||
➕ Добавить вариант
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ============ Ошибка ============ */}
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '10px 14px',
|
||||
background: 'rgba(231,76,60,0.08)',
|
||||
borderRadius: radius.md,
|
||||
border: '1px solid rgba(231,76,60,0.2)',
|
||||
color: '#E74C3C',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
marginBottom: '8px',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
⚠️ {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ============ Футер с кнопками ============ */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
paddingTop: '12px',
|
||||
borderTop: '1px solid rgba(0,0,0,0.06)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
style={{
|
||||
...glass({ padding: '12px 16px', borderRadius: radius.md }),
|
||||
color: textColor,
|
||||
fontSize: '13px',
|
||||
fontWeight: 700,
|
||||
cursor: saving ? 'default' : 'pointer',
|
||||
opacity: saving ? 0.5 : 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || loading}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px 16px',
|
||||
borderRadius: radius.md,
|
||||
border: `2px solid ${brand}`,
|
||||
background: brand,
|
||||
color: '#FFF',
|
||||
fontSize: '13px',
|
||||
fontWeight: 800,
|
||||
cursor: (saving || loading) ? 'default' : 'pointer',
|
||||
opacity: (saving || loading) ? 0.6 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{saving ? '⏳ Сохранение...' : '💾 Сохранить урок'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LessonEditorModal;
|
||||
@@ -0,0 +1,407 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { radius } from '../styles/theme';
|
||||
import {
|
||||
getLessonLevels,
|
||||
getLessonTopics,
|
||||
getLessonTopic,
|
||||
type ContentLessonLevel,
|
||||
type ContentLessonTopic,
|
||||
type ContentTopicDetail,
|
||||
type ContentLessonItem,
|
||||
} from '../api';
|
||||
|
||||
interface LessonsEditorProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** Колбэк для открытия модалки урока */
|
||||
onOpenLesson?: (
|
||||
lessonId: string,
|
||||
topicPath: { levelCode: string; topicOrder: number; topicName: string }
|
||||
) => void;
|
||||
}
|
||||
|
||||
const LessonsEditor: React.FC<LessonsEditorProps> = ({ isOpen, onClose, onOpenLesson }) => {
|
||||
const [levels, setLevels] = useState<ContentLessonLevel[]>([]);
|
||||
const [selectedLevel, setSelectedLevel] = useState<string | null>(null);
|
||||
const [topics, setTopics] = useState<ContentLessonTopic[]>([]);
|
||||
const [selectedTopic, setSelectedTopic] = useState<ContentTopicDetail | null>(null);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Загрузка уровней при открытии
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setSelectedLevel(null);
|
||||
setSelectedTopic(null);
|
||||
setTopics([]);
|
||||
setError(null);
|
||||
|
||||
setLoading(true);
|
||||
getLessonLevels()
|
||||
.then((data) => setLevels(data.levels))
|
||||
.catch((e) => {
|
||||
console.error('[LessonsEditor] levels load failed:', e);
|
||||
setError('Не удалось загрузить уровни');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [isOpen]);
|
||||
|
||||
// Загрузка тем при выборе уровня
|
||||
useEffect(() => {
|
||||
if (!selectedLevel) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSelectedTopic(null);
|
||||
|
||||
getLessonTopics(selectedLevel)
|
||||
.then((data) => setTopics(data.topics))
|
||||
.catch((e) => {
|
||||
console.error('[LessonsEditor] topics load failed:', e);
|
||||
setError('Не удалось загрузить темы');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [selectedLevel]);
|
||||
|
||||
// Загрузка темы целиком при клике на тему
|
||||
const handleTopicClick = async (topicId: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getLessonTopic(topicId);
|
||||
setSelectedTopic(data);
|
||||
} catch (e) {
|
||||
console.error('[LessonsEditor] topic load failed:', e);
|
||||
setError('Не удалось загрузить тему');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 🔧 (19.09.2026): передаём topicPath в родителя, чтобы LessonEditorModal знал путь
|
||||
const handleLessonClick = (lessonId: string) => {
|
||||
if (!onOpenLesson) {
|
||||
console.log('[LessonsEditor] TODO: open lesson', lessonId);
|
||||
return;
|
||||
}
|
||||
if (!selectedTopic) return;
|
||||
|
||||
onOpenLesson(lessonId, {
|
||||
levelCode: selectedTopic.topic.level_code,
|
||||
topicOrder: selectedTopic.topic.order_number,
|
||||
topicName: selectedTopic.topic.name_ru,
|
||||
});
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const textColor = '#1A1A2E';
|
||||
const subColor = '#6B7280';
|
||||
const cardBg = 'rgba(255,255,255,0.55)';
|
||||
const brand = '#8B5CF6';
|
||||
|
||||
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
||||
background: cardBg,
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 8px 32px rgba(31,38,73,0.10), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
// ============ Хлебные крошки ============
|
||||
const breadcrumb = () => {
|
||||
if (selectedTopic) {
|
||||
return `🎓 ${selectedTopic.topic.level_code} → Тема ${selectedTopic.topic.order_number}: ${selectedTopic.topic.name_ru}`;
|
||||
}
|
||||
if (selectedLevel) {
|
||||
return `🎓 ${selectedLevel} → Темы`;
|
||||
}
|
||||
return '🎓 Уроки';
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(20,22,35,0.55)', zIndex: 2200,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '20px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
...glass({
|
||||
borderRadius: radius.lg,
|
||||
padding: '20px',
|
||||
width: '100%',
|
||||
maxWidth: '560px',
|
||||
maxHeight: '90vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{/* Шапка */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '16px',
|
||||
paddingBottom: '12px',
|
||||
borderBottom: '1px solid rgba(0,0,0,0.06)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<h3 style={{ margin: 0, fontSize: '17px', fontWeight: 800, color: textColor }}>
|
||||
{breadcrumb()}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
...glass({ padding: '8px 14px', borderRadius: radius.sm }),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Контент */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px',
|
||||
paddingBottom: '12px',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
minHeight: 0,
|
||||
}}>
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '10px 14px',
|
||||
background: 'rgba(231,76,60,0.08)',
|
||||
borderRadius: radius.md,
|
||||
color: '#E74C3C',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
⚠️ {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', padding: '20px', color: subColor, fontSize: '13px' }}>
|
||||
Загрузка...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ============ УРОВНИ ============ */}
|
||||
{!loading && !selectedLevel && !selectedTopic && levels.map((lvl) => {
|
||||
const isReady = lvl.topics_count > 0 && lvl.lessons_count > 0;
|
||||
const icon = isReady ? '✅' : (lvl.topics_count > 0 ? '🟡' : '⚪');
|
||||
return (
|
||||
<button
|
||||
key={lvl.code}
|
||||
onClick={() => setSelectedLevel(lvl.code)}
|
||||
style={{
|
||||
...glass({ padding: '14px 16px', borderRadius: radius.md }),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<span style={{ fontSize: '18px' }}>{icon}</span>
|
||||
<div>
|
||||
<div style={{ fontSize: '15px', fontWeight: 800, color: textColor }}>
|
||||
{lvl.code} — {lvl.name_ru}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: subColor, marginTop: '2px' }}>
|
||||
{lvl.topics_count} тем · {lvl.lessons_count} уроков
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontSize: '20px', color: subColor }}>›</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* ============ ТЕМЫ УРОВНЯ ============ */}
|
||||
{!loading && selectedLevel && !selectedTopic && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => { setSelectedLevel(null); setSelectedTopic(null); setTopics([]); }}
|
||||
style={{
|
||||
...glass({ padding: '8px 14px', borderRadius: radius.sm }),
|
||||
color: textColor,
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
alignSelf: 'flex-start',
|
||||
}}
|
||||
>
|
||||
← К уровням
|
||||
</button>
|
||||
|
||||
{topics.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '20px', color: subColor, fontSize: '13px' }}>
|
||||
Тем пока нет
|
||||
</div>
|
||||
)}
|
||||
|
||||
{topics.map((t) => {
|
||||
const isReady = t.lessons_count > 0 && t.test_questions_count > 0;
|
||||
const icon = isReady ? '✅' : (t.lessons_count > 0 ? '🟡' : '⚪');
|
||||
return (
|
||||
<button
|
||||
key={t.topic_id}
|
||||
onClick={() => handleTopicClick(t.topic_id)}
|
||||
style={{
|
||||
...glass({ padding: '12px 14px', borderRadius: radius.md }),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '16px', flexShrink: 0 }}>{icon}</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: 700, color: textColor }}>
|
||||
Тема {t.order_number}: {t.name_ru}
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: subColor, marginTop: '2px' }}>
|
||||
{t.lessons_count} уроков · тест {t.test_questions_count} вопросов
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontSize: '18px', color: subColor }}>›</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ============ УРОКИ ТЕМЫ ============ */}
|
||||
{!loading && selectedTopic && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => { setSelectedTopic(null); }}
|
||||
style={{
|
||||
...glass({ padding: '8px 14px', borderRadius: radius.sm }),
|
||||
color: textColor,
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
alignSelf: 'flex-start',
|
||||
}}
|
||||
>
|
||||
← К темам
|
||||
</button>
|
||||
|
||||
{selectedTopic.topic.description_ru && (
|
||||
<div style={{
|
||||
padding: '10px 14px',
|
||||
background: 'rgba(139,92,246,0.06)',
|
||||
borderRadius: radius.md,
|
||||
border: `1px solid ${brand}20`,
|
||||
fontSize: '12px',
|
||||
color: subColor,
|
||||
lineHeight: '1.5',
|
||||
}}>
|
||||
{selectedTopic.topic.description_ru}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTopic.lessons.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '20px', color: subColor, fontSize: '13px' }}>
|
||||
Уроков пока нет
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTopic.lessons.map((lesson: ContentLessonItem) => (
|
||||
<button
|
||||
key={lesson.lesson_id}
|
||||
onClick={() => handleLessonClick(lesson.lesson_id)}
|
||||
style={{
|
||||
...glass({ padding: '12px 14px', borderRadius: radius.md }),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
minWidth: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
background: `${brand}15`,
|
||||
border: `1px solid ${brand}40`,
|
||||
color: brand,
|
||||
fontSize: '13px',
|
||||
fontWeight: 800,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{lesson.order_number}
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: 700, color: textColor }}>
|
||||
{lesson.title_ru}
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: subColor, marginTop: '2px' }}>
|
||||
{lesson.skill_tag}
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontSize: '18px', color: subColor }}>›</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Тест темы — отдельная строка */}
|
||||
{selectedTopic.has_test && (
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log('[LessonsEditor] TODO: open topic test', selectedTopic.topic.topic_id);
|
||||
}}
|
||||
style={{
|
||||
...glass({ padding: '14px 16px', borderRadius: radius.md }),
|
||||
border: `2px dashed ${brand}60`,
|
||||
background: `${brand}10`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '20px', flexShrink: 0 }}>📝</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: 800, color: textColor }}>
|
||||
Тест темы
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: subColor, marginTop: '2px' }}>
|
||||
{selectedTopic.test_questions_count} вопросов
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontSize: '18px', color: brand }}>›</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LessonsEditor;
|
||||
@@ -0,0 +1,351 @@
|
||||
import { useState, useRef, useCallback, useEffect, memo, useMemo } from 'react';
|
||||
import { colors, radius, shadows } from '../styles/theme';
|
||||
|
||||
interface CellProps {
|
||||
letter: string;
|
||||
size: number;
|
||||
fontSize: number;
|
||||
bg: string;
|
||||
border: string;
|
||||
textColor: string;
|
||||
boxShadow: string;
|
||||
animation: string | undefined;
|
||||
}
|
||||
|
||||
const Cell = memo<CellProps>(({ letter, size, fontSize, bg, border, textColor, boxShadow, animation }) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.sm,
|
||||
background: bg,
|
||||
border: `2px solid ${border}`,
|
||||
color: textColor,
|
||||
fontSize: `${fontSize}px`,
|
||||
fontWeight: bg !== colors.cellDefault ? 700 : 500,
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
transition: animation ? 'all 0.3s ease' : 'all 0.15s ease',
|
||||
pointerEvents: 'none',
|
||||
boxShadow: boxShadow,
|
||||
animation: animation || undefined,
|
||||
willChange: bg !== colors.cellDefault ? 'transform, box-shadow' : 'auto',
|
||||
}}
|
||||
>
|
||||
{letter}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Cell.displayName = 'Cell';
|
||||
|
||||
interface LetterGridProps {
|
||||
letters: string[][];
|
||||
gridSize: number;
|
||||
onWordSelect: (cells: [number, number][]) => void;
|
||||
foundWords: string[];
|
||||
firstLetterCell: [number, number] | null;
|
||||
wordPaths: Record<string, [number, number][]>;
|
||||
newFoundWord?: string | null;
|
||||
}
|
||||
|
||||
const LetterGrid: React.FC<LetterGridProps> = ({
|
||||
letters,
|
||||
gridSize,
|
||||
onWordSelect,
|
||||
foundWords,
|
||||
firstLetterCell,
|
||||
wordPaths,
|
||||
newFoundWord,
|
||||
}) => {
|
||||
const [selectedCells, setSelectedCells] = useState<[number, number][]>([]);
|
||||
const [isSelecting, setIsSelecting] = useState(false);
|
||||
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const processingRef = useRef(false);
|
||||
const touchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastProcessedCellsRef = useRef<string>('');
|
||||
|
||||
// Адаптивный размер клетки
|
||||
const maxGridWidth = Math.min(windowWidth - 32, 400);
|
||||
const gapSize = gridSize <= 4 ? 5 : gridSize <= 6 ? 4 : 3;
|
||||
const totalGap = gapSize * (gridSize - 1);
|
||||
const cellSize = Math.floor((maxGridWidth - totalGap) / gridSize);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setWindowWidth(window.innerWidth);
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
if (touchTimeoutRef.current) {
|
||||
clearTimeout(touchTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
processingRef.current = false;
|
||||
lastProcessedCellsRef.current = '';
|
||||
if (touchTimeoutRef.current) {
|
||||
clearTimeout(touchTimeoutRef.current);
|
||||
touchTimeoutRef.current = null;
|
||||
}
|
||||
}, [letters]);
|
||||
|
||||
// useMemo для мгновенного обновления (без задержки useEffect)
|
||||
const foundCells = useMemo(() => {
|
||||
const fc = new Set<string>();
|
||||
foundWords.forEach(word => {
|
||||
const path = wordPaths[word];
|
||||
if (path) {
|
||||
path.forEach(([r, c]) => {
|
||||
fc.add(`${r},${c}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
return fc;
|
||||
}, [foundWords, wordPaths]);
|
||||
|
||||
const newFoundCells = useMemo(() => {
|
||||
const nfc = new Set<string>();
|
||||
if (newFoundWord) {
|
||||
const path = wordPaths[newFoundWord];
|
||||
if (path) {
|
||||
path.forEach(([r, c]) => {
|
||||
nfc.add(`${r},${c}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
return nfc;
|
||||
}, [newFoundWord, wordPaths]);
|
||||
|
||||
const selectedSet = useMemo(() => {
|
||||
const ss = new Set<string>();
|
||||
selectedCells.forEach(([r, c]) => ss.add(`${r},${c}`));
|
||||
return ss;
|
||||
}, [selectedCells]);
|
||||
|
||||
const isAdjacent = (cell1: [number, number], cell2: [number, number]): boolean => {
|
||||
const [r1, c1] = cell1;
|
||||
const [r2, c2] = cell2;
|
||||
return (Math.abs(r1 - r2) === 1 && c1 === c2) || (Math.abs(c1 - c2) === 1 && r1 === r2);
|
||||
};
|
||||
|
||||
const getCellFromTouch = useCallback((clientX: number, clientY: number): [number, number] | null => {
|
||||
if (!gridRef.current) return null;
|
||||
const rect = gridRef.current.getBoundingClientRect();
|
||||
const x = clientX - rect.left;
|
||||
const y = clientY - rect.top;
|
||||
const col = Math.floor(x / (cellSize + gapSize));
|
||||
const row = Math.floor(y / (cellSize + gapSize));
|
||||
if (row >= 0 && row < gridSize && col >= 0 && col < gridSize) {
|
||||
return [row, col];
|
||||
}
|
||||
return null;
|
||||
}, [gridSize, cellSize, gapSize]);
|
||||
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (processingRef.current) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
const cell = getCellFromTouch(touch.clientX, touch.clientY);
|
||||
if (cell) {
|
||||
const [row, col] = cell;
|
||||
const key = `${row},${col}`;
|
||||
if (!foundCells.has(key)) {
|
||||
setIsSelecting(true);
|
||||
setSelectedCells([cell]);
|
||||
|
||||
if (window.Telegram?.WebApp?.HapticFeedback) {
|
||||
window.Telegram.WebApp.HapticFeedback.impactOccurred('light');
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [foundCells, getCellFromTouch]);
|
||||
|
||||
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
if (!isSelecting || processingRef.current) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
const cell = getCellFromTouch(touch.clientX, touch.clientY);
|
||||
if (!cell) return;
|
||||
|
||||
const [row, col] = cell;
|
||||
const key = `${row},${col}`;
|
||||
if (foundCells.has(key)) return;
|
||||
|
||||
setSelectedCells(prev => {
|
||||
const lastCell = prev[prev.length - 1];
|
||||
if (!lastCell) return prev;
|
||||
|
||||
if (lastCell[0] === row && lastCell[1] === col) return prev;
|
||||
|
||||
if (!isAdjacent(lastCell, [row, col])) return prev;
|
||||
|
||||
const alreadySelected = prev.some(([r, c]) => r === row && c === col);
|
||||
if (alreadySelected) {
|
||||
const prevCell = prev[prev.length - 2];
|
||||
if (prevCell && prevCell[0] === row && prevCell[1] === col) {
|
||||
return prev.slice(0, -1);
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
|
||||
return [...prev, [row, col]];
|
||||
});
|
||||
}, [isSelecting, foundCells, getCellFromTouch]);
|
||||
|
||||
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!isSelecting || processingRef.current) {
|
||||
setIsSelecting(false);
|
||||
setSelectedCells([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedCells.length > 1) {
|
||||
const cellsKey = selectedCells.map(([r, c]) => `${r},${c}`).join('|');
|
||||
|
||||
if (cellsKey !== lastProcessedCellsRef.current) {
|
||||
processingRef.current = true;
|
||||
lastProcessedCellsRef.current = cellsKey;
|
||||
|
||||
onWordSelect(selectedCells);
|
||||
|
||||
if (touchTimeoutRef.current) {
|
||||
clearTimeout(touchTimeoutRef.current);
|
||||
}
|
||||
touchTimeoutRef.current = setTimeout(() => {
|
||||
processingRef.current = false;
|
||||
touchTimeoutRef.current = null;
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
setIsSelecting(false);
|
||||
setSelectedCells([]);
|
||||
}, [isSelecting, selectedCells, onWordSelect]);
|
||||
|
||||
const flatLetters = Array.isArray(letters) ? letters.flat() : [];
|
||||
|
||||
if (flatLetters.length === 0 || flatLetters.length !== gridSize * gridSize) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '40px',
|
||||
textAlign: 'center',
|
||||
color: colors.textSecondary,
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
}}>
|
||||
Loading grid...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fontSize = gridSize <= 4
|
||||
? Math.max(cellSize * 0.42, 18)
|
||||
: gridSize <= 6
|
||||
? Math.max(cellSize * 0.4, 15)
|
||||
: gridSize <= 8
|
||||
? Math.max(cellSize * 0.38, 13)
|
||||
: Math.max(cellSize * 0.35, 11);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={gridRef}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${gridSize}, ${cellSize}px)`,
|
||||
gap: `${gapSize}px`,
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
touchAction: 'none',
|
||||
justifyContent: 'center',
|
||||
margin: '0 auto',
|
||||
padding: '8px',
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes cellPopIn {
|
||||
0% { transform: scale(0.8); opacity: 0.5; }
|
||||
50% { transform: scale(1.15); opacity: 1; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
@keyframes cellGlow {
|
||||
0%, 100% { box-shadow: 0 0 8px rgba(76, 217, 100, 0.3); }
|
||||
50% { box-shadow: 0 0 16px rgba(76, 217, 100, 0.6); }
|
||||
}
|
||||
@keyframes firstLetterPulse {
|
||||
0%, 100% { box-shadow: 0 0 6px rgba(255, 215, 0, 0.4); }
|
||||
50% { box-shadow: 0 0 14px rgba(255, 215, 0, 0.8); }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{flatLetters.map((letter, index) => {
|
||||
const row = Math.floor(index / gridSize);
|
||||
const col = index % gridSize;
|
||||
const key = `${row},${col}`;
|
||||
|
||||
const selected = selectedSet.has(key);
|
||||
const first = firstLetterCell ? firstLetterCell[0] === row && firstLetterCell[1] === col : false;
|
||||
const found = foundCells.has(key);
|
||||
const newFound = newFoundCells.has(key);
|
||||
|
||||
let bg = colors.cellDefault;
|
||||
let border = colors.border;
|
||||
let textColor = colors.text;
|
||||
let animation = '';
|
||||
let boxShadow = shadows.cell;
|
||||
|
||||
if (found) {
|
||||
bg = colors.cellFound;
|
||||
border = colors.success;
|
||||
textColor = '#1a7a3a';
|
||||
if (newFound) {
|
||||
animation = 'cellPopIn 0.4s ease, cellGlow 1s ease 0.4s';
|
||||
}
|
||||
} else if (first) {
|
||||
bg = colors.cellFirst;
|
||||
border = colors.gold;
|
||||
textColor = '#b8860b';
|
||||
animation = 'firstLetterPulse 1s ease infinite';
|
||||
boxShadow = '0 0 10px rgba(255, 215, 0, 0.5)';
|
||||
} else if (selected) {
|
||||
bg = colors.cellSelected;
|
||||
border = colors.accent;
|
||||
textColor = colors.accent;
|
||||
boxShadow = `0 0 8px ${colors.accent}40`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Cell
|
||||
key={`${row}-${col}-${index}`}
|
||||
letter={letter}
|
||||
size={cellSize}
|
||||
fontSize={fontSize}
|
||||
bg={bg}
|
||||
border={border}
|
||||
textColor={textColor}
|
||||
boxShadow={boxShadow}
|
||||
animation={animation || undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LetterGrid;
|
||||
@@ -0,0 +1,291 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '../i18n/LanguageContext';
|
||||
import { radius } from '../styles/theme';
|
||||
|
||||
interface OnboardingModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** Вызывается только когда пользователь прошёл онбординг до конца (не при пропуске) */
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
interface OnboardingStep {
|
||||
emoji: string;
|
||||
titleKey: string;
|
||||
textKey: string;
|
||||
}
|
||||
|
||||
const OnboardingModal: React.FC<OnboardingModalProps> = ({ isOpen, onClose, onComplete }) => {
|
||||
const { t } = useLanguage();
|
||||
const [step, setStep] = useState(0);
|
||||
|
||||
const brandColor = '#8B5CF6'; // учительский фиолетовый
|
||||
const textColor = '#1A1A2E';
|
||||
const subColor = '#6B7280';
|
||||
const cardBg = 'rgba(255,255,255,0.85)';
|
||||
|
||||
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
||||
background: cardBg,
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 24px 60px rgba(31,38,73,0.25), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
// 5 шагов — эмодзи + ключи переводов
|
||||
const steps: OnboardingStep[] = [
|
||||
{ emoji: '👋', titleKey: 'onboardingStep1Title', textKey: 'onboardingStep1Text' },
|
||||
{ emoji: '🎓', titleKey: 'onboardingStep2Title', textKey: 'onboardingStep2Text' },
|
||||
{ emoji: '🎮', titleKey: 'onboardingStep3Title', textKey: 'onboardingStep3Text' },
|
||||
{ emoji: '⌨️', titleKey: 'onboardingStep4Title', textKey: 'onboardingStep4Text' },
|
||||
{ emoji: '🚀', titleKey: 'onboardingStep5Title', textKey: 'onboardingStep5Text' },
|
||||
];
|
||||
|
||||
// Сброс шага при каждом открытии модалки
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setStep(0);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const isLastStep = step === steps.length - 1;
|
||||
const currentStep = steps[step];
|
||||
|
||||
const handleNext = () => {
|
||||
if (isLastStep) {
|
||||
if (onComplete) onComplete();
|
||||
onClose();
|
||||
} else {
|
||||
setStep(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleRestart = () => {
|
||||
setStep(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(20,22,35,0.45)', zIndex: 1000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '20px',
|
||||
backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)',
|
||||
animation: 'onbFadeIn 0.25s ease',
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) handleSkip(); }}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes onbFadeIn {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
@keyframes onbSlideUp {
|
||||
0% { transform: translateY(20px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes onbEmojiPop {
|
||||
0% { transform: scale(0.4); opacity: 0; }
|
||||
60% { transform: scale(1.15); opacity: 1; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
@keyframes onbContentFade {
|
||||
0% { transform: translateY(8px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div
|
||||
style={glass({
|
||||
borderRadius: radius.lg,
|
||||
padding: '28px 24px 24px 24px',
|
||||
width: '100%',
|
||||
maxWidth: '400px',
|
||||
maxHeight: '90vh',
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
animation: 'onbSlideUp 0.3s ease',
|
||||
})}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Кнопка закрытия ✕ */}
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '12px',
|
||||
right: '12px',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
border: '1px solid rgba(0,0,0,0.06)',
|
||||
background: 'rgba(255,255,255,0.7)',
|
||||
color: subColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.9)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
{/* Эмодзи шага — с pop-анимацией, key для перезапуска анимации */}
|
||||
<div
|
||||
key={`emoji-${step}`}
|
||||
style={{
|
||||
fontSize: '64px',
|
||||
textAlign: 'center',
|
||||
marginBottom: '16px',
|
||||
lineHeight: '1',
|
||||
animation: 'onbEmojiPop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{currentStep.emoji}
|
||||
</div>
|
||||
|
||||
{/* Контент шага — с fade-анимацией, key для перезапуска */}
|
||||
<div
|
||||
key={`content-${step}`}
|
||||
style={{
|
||||
animation: 'onbContentFade 0.3s ease',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<h2 style={{
|
||||
margin: '0 0 12px 0',
|
||||
fontSize: '22px',
|
||||
fontWeight: 800,
|
||||
color: textColor,
|
||||
textAlign: 'center',
|
||||
letterSpacing: '-0.3px',
|
||||
}}>
|
||||
{t(currentStep.titleKey)}
|
||||
</h2>
|
||||
|
||||
<p style={{
|
||||
margin: 0,
|
||||
fontSize: '15px',
|
||||
lineHeight: '1.6',
|
||||
color: textColor,
|
||||
textAlign: 'center',
|
||||
whiteSpace: 'pre-line',
|
||||
}}>
|
||||
{t(currentStep.textKey)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Точки-индикаторы */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
margin: '24px 0 20px 0',
|
||||
}}>
|
||||
{steps.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => setStep(i)}
|
||||
style={{
|
||||
width: i === step ? '24px' : '8px',
|
||||
height: '8px',
|
||||
borderRadius: '4px',
|
||||
background: i === step ? brandColor : 'rgba(0,0,0,0.15)',
|
||||
transition: 'all 0.3s ease',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Основная кнопка */}
|
||||
<button
|
||||
onClick={handleNext}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '16px 20px',
|
||||
borderRadius: radius.lg,
|
||||
border: 'none',
|
||||
background: `linear-gradient(135deg, ${brandColor}, #6B46C1)`,
|
||||
color: '#FFFFFF',
|
||||
fontSize: '16px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
boxShadow: `0 6px 20px ${brandColor}50`,
|
||||
transition: 'all 0.2s ease',
|
||||
letterSpacing: '0.2px',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.97)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
{isLastStep ? t('onboardingStart') : `${t('onboardingNext')} →`}
|
||||
</button>
|
||||
|
||||
{/* Кнопка «Посмотреть ещё раз» — только на последнем шаге */}
|
||||
{isLastStep && (
|
||||
<button
|
||||
onClick={handleRestart}
|
||||
style={{
|
||||
width: '100%',
|
||||
marginTop: '10px',
|
||||
padding: '12px 20px',
|
||||
borderRadius: radius.md,
|
||||
border: '1px solid rgba(0,0,0,0.08)',
|
||||
background: 'rgba(255,255,255,0.5)',
|
||||
color: subColor,
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.97)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
↻ {t('onboardingRestart')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Кнопка «Пропустить» — только не на последнем шаге */}
|
||||
{!isLastStep && step > 0 && (
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
style={{
|
||||
width: '100%',
|
||||
marginTop: '10px',
|
||||
padding: '12px 20px',
|
||||
borderRadius: radius.md,
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: subColor,
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{t('onboardingSkip')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingModal;
|
||||
@@ -0,0 +1,360 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useLanguage } from '../i18n/LanguageContext';
|
||||
import { radius } from '../styles/theme';
|
||||
import { getNextPronunciationWord, getTTS } from '../api';
|
||||
|
||||
interface PronunciationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface PronunciationWord {
|
||||
word_pl: string;
|
||||
translation: string;
|
||||
level: string;
|
||||
}
|
||||
|
||||
const levelColors: Record<string, string> = {
|
||||
A1: '#2ECC71',
|
||||
A2: '#27AE60',
|
||||
B1: '#F39C12',
|
||||
B2: '#E67E22',
|
||||
C1: '#E74C3C',
|
||||
C2: '#C0392B',
|
||||
};
|
||||
|
||||
const PronunciationModal: React.FC<PronunciationModalProps> = ({ isOpen, onClose }) => {
|
||||
const { t } = useLanguage();
|
||||
const [currentWord, setCurrentWord] = useState<PronunciationWord | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
const brandColor = '#2ECC71';
|
||||
const textColor = '#1A1A2E';
|
||||
const subColor = '#6B7280';
|
||||
const cardBg = 'rgba(255,255,255,0.55)';
|
||||
|
||||
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
||||
background: cardBg,
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 8px 32px rgba(31,38,73,0.10), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
const stopAudio = () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
}
|
||||
if ('speechSynthesis' in window) {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
};
|
||||
|
||||
const playWord = async (word: string) => {
|
||||
stopAudio();
|
||||
setIsPlaying(true);
|
||||
try {
|
||||
const audioBlob = await getTTS(word, 'male');
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
const audio = new Audio(audioUrl);
|
||||
audioRef.current = audio;
|
||||
audio.onended = () => setIsPlaying(false);
|
||||
audio.onerror = () => setIsPlaying(false);
|
||||
await audio.play();
|
||||
} catch (e) {
|
||||
console.error('TTS error:', e);
|
||||
setIsPlaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchWord = useCallback(() => {
|
||||
setLoading(true);
|
||||
setIsPlaying(false);
|
||||
stopAudio();
|
||||
|
||||
getNextPronunciationWord()
|
||||
.then((data: any) => {
|
||||
if (isMountedRef.current) {
|
||||
setCurrentWord(data);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (isMountedRef.current) setCurrentWord(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (isMountedRef.current) setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
isMountedRef.current = true;
|
||||
fetchWord();
|
||||
}
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
stopAudio();
|
||||
};
|
||||
}, [isOpen, fetchWord]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const levelColor = currentWord?.level ? levelColors[currentWord.level] || brandColor : brandColor;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(20,22,35,0.25)', zIndex: 600,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '20px',
|
||||
backdropFilter: 'blur(4px)', WebkitBackdropFilter: 'blur(4px)',
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes pronunciationModalFadeIn { 0% { opacity: 0; } 100% { opacity: 1; } }
|
||||
@keyframes pronunciationModalSlideUp { 0% { transform: translateY(20px); opacity: 0; } 100% { transform: translateY(0); opacity: 1; } }
|
||||
@keyframes pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.1); } }
|
||||
@keyframes soundWave { 0%, 100% { height: 8px; } 50% { height: 24px; } }
|
||||
`}</style>
|
||||
|
||||
<div
|
||||
style={{
|
||||
...glass({
|
||||
borderRadius: radius.lg,
|
||||
padding: '24px',
|
||||
width: '100%',
|
||||
maxWidth: '420px',
|
||||
maxHeight: '85vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 24px 60px rgba(31,38,73,0.20), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
}),
|
||||
animation: 'pronunciationModalSlideUp 0.3s ease',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Шапка: строка 1 — кнопки управления, строка 2 — заголовок с уровнем */}
|
||||
<div style={{
|
||||
marginBottom: '16px',
|
||||
paddingBottom: '12px',
|
||||
borderBottom: '1px solid rgba(0,0,0,0.06)',
|
||||
}}>
|
||||
{/* Строка 1: Назад + Крестик */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '12px',
|
||||
gap: '8px',
|
||||
}}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
...glass({ padding: '8px 14px', borderRadius: radius.sm }),
|
||||
color: textColor,
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.95)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
← {t('back')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
style={{
|
||||
...glass({ padding: '8px 14px', borderRadius: radius.sm }),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.9)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Строка 2: заголовок + уровень, по центру */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
<h3 style={{
|
||||
margin: 0,
|
||||
fontSize: '18px',
|
||||
fontWeight: 800,
|
||||
color: textColor,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
🔊 {t('pronunciation')}
|
||||
</h3>
|
||||
{currentWord?.level && (
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 800,
|
||||
color: levelColor,
|
||||
background: `${levelColor}15`,
|
||||
padding: '3px 8px',
|
||||
borderRadius: radius.sm,
|
||||
border: `1px solid ${levelColor}40`,
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{currentWord.level}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: subColor, fontSize: '14px' }}>
|
||||
{t('loading')}
|
||||
</div>
|
||||
) : !currentWord ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: subColor, fontSize: '14px' }}>
|
||||
{t('noLevels')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Карточка слова */}
|
||||
<div
|
||||
onClick={() => playWord(currentWord.word_pl)}
|
||||
style={{
|
||||
...glass({
|
||||
borderRadius: radius.lg,
|
||||
padding: '28px 20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
cursor: 'pointer',
|
||||
}),
|
||||
border: `2px solid ${levelColor}30`,
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.98)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
{/* Иконка динамика */}
|
||||
<div style={{
|
||||
width: '64px',
|
||||
height: '64px',
|
||||
borderRadius: '50%',
|
||||
background: `${levelColor}20`,
|
||||
border: `2px solid ${levelColor}50`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
animation: isPlaying ? 'pulse 1s ease infinite' : 'none',
|
||||
}}>
|
||||
<span style={{ fontSize: '28px' }}>🔊</span>
|
||||
</div>
|
||||
|
||||
{/* Польское слово */}
|
||||
<div style={{
|
||||
fontSize: '32px',
|
||||
fontWeight: 900,
|
||||
color: textColor,
|
||||
textAlign: 'center',
|
||||
letterSpacing: '1px',
|
||||
lineHeight: '1.2',
|
||||
}}>
|
||||
{currentWord.word_pl}
|
||||
</div>
|
||||
|
||||
{/* Разделитель */}
|
||||
<div style={{
|
||||
width: '50px',
|
||||
height: '2px',
|
||||
background: `${levelColor}40`,
|
||||
borderRadius: '1px',
|
||||
}} />
|
||||
|
||||
{/* Перевод */}
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
color: subColor,
|
||||
textAlign: 'center',
|
||||
fontWeight: 400,
|
||||
}}>
|
||||
{currentWord.translation}
|
||||
</div>
|
||||
|
||||
{/* Индикатор воспроизведения */}
|
||||
{isPlaying && (
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'flex-end', height: '24px' }}>
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
width: '4px',
|
||||
height: '8px',
|
||||
background: levelColor,
|
||||
borderRadius: '2px',
|
||||
animation: `soundWave 0.6s ease infinite ${i * 0.1}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Подсказка */}
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
fontSize: '12px',
|
||||
color: subColor,
|
||||
marginBottom: '16px',
|
||||
}}>
|
||||
{t('tapToListen')}
|
||||
</div>
|
||||
|
||||
{/* Кнопка Далее */}
|
||||
<button
|
||||
onClick={fetchWord}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '14px',
|
||||
borderRadius: radius.lg,
|
||||
border: 'none',
|
||||
background: `linear-gradient(180deg, ${levelColor}E6, ${levelColor}CC)`,
|
||||
color: '#FFFFFF',
|
||||
fontSize: '15px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
boxShadow: `0 8px 24px ${levelColor}50`,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.96)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
{t('next')} →
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PronunciationModal;
|
||||
@@ -0,0 +1,118 @@
|
||||
import { colors, radius } from '../styles/theme';
|
||||
|
||||
interface RulesModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const RulesModal: React.FC<RulesModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
content,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
zIndex: 500,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
animation: 'modalFadeIn 0.2s ease',
|
||||
padding: '20px',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
background: colors.card,
|
||||
borderRadius: radius.lg,
|
||||
padding: '24px',
|
||||
width: '100%',
|
||||
maxWidth: '400px',
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
animation: 'modalSlideUp 0.3s ease',
|
||||
}}>
|
||||
<style>{`
|
||||
@keyframes modalFadeIn {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
@keyframes modalSlideUp {
|
||||
0% { transform: translateY(30px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Заголовок */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px',
|
||||
paddingBottom: '12px',
|
||||
borderBottom: `1px solid ${colors.border}`,
|
||||
}}>
|
||||
<h3 style={{
|
||||
margin: 0,
|
||||
fontSize: '18px',
|
||||
fontWeight: 700,
|
||||
color: colors.text,
|
||||
}}>
|
||||
{title}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: radius.md,
|
||||
border: `1px solid ${colors.border}`,
|
||||
background: colors.bgGradient,
|
||||
color: colors.text,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s ease',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(0.95)';
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Содержимое */}
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.7',
|
||||
color: colors.text,
|
||||
whiteSpace: 'pre-line',
|
||||
}}>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RulesModal;
|
||||
@@ -0,0 +1,561 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useLanguage } from '../i18n/LanguageContext';
|
||||
import { fonts, radius } from '../styles/theme';
|
||||
import {
|
||||
sendSupportMessage,
|
||||
getUserMessages,
|
||||
deleteTicket,
|
||||
getAdminTickets,
|
||||
getAdminMessages,
|
||||
adminReply,
|
||||
translateMessage,
|
||||
getUserTickets,
|
||||
} from '../api';
|
||||
import { getUserId } from '../telegram';
|
||||
|
||||
interface SupportChatModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
isAdmin: boolean;
|
||||
/** Вызывается, когда счётчик непрочитанных мог измениться (прочитали / отправили / удалили) */
|
||||
onUnreadChange?: () => void;
|
||||
}
|
||||
|
||||
interface SupportMessage {
|
||||
id: number;
|
||||
sender_id: number;
|
||||
message: string;
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface SupportTicket {
|
||||
id: number;
|
||||
user_id?: number;
|
||||
status: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
username?: string | null;
|
||||
app_language?: string;
|
||||
unread_count?: number;
|
||||
last_message?: string | null;
|
||||
}
|
||||
|
||||
const SupportChatModal: React.FC<SupportChatModalProps> = ({ isOpen, onClose, isAdmin, onUnreadChange }) => {
|
||||
const { language } = useLanguage();
|
||||
const [messages, setMessages] = useState<SupportMessage[]>([]);
|
||||
const [tickets, setTickets] = useState<SupportTicket[]>([]);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedTicket, setSelectedTicket] = useState<SupportTicket | null>(null);
|
||||
const [showTicketList, setShowTicketList] = useState(isAdmin);
|
||||
const [translatedText, setTranslatedText] = useState<Record<number, string>>({});
|
||||
const [isTranslating, setIsTranslating] = useState<number | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const userId = getUserId();
|
||||
|
||||
const supportColor = '#FF6B6B';
|
||||
const textColor = '#1A1A2E';
|
||||
const subColor = '#6B7280';
|
||||
const cardBg = 'rgba(255,255,255,0.55)';
|
||||
|
||||
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
||||
background: cardBg,
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 8px 32px rgba(31,38,73,0.10), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
const getLabel = (pl: string, ru: string, uk: string, en: string): string => {
|
||||
const labels: Record<string, string> = { pl, ru, uk, en };
|
||||
return labels[language] || labels.en;
|
||||
};
|
||||
|
||||
const getBackLabel = () => getLabel('Powrót', 'Назад', 'Назад', 'Back');
|
||||
const getPlaceholderLabel = () => getLabel('Napisz wiadomość...', 'Сообщение...', 'Повідомлення...', 'Message...');
|
||||
const getSendLabel = () => getLabel('Wyślij', 'Отправить', 'Надіслати', 'Send');
|
||||
const getNoTicketsLabel = () => getLabel('Brak zgłoszeń', 'Нет обращений', 'Немає звернень', 'No tickets');
|
||||
const getLoadingLabel = () => getLabel('Ładowanie...', 'Загрузка...', 'Завантаження...', 'Loading...');
|
||||
const getDescriptionLabel = () => getLabel(
|
||||
'Masz pytanie lub znalazłeś błąd? Napisz do nas!',
|
||||
'Есть вопрос или нашёл ошибку? Напиши нам!',
|
||||
'Є питання або знайшов помилку? Напиши нам!',
|
||||
'Have a question or found a bug? Write to us!'
|
||||
);
|
||||
|
||||
const loadAdminTickets = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await getAdminTickets();
|
||||
setTickets(data.tickets || []);
|
||||
} catch (e) {
|
||||
console.error('Error loading tickets:', e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadAdminMessages = useCallback(async (ticketId: number) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await getAdminMessages(ticketId);
|
||||
setMessages(data.messages || []);
|
||||
} catch (e) {
|
||||
console.error('Error loading admin messages:', e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadUserMessages = useCallback(async (ticketId: number) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await getUserMessages(ticketId);
|
||||
setMessages(data.messages || []);
|
||||
// 🔧 Бэк пометил ответы админа как прочитанные — сообщаем родителю
|
||||
if (onUnreadChange) onUnreadChange();
|
||||
} catch (e) {
|
||||
console.error('Error loading messages:', e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [onUnreadChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setMessages([]);
|
||||
setTickets([]);
|
||||
setInputValue('');
|
||||
setSelectedTicket(null);
|
||||
setShowTicketList(isAdmin);
|
||||
setTranslatedText({});
|
||||
setIsDeleting(false);
|
||||
|
||||
if (isAdmin) {
|
||||
loadAdminTickets();
|
||||
} else {
|
||||
const loadUserTicket = async () => {
|
||||
try {
|
||||
const data = await getUserTickets();
|
||||
const openTickets = data.tickets?.filter(t => t.status === 'open') || [];
|
||||
if (openTickets.length > 0) {
|
||||
setSelectedTicket(openTickets[0]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading user tickets:', e);
|
||||
}
|
||||
};
|
||||
loadUserTicket();
|
||||
}
|
||||
}
|
||||
}, [isOpen, isAdmin, loadAdminTickets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTicket) {
|
||||
if (isAdmin) {
|
||||
loadAdminMessages(selectedTicket.id);
|
||||
} else {
|
||||
loadUserMessages(selectedTicket.id);
|
||||
}
|
||||
}
|
||||
}, [selectedTicket, isAdmin, loadAdminMessages, loadUserMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const handleSend = async () => {
|
||||
const text = inputValue.trim();
|
||||
if (!text || isLoading) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (isAdmin && selectedTicket) {
|
||||
await adminReply(selectedTicket.id, text);
|
||||
await loadAdminMessages(selectedTicket.id);
|
||||
} else {
|
||||
const result = await sendSupportMessage(text);
|
||||
if (result.ticket_id) {
|
||||
const ticketsData = await getUserTickets();
|
||||
const ticket = ticketsData.tickets?.find(t => t.id === result.ticket_id);
|
||||
if (ticket) {
|
||||
setSelectedTicket(ticket);
|
||||
}
|
||||
}
|
||||
if (selectedTicket) {
|
||||
await loadUserMessages(selectedTicket.id);
|
||||
}
|
||||
}
|
||||
setInputValue('');
|
||||
if (inputRef.current) {
|
||||
inputRef.current.style.height = 'auto';
|
||||
}
|
||||
// 🔧 Сообщаем родителю — счётчик мог измениться
|
||||
if (onUnreadChange) onUnreadChange();
|
||||
} catch (e) {
|
||||
console.error('Error sending message:', e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTranslate = async (msgId: number, text: string) => {
|
||||
if (translatedText[msgId] || isTranslating === msgId) return;
|
||||
|
||||
setIsTranslating(msgId);
|
||||
try {
|
||||
const targetLang = isAdmin ? 'ru' : language;
|
||||
const data = await translateMessage(text, targetLang);
|
||||
setTranslatedText(prev => ({ ...prev, [msgId]: data.translation }));
|
||||
} catch (e) {
|
||||
console.error('Error translating:', e);
|
||||
} finally {
|
||||
setIsTranslating(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Мгновенное удаление
|
||||
const handleDeleteClick = async () => {
|
||||
if (!selectedTicket || isDeleting) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteTicket(selectedTicket.id);
|
||||
setSelectedTicket(null);
|
||||
setMessages([]);
|
||||
setShowTicketList(isAdmin);
|
||||
if (isAdmin) {
|
||||
loadAdminTickets();
|
||||
}
|
||||
// 🔧 Сообщаем родителю — счётчик мог измениться
|
||||
if (onUnreadChange) onUnreadChange();
|
||||
} catch (e) {
|
||||
console.error('Error deleting ticket:', e);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setInputValue(e.target.value);
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = Math.min(e.target.scrollHeight, 100) + 'px';
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// 🔧 ИСПРАВЛЕНО: определяем "от кого" сообщение через isAdmin проп, а не через хардкод
|
||||
// Если я админ: мои сообщения — те, где sender_id === userId. Остальные — от пользователя.
|
||||
// Если я пользователь: мои сообщения — те, где sender_id === userId. Остальные — от админа.
|
||||
// В обоих случаях "свои" = sender_id === userId. Меняется только логика цвета.
|
||||
const isMessageFromMe = (senderId: number) => senderId === Number(userId);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: cardBg,
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 24px 60px rgba(31,38,73,0.20), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
borderRadius: radius.lg,
|
||||
padding: '20px',
|
||||
width: '100%',
|
||||
maxWidth: '420px',
|
||||
height: '80vh',
|
||||
maxHeight: '600px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
animation: 'modalSlideUp 0.3s ease',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes modalSlideUp {
|
||||
0% { transform: translateY(20px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes messageIn {
|
||||
0% { transform: translateY(10px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes fadeInUp {
|
||||
0% { transform: translateY(10px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Заголовок */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '16px',
|
||||
paddingBottom: '12px',
|
||||
borderBottom: '1px solid rgba(0,0,0,0.06)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{isAdmin && showTicketList ? (
|
||||
<span />
|
||||
) : isAdmin ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowTicketList(true);
|
||||
setSelectedTicket(null);
|
||||
}}
|
||||
style={{
|
||||
...glass({ padding: '8px 14px', borderRadius: radius.sm }),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
← {getBackLabel()}
|
||||
</button>
|
||||
) : null}
|
||||
<h3 style={{ margin: 0, fontSize: '18px', fontWeight: 800, color: textColor }}>
|
||||
📮 {getLabel('Wsparcie', 'Поддержка', 'Підтримка', 'Support')}
|
||||
</h3>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
{selectedTicket && (
|
||||
<button
|
||||
onClick={handleDeleteClick}
|
||||
disabled={isDeleting}
|
||||
style={{
|
||||
...glass({ padding: '8px 12px', borderRadius: radius.sm }),
|
||||
color: '#E74C3C',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{isDeleting ? '...' : '🗑️'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
...glass({ padding: '8px 14px', borderRadius: radius.sm }),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Список тикетов (для админа) */}
|
||||
{isAdmin && showTicketList && (
|
||||
<div style={{ flex: 1, overflow: 'auto', display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{isLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: subColor, fontSize: '14px' }}>
|
||||
{getLoadingLabel()}
|
||||
</div>
|
||||
) : tickets.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: subColor, fontSize: '14px' }}>
|
||||
{getNoTicketsLabel()}
|
||||
</div>
|
||||
) : (
|
||||
tickets.map((ticket, index) => (
|
||||
<button
|
||||
key={ticket.id}
|
||||
onClick={() => {
|
||||
setSelectedTicket(ticket);
|
||||
setShowTicketList(false);
|
||||
}}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '14px 16px',
|
||||
borderRadius: radius.md,
|
||||
textAlign: 'left',
|
||||
}),
|
||||
cursor: 'pointer',
|
||||
animation: `fadeInUp 0.3s ease ${index * 0.03}s both`,
|
||||
border: ticket.unread_count && ticket.unread_count > 0 ? `2px solid ${supportColor}50` : '1px solid rgba(255,255,255,0.7)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '6px' }}>
|
||||
<span style={{ fontSize: '14px', fontWeight: 700, color: textColor }}>
|
||||
{ticket.username || `ID: ${ticket.user_id}`}
|
||||
</span>
|
||||
{ticket.unread_count && ticket.unread_count > 0 ? (
|
||||
<span style={{
|
||||
minWidth: '22px',
|
||||
height: '22px',
|
||||
borderRadius: '11px',
|
||||
background: '#E74C3C',
|
||||
color: '#FFFFFF',
|
||||
fontSize: '11px',
|
||||
fontWeight: 800,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0 6px',
|
||||
}}>
|
||||
{ticket.unread_count}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: '11px', color: subColor }}>
|
||||
{ticket.app_language ? ticket.app_language.toUpperCase() : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{ticket.last_message && (
|
||||
<div style={{ fontSize: '12px', color: subColor, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{ticket.last_message}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Чат с сообщениями */}
|
||||
{(!isAdmin || (isAdmin && !showTicketList)) && (
|
||||
<>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px',
|
||||
paddingBottom: '12px',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}>
|
||||
{isLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: subColor, fontSize: '14px' }}>
|
||||
{getLoadingLabel()}
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: subColor, fontSize: '14px' }}>
|
||||
<div style={{ fontSize: '40px', marginBottom: '12px' }}>📮</div>
|
||||
{getDescriptionLabel()}
|
||||
</div>
|
||||
) : (
|
||||
messages.map((msg) => {
|
||||
const isFromMe = isMessageFromMe(msg.sender_id);
|
||||
const isTranslated = translatedText[msg.id];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
style={{
|
||||
alignSelf: isFromMe ? 'flex-end' : 'flex-start',
|
||||
maxWidth: '85%',
|
||||
animation: 'messageIn 0.3s ease',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
borderRadius: isFromMe ? '16px 16px 4px 16px' : '16px 16px 16px 4px',
|
||||
background: isFromMe
|
||||
? `linear-gradient(135deg, ${supportColor}, #E85D5D)`
|
||||
: 'rgba(255,255,255,0.8)',
|
||||
color: isFromMe ? '#FFFFFF' : textColor,
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
whiteSpace: 'pre-line',
|
||||
border: isFromMe ? 'none' : '1px solid rgba(0,0,0,0.06)',
|
||||
boxShadow: isFromMe
|
||||
? '0 2px 10px rgba(255,107,107,0.3)'
|
||||
: '0 2px 8px rgba(31,38,73,0.06)',
|
||||
wordBreak: 'break-word',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => handleTranslate(msg.id, msg.message)}
|
||||
>
|
||||
{isTranslated ? isTranslated : msg.message}
|
||||
{isTranslating === msg.id && (
|
||||
<span style={{ fontSize: '10px', opacity: 0.7 }}> ...</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
flexShrink: 0,
|
||||
paddingTop: '12px',
|
||||
borderTop: '1px solid rgba(0,0,0,0.06)',
|
||||
}}>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={handleTextareaChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={getPlaceholderLabel()}
|
||||
disabled={isLoading}
|
||||
rows={1}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px 16px',
|
||||
borderRadius: radius.md,
|
||||
border: `1px solid ${supportColor}30`,
|
||||
background: 'rgba(255,255,255,0.8)',
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontFamily: fonts.main,
|
||||
outline: 'none',
|
||||
resize: 'none',
|
||||
maxHeight: '100px',
|
||||
overflowY: 'auto',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = supportColor;
|
||||
e.currentTarget.style.boxShadow = `0 0 0 3px ${supportColor}15`;
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = `${supportColor}30`;
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={isLoading || !inputValue.trim()}
|
||||
style={{
|
||||
padding: '12px 18px',
|
||||
borderRadius: radius.md,
|
||||
border: 'none',
|
||||
background: isLoading || !inputValue.trim()
|
||||
? 'rgba(0,0,0,0.1)'
|
||||
: `linear-gradient(135deg, ${supportColor}, #E85D5D)`,
|
||||
color: '#FFFFFF',
|
||||
fontSize: '14px',
|
||||
fontWeight: 700,
|
||||
cursor: isLoading || !inputValue.trim() ? 'default' : 'pointer',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{getSendLabel()}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SupportChatModal;
|
||||
@@ -0,0 +1,960 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useLanguage } from '../i18n/LanguageContext';
|
||||
import { useLocalized } from '../hooks/useLocalized';
|
||||
import { fonts, radius } from '../styles/theme';
|
||||
import { polishAlphabet } from '../data/alphabet';
|
||||
import { grammarRules } from '../data/grammarRules';
|
||||
import { cheatsheets } from '../data/cheatsheets';
|
||||
import { falseFriendsRu, falseFriendsRuBonus, falseFriendsUk, falseFriendsUkBonus } from '../data/falseFriends';
|
||||
import { teacherTranslateToPolish, getTTS, getTeacherProgress, getPhraseOfDay, trackTeacherMenuOpened, trackProgressOpened, trackAlphabetOpened, trackRulesOpened, trackCheatsheetsOpened } from '../api';
|
||||
import { useAchievements } from '../hooks/useAchievements';
|
||||
|
||||
interface TeacherMenuModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onStartLesson: () => void;
|
||||
onClearChat: () => void;
|
||||
/** 🔧 Открывает модалку печати (рендерится в TeacherChatPage) */
|
||||
onOpenTyping: () => void;
|
||||
/** 🔧 Открывает модалку произношения (рендерится в TeacherChatPage) */
|
||||
onOpenPronunciation: () => void;
|
||||
}
|
||||
|
||||
type TabType = 'menu' | 'alphabet' | 'grammar' | 'cheatsheets' | 'translate' | 'progress' | 'phrase' | 'falseFriends';
|
||||
|
||||
interface LevelProgress {
|
||||
code: string;
|
||||
name_pl: string;
|
||||
name_ru: string;
|
||||
name_uk: string;
|
||||
name_en: string;
|
||||
topics_total: number;
|
||||
topics_completed: number;
|
||||
}
|
||||
|
||||
interface PhraseOfDay {
|
||||
phrase_pl: string;
|
||||
date: string | null;
|
||||
translation?: string;
|
||||
grammar_breakdown: string;
|
||||
}
|
||||
|
||||
const TeacherMenuModal: React.FC<TeacherMenuModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onStartLesson,
|
||||
onClearChat,
|
||||
onOpenTyping,
|
||||
onOpenPronunciation,
|
||||
}) => {
|
||||
const { t, language } = useLanguage();
|
||||
const getLocalized = useLocalized();
|
||||
const { activeAchievement, showAchievement } = useAchievements();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('menu');
|
||||
const [selectedLetter, setSelectedLetter] = useState<number | null>(null);
|
||||
const [selectedRule, setSelectedRule] = useState<number | null>(null);
|
||||
const [selectedCheatSheet, setSelectedCheatSheet] = useState<string | null>(null);
|
||||
const [translateInput, setTranslateInput] = useState('');
|
||||
const [translateResult, setTranslateResult] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [levelsProgress, setLevelsProgress] = useState<LevelProgress[]>([]);
|
||||
const [isProgressLoading, setIsProgressLoading] = useState(false);
|
||||
const [phraseOfDay, setPhraseOfDay] = useState<PhraseOfDay | null>(null);
|
||||
const [isPhraseLoading, setIsPhraseLoading] = useState(false);
|
||||
const [isPlayingPhrase, setIsPlayingPhrase] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const menuTrackedRef = useRef(false);
|
||||
|
||||
const brandColor = '#2ECC71';
|
||||
const teacherColor = '#8B5CF6';
|
||||
const falseFriendsColor = '#E8933F';
|
||||
const textColor = '#1A1A2E';
|
||||
const subColor = '#6B7280';
|
||||
const cardBg = 'rgba(255,255,255,0.55)';
|
||||
|
||||
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
||||
background: cardBg,
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 8px 32px rgba(31,38,73,0.10), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
const scrollToTop = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTop = 0;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
scrollToTop();
|
||||
if (!menuTrackedRef.current) {
|
||||
menuTrackedRef.current = true;
|
||||
trackTeacherMenuOpened()
|
||||
.then((data: any) => {
|
||||
if (data?.new_achievements && data.new_achievements.length > 0) {
|
||||
data.new_achievements.forEach((ach: any) => {
|
||||
showAchievement({
|
||||
id: ach.code,
|
||||
icon: ach.icon,
|
||||
name: ach.name,
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
}, [isOpen, showAchievement]);
|
||||
|
||||
const handleBack = () => {
|
||||
if (selectedLetter !== null) {
|
||||
setSelectedLetter(null);
|
||||
} else if (selectedRule !== null) {
|
||||
setSelectedRule(null);
|
||||
} else if (selectedCheatSheet !== null) {
|
||||
setSelectedCheatSheet(null);
|
||||
} else {
|
||||
setActiveTab('menu');
|
||||
}
|
||||
scrollToTop();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setActiveTab('menu');
|
||||
setSelectedLetter(null);
|
||||
setSelectedRule(null);
|
||||
setSelectedCheatSheet(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleOpenLetter = (index: number) => {
|
||||
setSelectedLetter(index);
|
||||
scrollToTop();
|
||||
};
|
||||
|
||||
const handleOpenRule = (ruleId: number) => {
|
||||
setSelectedRule(ruleId);
|
||||
scrollToTop();
|
||||
};
|
||||
|
||||
const handleOpenCheatSheet = (id: string) => {
|
||||
setSelectedCheatSheet(id);
|
||||
scrollToTop();
|
||||
};
|
||||
|
||||
const handleTabSwitch = (tab: TabType) => {
|
||||
setActiveTab(tab);
|
||||
scrollToTop();
|
||||
|
||||
if (tab === 'progress') {
|
||||
loadProgress();
|
||||
trackProgressOpened()
|
||||
.then((data: any) => {
|
||||
if (data?.new_achievements && data.new_achievements.length > 0) {
|
||||
data.new_achievements.forEach((ach: any) => {
|
||||
showAchievement({
|
||||
id: ach.code,
|
||||
icon: ach.icon,
|
||||
name: ach.name,
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
if (tab === 'phrase') {
|
||||
loadPhraseOfDay();
|
||||
}
|
||||
|
||||
if (tab === 'alphabet') {
|
||||
trackAlphabetOpened()
|
||||
.then((data: any) => {
|
||||
if (data?.new_achievements && data.new_achievements.length > 0) {
|
||||
data.new_achievements.forEach((ach: any) => {
|
||||
showAchievement({
|
||||
id: ach.code,
|
||||
icon: ach.icon,
|
||||
name: ach.name,
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
if (tab === 'grammar') {
|
||||
trackRulesOpened()
|
||||
.then((data: any) => {
|
||||
if (data?.new_achievements && data.new_achievements.length > 0) {
|
||||
data.new_achievements.forEach((ach: any) => {
|
||||
showAchievement({
|
||||
id: ach.code,
|
||||
icon: ach.icon,
|
||||
name: ach.name,
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
if (tab === 'cheatsheets') {
|
||||
trackCheatsheetsOpened()
|
||||
.then((data: any) => {
|
||||
if (data?.new_achievements && data.new_achievements.length > 0) {
|
||||
data.new_achievements.forEach((ach: any) => {
|
||||
showAchievement({
|
||||
id: ach.code,
|
||||
icon: ach.icon,
|
||||
name: ach.name,
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
const loadProgress = async () => {
|
||||
setIsProgressLoading(true);
|
||||
try {
|
||||
const data = await getTeacherProgress();
|
||||
setLevelsProgress(data.levels || []);
|
||||
} catch (e) {
|
||||
console.error('Error loading progress:', e);
|
||||
setLevelsProgress([]);
|
||||
} finally {
|
||||
setIsProgressLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPhraseOfDay = async () => {
|
||||
setIsPhraseLoading(true);
|
||||
try {
|
||||
const data = await getPhraseOfDay();
|
||||
setPhraseOfDay(data);
|
||||
} catch (e) {
|
||||
console.error('Error loading phrase of day:', e);
|
||||
setPhraseOfDay(null);
|
||||
} finally {
|
||||
setIsPhraseLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlayPhrase = async () => {
|
||||
if (!phraseOfDay || isPlayingPhrase) return;
|
||||
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
}
|
||||
|
||||
if ('speechSynthesis' in window) {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
|
||||
setIsPlayingPhrase(true);
|
||||
|
||||
try {
|
||||
const audioBlob = await getTTS(phraseOfDay.phrase_pl, 'male');
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
const audio = new Audio(audioUrl);
|
||||
audioRef.current = audio;
|
||||
audio.onended = () => setIsPlayingPhrase(false);
|
||||
audio.onerror = () => setIsPlayingPhrase(false);
|
||||
await audio.play();
|
||||
} catch (e) {
|
||||
setIsPlayingPhrase(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTranslateSubmit = async () => {
|
||||
const text = translateInput.trim();
|
||||
if (!text || isLoading) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await teacherTranslateToPolish(text);
|
||||
if (data.limit_exceeded && data.message) {
|
||||
setTranslateResult(data.message);
|
||||
} else if (data.response) {
|
||||
setTranslateResult(data.response);
|
||||
}
|
||||
} catch (e) {
|
||||
setTranslateResult('');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlayTranslate = async () => {
|
||||
if (!translateResult || isPlaying) return;
|
||||
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
}
|
||||
|
||||
if ('speechSynthesis' in window) {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
|
||||
setIsPlaying(true);
|
||||
|
||||
try {
|
||||
const audioBlob = await getTTS(translateResult, 'male');
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
const audio = new Audio(audioUrl);
|
||||
audioRef.current = audio;
|
||||
audio.onended = () => setIsPlaying(false);
|
||||
audio.onerror = () => setIsPlaying(false);
|
||||
await audio.play();
|
||||
} catch (e) {
|
||||
setIsPlaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlayLetter = async (word: string) => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
}
|
||||
if ('speechSynthesis' in window) {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
setIsPlaying(true);
|
||||
try {
|
||||
const audioBlob = await getTTS(word, 'male');
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
const audio = new Audio(audioUrl);
|
||||
audioRef.current = audio;
|
||||
audio.onended = () => setIsPlaying(false);
|
||||
audio.onerror = () => setIsPlaying(false);
|
||||
await audio.play();
|
||||
} catch (e) {
|
||||
setIsPlaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// 🔧 ОБНОВЛЕНО: кнопки меню — плотное стекло вместо прозрачного фиолетового.
|
||||
// Раньше был background: `${teacherColor}10` (10% непрозрачности) — кнопки сливались с фоном.
|
||||
// Теперь: 88% белого + чёткая обводка + лёгкая тень + backdrop-filter.
|
||||
const menuButtonStyle: React.CSSProperties = {
|
||||
padding: '14px 16px',
|
||||
borderRadius: radius.md,
|
||||
border: '1px solid rgba(255,255,255,0.85)',
|
||||
background: 'rgba(255,255,255,0.88)',
|
||||
backdropFilter: 'blur(12px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(12px) saturate(180%)',
|
||||
boxShadow: '0 4px 14px rgba(31,38,73,0.10), inset 0 1px 0 rgba(255,255,255,0.95)',
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
transition: 'all 0.2s ease',
|
||||
};
|
||||
|
||||
const isRuOrUk = language === 'ru' || language === 'uk';
|
||||
|
||||
const falseFriends = language === 'ru' ? falseFriendsRu : falseFriendsUk;
|
||||
const falseFriendsBonus = language === 'ru' ? falseFriendsRuBonus : falseFriendsUkBonus;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(20,22,35,0.25)', zIndex: 500,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '20px',
|
||||
backdropFilter: 'blur(4px)', WebkitBackdropFilter: 'blur(4px)',
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) handleClose(); }}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes modalFadeIn { 0% { opacity: 0; } 100% { opacity: 1; } }
|
||||
@keyframes modalSlideUp { 0% { transform: translateY(20px); opacity: 0; } 100% { transform: translateY(0); opacity: 1; } }
|
||||
@keyframes pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.1); } }
|
||||
@keyframes fadeInUp { 0% { transform: translateY(10px); opacity: 0; } 100% { transform: translateY(0); opacity: 1; } }
|
||||
@keyframes achievementSlideDown {
|
||||
0% { transform: translateX(-50%) translateY(-80px); opacity: 0; }
|
||||
100% { transform: translateX(-50%) translateY(0); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{activeAchievement && (
|
||||
<div style={{
|
||||
position: 'fixed', top: '16px', left: '50%', transform: 'translateX(-50%)', zIndex: 900,
|
||||
background: 'linear-gradient(135deg, #FFD700, #FFA500)',
|
||||
color: '#FFFFFF', padding: '14px 20px', borderRadius: radius.lg,
|
||||
boxShadow: '0 8px 30px rgba(255, 215, 0, 0.5)',
|
||||
display: 'flex', alignItems: 'center', gap: '12px',
|
||||
minWidth: '250px', maxWidth: '90vw', animation: 'achievementSlideDown 0.4s ease',
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
<span style={{ fontSize: '28px', flexShrink: 0 }}>{activeAchievement.icon}</span>
|
||||
<div>
|
||||
<div style={{ fontSize: '11px', fontWeight: 600, opacity: 0.9, letterSpacing: '1px', textTransform: 'uppercase' }}>
|
||||
🏆 {t('achievement')}
|
||||
</div>
|
||||
<div style={{ fontSize: '15px', fontWeight: 800, marginTop: '2px' }}>
|
||||
{activeAchievement.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
style={{
|
||||
...glass({
|
||||
borderRadius: radius.lg,
|
||||
padding: '20px',
|
||||
width: '100%',
|
||||
maxWidth: '420px',
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 24px 60px rgba(31,38,73,0.20), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
}),
|
||||
animation: 'modalSlideUp 0.3s ease',
|
||||
}}
|
||||
>
|
||||
{/* Заголовок */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '16px',
|
||||
paddingBottom: '12px',
|
||||
borderBottom: '1px solid rgba(0,0,0,0.06)',
|
||||
}}>
|
||||
{activeTab !== 'menu' ? (
|
||||
<button
|
||||
onClick={handleBack}
|
||||
style={{ ...glass({ padding: '8px 14px', borderRadius: radius.sm }), color: textColor, fontSize: '14px', fontWeight: 600, cursor: 'pointer' }}
|
||||
>
|
||||
← {t('back')}
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<h3 style={{ margin: 0, fontSize: '18px', fontWeight: 800, color: textColor }}>
|
||||
{activeTab === 'menu' ? `☰ ${t('teacherMenu')}` :
|
||||
activeTab === 'alphabet' ? `🔤 ${t('teacherMenuAlphabet')}` :
|
||||
activeTab === 'grammar' ? `📐 ${t('teacherMenuRules')}` :
|
||||
activeTab === 'cheatsheets' ? `📝 ${t('teacherMenuCheatsheets')}` :
|
||||
activeTab === 'progress' ? `📊 ${t('teacherMenuProgress')}` :
|
||||
activeTab === 'phrase' ? `💬 ${t('teacherMenuPhrase')}` :
|
||||
activeTab === 'falseFriends' ? `⚠️ ${t('teacherMenuFalseFriends')}` :
|
||||
`🔄 ${t('teacherMenuTranslate')}`}
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
style={{ ...glass({ padding: '8px 14px', borderRadius: radius.sm }), color: textColor, fontSize: '14px', cursor: 'pointer' }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ====== МЕНЮ ====== */}
|
||||
{activeTab === 'menu' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
<button onClick={() => { handleClose(); onStartLesson(); }} style={menuButtonStyle}>
|
||||
<span>📚</span> {t('teacherMenuStart')}
|
||||
</button>
|
||||
<button onClick={() => { handleTabSwitch('phrase'); }} style={menuButtonStyle}>
|
||||
<span>💬</span> {t('teacherMenuPhrase')}
|
||||
</button>
|
||||
|
||||
{/* 🔧 НОВОЕ: Произношение — перед алфавитом */}
|
||||
<button onClick={() => { handleClose(); onOpenPronunciation(); }} style={menuButtonStyle}>
|
||||
<span>🔊</span> {t('pronunciation')}
|
||||
</button>
|
||||
|
||||
{/* 🔧 НОВОЕ: Печать — перед алфавитом */}
|
||||
<button onClick={() => { handleClose(); onOpenTyping(); }} style={menuButtonStyle}>
|
||||
<span>⌨️</span> {t('typingTitle')}
|
||||
</button>
|
||||
|
||||
<button onClick={() => { setSelectedLetter(null); handleTabSwitch('alphabet'); }} style={menuButtonStyle}>
|
||||
<span>🔤</span> {t('teacherMenuAlphabet')}
|
||||
</button>
|
||||
<button onClick={() => { setSelectedRule(null); handleTabSwitch('grammar'); }} style={menuButtonStyle}>
|
||||
<span>📐</span> {t('teacherMenuRules')}
|
||||
</button>
|
||||
<button onClick={() => { setSelectedCheatSheet(null); handleTabSwitch('cheatsheets'); }} style={menuButtonStyle}>
|
||||
<span>📝</span> {t('teacherMenuCheatsheets')}
|
||||
</button>
|
||||
{isRuOrUk && (
|
||||
<button onClick={() => { handleTabSwitch('falseFriends'); }} style={menuButtonStyle}>
|
||||
<span>⚠️</span> {t('teacherMenuFalseFriends')}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => { handleTabSwitch('progress'); }} style={menuButtonStyle}>
|
||||
<span>📊</span> {t('teacherMenuProgress')}
|
||||
</button>
|
||||
<button onClick={() => { setTranslateInput(''); setTranslateResult(''); handleTabSwitch('translate'); }} style={menuButtonStyle}>
|
||||
<span>🔄</span> {t('teacherMenuTranslate')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { handleClose(); onClearChat(); }}
|
||||
style={{
|
||||
...menuButtonStyle,
|
||||
border: '1px solid rgba(231,76,60,0.4)',
|
||||
background: 'rgba(231,76,60,0.10)',
|
||||
color: '#E74C3C',
|
||||
}}
|
||||
>
|
||||
<span>🗑️</span> {t('teacherMenuReset')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ====== ЛОЖНЫЕ ДРУЗЬЯ ====== */}
|
||||
{activeTab === 'falseFriends' && isRuOrUk && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{/* Основной список */}
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', fontWeight: 800, color: falseFriendsColor, marginBottom: '8px' }}>
|
||||
📌 {t('teacherMenuMain')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{falseFriends.map((ff) => (
|
||||
<div
|
||||
key={ff.id}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '12px 14px',
|
||||
borderRadius: radius.md,
|
||||
}),
|
||||
border: `1px solid ${falseFriendsColor}20`,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '15px', fontWeight: 700, color: textColor, marginBottom: '4px' }}>
|
||||
🇵🇱 {ff.word_pl}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#E74C3C' }}>
|
||||
❌ {t('teacherMenuSeems')}: {ff.false_meaning}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: brandColor }}>
|
||||
✅ {t('teacherMenuActually')}: {ff.true_meaning}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Бонусный список */}
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', fontWeight: 800, color: falseFriendsColor, marginBottom: '8px' }}>
|
||||
🎁 {t('teacherMenuBonus')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{falseFriendsBonus.map((ff) => (
|
||||
<div
|
||||
key={ff.id}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '12px 14px',
|
||||
borderRadius: radius.md,
|
||||
}),
|
||||
border: `1px solid ${falseFriendsColor}20`,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '15px', fontWeight: 700, color: textColor, marginBottom: '4px' }}>
|
||||
🇵🇱 {ff.word_pl}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
||||
<div style={{ fontSize: '12px', color: subColor }}>
|
||||
👁️ {t('teacherMenuObvious')}: {ff.obvious_meaning}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: brandColor }}>
|
||||
➕ {t('teacherMenuAdditional')}: {ff.additional_meaning}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ====== ФРАЗА ДНЯ ====== */}
|
||||
{activeTab === 'phrase' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{isPhraseLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: subColor, fontSize: '14px' }}>
|
||||
{t('loading')}
|
||||
</div>
|
||||
) : !phraseOfDay ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: subColor, fontSize: '14px' }}>
|
||||
{t('noLevels')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', animation: 'fadeInUp 0.3s ease' }}>
|
||||
<div style={{
|
||||
...glass({
|
||||
padding: '20px',
|
||||
borderRadius: radius.lg,
|
||||
textAlign: 'center',
|
||||
}),
|
||||
border: `2px solid ${teacherColor}40`,
|
||||
}}>
|
||||
<div style={{ fontSize: '12px', fontWeight: 600, color: subColor, marginBottom: '8px', letterSpacing: '0.5px', textTransform: 'uppercase' }}>
|
||||
🇵🇱 {t('teacherMenuPhrase')}
|
||||
</div>
|
||||
<div style={{ fontSize: '22px', fontWeight: 800, color: textColor, lineHeight: '1.4', marginBottom: '12px' }}>
|
||||
{phraseOfDay.phrase_pl}
|
||||
</div>
|
||||
<button
|
||||
onClick={handlePlayPhrase}
|
||||
disabled={isPlayingPhrase}
|
||||
style={{
|
||||
width: '44px', height: '44px', borderRadius: '50%',
|
||||
border: `2px solid ${teacherColor}40`,
|
||||
background: isPlayingPhrase ? `${teacherColor}30` : 'rgba(255,255,255,0.9)',
|
||||
color: teacherColor,
|
||||
fontSize: '18px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '0 auto',
|
||||
animation: isPlayingPhrase ? 'pulse 0.5s ease infinite' : 'none',
|
||||
boxShadow: '0 2px 10px rgba(139,92,246,0.2)',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.9)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
🔊
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{language !== 'pl' && phraseOfDay.translation && (
|
||||
<div style={{
|
||||
...glass({
|
||||
padding: '16px',
|
||||
borderRadius: radius.lg,
|
||||
}),
|
||||
border: `1px solid ${brandColor}30`,
|
||||
}}>
|
||||
<div style={{ fontSize: '12px', fontWeight: 600, color: brandColor, marginBottom: '8px', letterSpacing: '0.5px', textTransform: 'uppercase' }}>
|
||||
🔄 {t('teacherMenuTranslation')}
|
||||
</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: 600, color: textColor, lineHeight: '1.4' }}>
|
||||
{phraseOfDay.translation}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phraseOfDay.grammar_breakdown && (
|
||||
<div style={{
|
||||
...glass({
|
||||
padding: '16px',
|
||||
borderRadius: radius.lg,
|
||||
}),
|
||||
border: `1px solid ${teacherColor}20`,
|
||||
}}>
|
||||
<div style={{ fontSize: '12px', fontWeight: 600, color: teacherColor, marginBottom: '8px', letterSpacing: '0.5px', textTransform: 'uppercase' }}>
|
||||
📖 {t('teacherMenuGrammar')}
|
||||
</div>
|
||||
<div style={{ fontSize: '13px', color: textColor, lineHeight: '1.6', whiteSpace: 'pre-line' }}>
|
||||
{phraseOfDay.grammar_breakdown}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ====== ПРОГРЕСС ====== */}
|
||||
{activeTab === 'progress' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{isProgressLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: subColor, fontSize: '14px' }}>
|
||||
{t('loading')}
|
||||
</div>
|
||||
) : levelsProgress.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: subColor, fontSize: '14px' }}>
|
||||
{t('noLevels')}
|
||||
</div>
|
||||
) : (
|
||||
levelsProgress.map((level, index) => {
|
||||
const percent = level.topics_total > 0
|
||||
? Math.round((level.topics_completed / level.topics_total) * 100)
|
||||
: 0;
|
||||
const isCompleted = level.topics_completed >= level.topics_total && level.topics_total > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={level.code}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '14px 16px',
|
||||
borderRadius: radius.lg,
|
||||
}),
|
||||
animation: `fadeInUp 0.3s ease ${index * 0.05}s both`,
|
||||
border: isCompleted ? `2px solid ${brandColor}50` : '1px solid rgba(255,255,255,0.7)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<span style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: 800,
|
||||
color: teacherColor,
|
||||
background: `${teacherColor}15`,
|
||||
padding: '4px 10px',
|
||||
borderRadius: radius.sm,
|
||||
border: `1px solid ${teacherColor}40`,
|
||||
minWidth: '36px',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
{level.code}
|
||||
</span>
|
||||
<span style={{ fontSize: '13px', fontWeight: 700, color: textColor }}>
|
||||
{getLocalized(level, 'name')}
|
||||
</span>
|
||||
</div>
|
||||
{isCompleted ? (
|
||||
<span style={{ fontSize: '18px' }}>✅</span>
|
||||
) : level.topics_completed > 0 ? (
|
||||
<span style={{ fontSize: '14px', fontWeight: 700, color: brandColor }}>
|
||||
{Math.round((level.topics_completed / level.topics_total) * 100)}%
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: '12px', color: subColor }}>—</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '6px',
|
||||
background: 'rgba(0,0,0,0.04)',
|
||||
borderRadius: '3px',
|
||||
overflow: 'hidden',
|
||||
marginBottom: '8px',
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${percent}%`,
|
||||
height: '100%',
|
||||
background: isCompleted
|
||||
? `linear-gradient(90deg, ${brandColor}, #27AE60)`
|
||||
: `linear-gradient(90deg, ${teacherColor}, #6B46C1)`,
|
||||
borderRadius: '3px',
|
||||
transition: 'width 0.5s ease',
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '12px', color: subColor, fontWeight: 500 }}>
|
||||
{t('teacherMenuTopicsCompleted')}: {level.topics_completed} {t('teacherMenuOf')} {level.topics_total}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ====== АЛФАВИТ ====== */}
|
||||
{activeTab === 'alphabet' && selectedLetter === null && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(55px, 1fr))', gap: '8px' }}>
|
||||
{polishAlphabet.map((letter, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => handleOpenLetter(i)}
|
||||
style={{
|
||||
...glass({ padding: '12px 6px', borderRadius: radius.sm }),
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '4px',
|
||||
color: textColor, fontSize: '18px', fontWeight: 700, cursor: 'pointer',
|
||||
minHeight: '55px',
|
||||
}}
|
||||
>
|
||||
<span style={{ lineHeight: '1.2' }}>{letter.upper}</span>
|
||||
<span style={{ fontSize: '11px', color: subColor, lineHeight: '1.2' }}>{letter.lower}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'alphabet' && selectedLetter !== null && (
|
||||
<div>
|
||||
{(() => {
|
||||
const letter = polishAlphabet[selectedLetter];
|
||||
return (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '56px', fontWeight: 900, color: teacherColor, marginBottom: '8px', lineHeight: '1.2' }}>
|
||||
{letter.upper} <span style={{ fontSize: '42px' }}>{letter.lower}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: textColor, marginBottom: '8px' }}>
|
||||
{getLocalized(letter, 'pronunciation')}
|
||||
</div>
|
||||
<div
|
||||
onClick={() => handlePlayLetter(letter.example_pl)}
|
||||
style={{
|
||||
padding: '14px', background: `${teacherColor}10`, borderRadius: radius.md,
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '16px', fontWeight: 700, color: textColor }}>🇵🇱 {letter.example_pl}</span>
|
||||
<span style={{ fontSize: '14px', color: teacherColor }}>🔊</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: subColor, marginTop: '8px' }}>
|
||||
{getLocalized(letter, 'example')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ====== ПРАВИЛА ====== */}
|
||||
{activeTab === 'grammar' && selectedRule === null && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{grammarRules.map((rule) => (
|
||||
<button
|
||||
key={rule.id}
|
||||
onClick={() => handleOpenRule(rule.id)}
|
||||
style={{ ...menuButtonStyle, textAlign: 'left' }}
|
||||
>
|
||||
<span>📐</span> {getLocalized(rule, 'title')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'grammar' && selectedRule !== null && (
|
||||
<div>
|
||||
{(() => {
|
||||
const rule = grammarRules.find(r => r.id === selectedRule);
|
||||
if (!rule) return null;
|
||||
return (
|
||||
<div>
|
||||
<p style={{ fontSize: '13px', color: textColor, lineHeight: '1.6', whiteSpace: 'pre-line', marginBottom: '12px' }}>
|
||||
{getLocalized(rule, 'description').replace(/\\n/g, '\n')}
|
||||
</p>
|
||||
<div style={{ padding: '12px', background: `${teacherColor}10`, borderRadius: radius.md, fontSize: '13px', lineHeight: '1.6', whiteSpace: 'pre-line' }}>
|
||||
{getLocalized(rule, 'example').replace(/\\n/g, '\n')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ====== ШПАРГАЛКИ ====== */}
|
||||
{activeTab === 'cheatsheets' && selectedCheatSheet === null && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{cheatsheets.map((section) => (
|
||||
<button
|
||||
key={section.id}
|
||||
onClick={() => handleOpenCheatSheet(section.id)}
|
||||
style={{ ...menuButtonStyle, textAlign: 'left' }}
|
||||
>
|
||||
<span>{section.icon}</span> {getLocalized(section, 'title')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'cheatsheets' && selectedCheatSheet !== null && (
|
||||
<div>
|
||||
{(() => {
|
||||
const section = cheatsheets.find(s => s.id === selectedCheatSheet);
|
||||
if (!section) return null;
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{section.items.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
padding: '12px', background: i % 2 === 0 ? 'rgba(0,0,0,0.02)' : 'transparent',
|
||||
borderRadius: radius.md, fontSize: '12px', lineHeight: '1.6', color: textColor, whiteSpace: 'pre-line',
|
||||
}}
|
||||
>
|
||||
{item[language as keyof typeof item] || item.pl}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ====== ПЕРЕВОД ====== */}
|
||||
{activeTab === 'translate' && (
|
||||
<div>
|
||||
<textarea
|
||||
value={translateInput}
|
||||
onChange={(e) => setTranslateInput(e.target.value)}
|
||||
placeholder={t('teacherPlaceholder')}
|
||||
rows={3}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
width: '100%', padding: '14px', borderRadius: radius.md,
|
||||
border: `1px solid ${teacherColor}30`, background: 'rgba(255,255,255,0.8)',
|
||||
color: textColor, fontSize: '14px', fontFamily: fonts.main,
|
||||
outline: 'none', resize: 'none', marginBottom: '10px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{translateResult && (
|
||||
<div style={{
|
||||
padding: '12px', background: 'rgba(46,204,113,0.08)', borderRadius: radius.md,
|
||||
marginBottom: '10px', border: '1px solid rgba(46,204,113,0.2)',
|
||||
display: 'flex', alignItems: 'center', gap: '10px',
|
||||
}}>
|
||||
<div style={{ fontSize: '14px', color: textColor, fontWeight: 600, flex: 1 }}>{translateResult}</div>
|
||||
<button
|
||||
onClick={handlePlayTranslate}
|
||||
disabled={isPlaying}
|
||||
style={{
|
||||
width: '32px', height: '32px', borderRadius: '50%',
|
||||
border: `1px solid ${brandColor}40`,
|
||||
background: isPlaying ? `${brandColor}30` : 'rgba(255,255,255,0.9)',
|
||||
color: brandColor, fontSize: '12px', cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
animation: isPlaying ? 'pulse 0.5s ease infinite' : 'none',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
🔊
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleTranslateSubmit}
|
||||
disabled={isLoading || !translateInput.trim()}
|
||||
style={{
|
||||
width: '100%', padding: '14px', borderRadius: radius.md, border: 'none',
|
||||
background: isLoading || !translateInput.trim() ? 'rgba(0,0,0,0.1)' : `linear-gradient(135deg, ${teacherColor}, #6B46C1)`,
|
||||
color: '#FFFFFF', fontSize: '14px', fontWeight: 700,
|
||||
cursor: isLoading || !translateInput.trim() ? 'default' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{t('translate')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeacherMenuModal;
|
||||
@@ -0,0 +1,579 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { radius } from '../styles/theme';
|
||||
import {
|
||||
getTextLevels,
|
||||
getLevelTexts,
|
||||
updateText,
|
||||
createText,
|
||||
type ContentTextLevel,
|
||||
type ContentText,
|
||||
} from '../api';
|
||||
|
||||
interface TextsEditorProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const TextsEditor: React.FC<TextsEditorProps> = ({ isOpen, onClose }) => {
|
||||
const [levels, setLevels] = useState<ContentTextLevel[]>([]);
|
||||
const [selectedLevel, setSelectedLevel] = useState<string | null>(null);
|
||||
const [texts, setTexts] = useState<ContentText[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [editingText, setEditingText] = useState<ContentText | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setSelectedLevel(null);
|
||||
setTexts([]);
|
||||
setSearch('');
|
||||
setError(null);
|
||||
|
||||
getTextLevels()
|
||||
.then((data) => setLevels(data.levels))
|
||||
.catch((e) => {
|
||||
console.error('[TextsEditor] levels load failed:', e);
|
||||
setError('Не удалось загрузить уровни');
|
||||
});
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedLevel) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getLevelTexts(selectedLevel, search || undefined)
|
||||
.then((data) => setTexts(data.texts))
|
||||
.catch((e) => {
|
||||
console.error('[TextsEditor] texts load failed:', e);
|
||||
setError('Не удалось загрузить тексты');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [selectedLevel, search]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const textColor = '#1A1A2E';
|
||||
const subColor = '#6B7280';
|
||||
const cardBg = 'rgba(255,255,255,0.55)';
|
||||
const brand = '#4FA9E8';
|
||||
|
||||
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
||||
background: cardBg,
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 8px 32px rgba(31,38,73,0.10), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(20,22,35,0.55)', zIndex: 2200,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '20px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
...glass({
|
||||
borderRadius: radius.lg,
|
||||
padding: '20px',
|
||||
width: '100%',
|
||||
maxWidth: '560px',
|
||||
maxHeight: '90vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{/* Шапка */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '16px',
|
||||
paddingBottom: '12px',
|
||||
borderBottom: '1px solid rgba(0,0,0,0.06)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<h3 style={{ margin: 0, fontSize: '17px', fontWeight: 800, color: textColor }}>
|
||||
📄 {selectedLevel ? `Тексты ${selectedLevel}` : 'Выбор уровня'}
|
||||
</h3>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{selectedLevel && (
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: radius.sm,
|
||||
border: 'none',
|
||||
background: `linear-gradient(135deg, ${brand}, #2E86C1)`,
|
||||
color: '#FFF',
|
||||
fontSize: '13px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
+ Добавить
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
...glass({ padding: '8px 14px', borderRadius: radius.sm }),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Контент */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px',
|
||||
paddingBottom: '12px',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
minHeight: 0,
|
||||
}}>
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '10px 14px',
|
||||
background: 'rgba(231,76,60,0.08)',
|
||||
borderRadius: radius.md,
|
||||
color: '#E74C3C',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
⚠️ {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Список уровней */}
|
||||
{!selectedLevel && levels.map((lvl) => (
|
||||
<button
|
||||
key={lvl.code}
|
||||
onClick={() => setSelectedLevel(lvl.code)}
|
||||
style={{
|
||||
...glass({ padding: '14px 16px', borderRadius: radius.md }),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: '15px', fontWeight: 800, color: textColor }}>
|
||||
{lvl.code} — {lvl.name_ru}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: subColor, marginTop: '2px' }}>
|
||||
{lvl.texts_count} текстов
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontSize: '20px', color: subColor }}>›</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Список текстов уровня */}
|
||||
{selectedLevel && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => { setSelectedLevel(null); setSearch(''); }}
|
||||
style={{
|
||||
...glass({ padding: '8px 14px', borderRadius: radius.sm }),
|
||||
color: textColor,
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
alignSelf: 'flex-start',
|
||||
}}
|
||||
>
|
||||
← К уровням
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="🔍 Поиск по тексту или переводу..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
borderRadius: radius.md,
|
||||
border: '1px solid rgba(0,0,0,0.08)',
|
||||
background: 'rgba(255,255,255,0.8)',
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', padding: '20px', color: subColor, fontSize: '13px' }}>
|
||||
Загрузка...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && texts.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setEditingText(t)}
|
||||
style={{
|
||||
...glass({ padding: '12px 14px', borderRadius: radius.md }),
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: '4px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '14px', fontWeight: 700, color: textColor }}>
|
||||
#{t.sentence_number} · {t.sentence_pl}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: subColor, display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
|
||||
{t.translation_en && <span>🇬🇧 {t.translation_en}</span>}
|
||||
{t.translation_ru && <span>🇷🇺 {t.translation_ru}</span>}
|
||||
{t.translation_uk && <span>🇺🇦 {t.translation_uk}</span>}
|
||||
<span style={{ opacity: 0.6 }}>· сложность {t.difficulty}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{!loading && texts.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '20px', color: subColor, fontSize: '13px' }}>
|
||||
{search ? 'Ничего не найдено' : 'Текстов пока нет'}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Модалка редактирования */}
|
||||
{editingText && (
|
||||
<TextEditModal
|
||||
text={editingText}
|
||||
onClose={() => setEditingText(null)}
|
||||
onSaved={() => {
|
||||
setEditingText(null);
|
||||
if (selectedLevel) {
|
||||
getLevelTexts(selectedLevel, search || undefined).then((data) => setTexts(data.texts));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Модалка добавления */}
|
||||
{isCreating && selectedLevel && (
|
||||
<TextCreateModal
|
||||
level={selectedLevel}
|
||||
onClose={() => setIsCreating(false)}
|
||||
onSaved={() => {
|
||||
setIsCreating(false);
|
||||
getLevelTexts(selectedLevel, search || undefined).then((data) => setTexts(data.texts));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============ Модалка редактирования ============
|
||||
|
||||
const TextEditModal: React.FC<{
|
||||
text: ContentText;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}> = ({ text, onClose, onSaved }) => {
|
||||
const [sentencePl, setSentencePl] = useState(text.sentence_pl);
|
||||
const [en, setEn] = useState(text.translation_en);
|
||||
const [ru, setRu] = useState(text.translation_ru);
|
||||
const [uk, setUk] = useState(text.translation_uk);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!sentencePl.trim()) {
|
||||
setError('Польский текст обязателен');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await updateText(text.id, {
|
||||
sentence_pl: sentencePl.trim(),
|
||||
translation_en: en.trim(),
|
||||
translation_ru: ru.trim(),
|
||||
translation_uk: uk.trim(),
|
||||
});
|
||||
onSaved();
|
||||
} catch (e: any) {
|
||||
console.error('[TextEditModal] save failed:', e);
|
||||
setError(e?.message || 'Не удалось сохранить');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const textColor = '#1A1A2E';
|
||||
const subColor = '#6B7280';
|
||||
const brand = '#4FA9E8';
|
||||
|
||||
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
||||
background: 'rgba(255,255,255,0.85)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 8px 32px rgba(31,38,73,0.10)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
borderRadius: radius.sm,
|
||||
border: '1px solid rgba(0,0,0,0.08)',
|
||||
background: 'rgba(255,255,255,0.9)',
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
const textareaStyle: React.CSSProperties = { ...inputStyle, minHeight: '60px', resize: 'vertical', fontFamily: 'inherit' };
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
color: subColor,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
marginBottom: '4px',
|
||||
display: 'block',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(20,22,35,0.6)', zIndex: 2300,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '20px',
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget && !saving) onClose(); }}
|
||||
>
|
||||
<div style={{ ...glass({ borderRadius: radius.lg, padding: '20px', width: '100%', maxWidth: '440px' }) }}>
|
||||
<h3 style={{ margin: '0 0 16px 0', fontSize: '16px', fontWeight: 800, color: textColor }}>
|
||||
✏️ Редактировать текст #{text.sentence_number}
|
||||
</h3>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
<div>
|
||||
<label style={labelStyle}>🇵🇱 Польский текст *</label>
|
||||
<textarea style={textareaStyle} value={sentencePl} onChange={(e) => setSentencePl(e.target.value)} disabled={saving} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>🇬🇧 English</label>
|
||||
<input style={inputStyle} value={en} onChange={(e) => setEn(e.target.value)} disabled={saving} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>🇷🇺 Русский</label>
|
||||
<input style={inputStyle} value={ru} onChange={(e) => setRu(e.target.value)} disabled={saving} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>🇺🇦 Українська</label>
|
||||
<input style={inputStyle} value={uk} onChange={(e) => setUk(e.target.value)} disabled={saving} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ marginTop: '12px', padding: '8px 12px', background: 'rgba(231,76,60,0.08)', borderRadius: radius.sm, color: '#E74C3C', fontSize: '12px', fontWeight: 600 }}>
|
||||
⚠️ {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '16px' }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
style={{ flex: 1, padding: '12px', borderRadius: radius.md, border: '1px solid rgba(0,0,0,0.08)', background: 'rgba(255,255,255,0.8)', color: textColor, fontSize: '13px', fontWeight: 700, cursor: saving ? 'default' : 'pointer', opacity: saving ? 0.5 : 1 }}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
style={{ flex: 2, padding: '12px', borderRadius: radius.md, border: 'none', background: `linear-gradient(135deg, ${brand}, #2E86C1)`, color: '#FFF', fontSize: '13px', fontWeight: 700, cursor: saving ? 'default' : 'pointer', opacity: saving ? 0.5 : 1 }}
|
||||
>
|
||||
{saving ? 'Сохранение...' : '💾 Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============ Модалка добавления ============
|
||||
|
||||
const TextCreateModal: React.FC<{
|
||||
level: string;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}> = ({ level, onClose, onSaved }) => {
|
||||
const [input, setInput] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Живой предпросмотр: "PL, EN, RU, UK"
|
||||
const parts = input.split(',').map((s) => s.trim());
|
||||
const preview = {
|
||||
sentence_pl: parts[0] || '',
|
||||
translation_en: parts[1] || '',
|
||||
translation_ru: parts[2] || '',
|
||||
translation_uk: parts[3] || '',
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!preview.sentence_pl) {
|
||||
setError('Польский текст обязателен');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await createText(level, {
|
||||
sentence_pl: preview.sentence_pl,
|
||||
translation_en: preview.translation_en,
|
||||
translation_ru: preview.translation_ru,
|
||||
translation_uk: preview.translation_uk,
|
||||
});
|
||||
onSaved();
|
||||
} catch (e: any) {
|
||||
console.error('[TextCreateModal] save failed:', e);
|
||||
setError(e?.message || 'Не удалось добавить');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const textColor = '#1A1A2E';
|
||||
const subColor = '#6B7280';
|
||||
const brand = '#4FA9E8';
|
||||
|
||||
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
||||
background: 'rgba(255,255,255,0.85)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 8px 32px rgba(31,38,73,0.10)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(20,22,35,0.6)', zIndex: 2300,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '20px',
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget && !saving) onClose(); }}
|
||||
>
|
||||
<div style={{ ...glass({ borderRadius: radius.lg, padding: '20px', width: '100%', maxWidth: '480px' }) }}>
|
||||
<h3 style={{ margin: '0 0 8px 0', fontSize: '16px', fontWeight: 800, color: textColor }}>
|
||||
➕ Добавить текст в {level}
|
||||
</h3>
|
||||
<div style={{ fontSize: '12px', color: subColor, marginBottom: '12px' }}>
|
||||
Введи через запятую: <b>PL, EN, RU, UK</b>
|
||||
<br />
|
||||
Например: <code>To jest kot, This is a cat, Это кот, Це кіт</code>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="To jest kot, This is a cat, Это кот, Це кіт"
|
||||
disabled={saving}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '80px',
|
||||
padding: '12px 14px',
|
||||
borderRadius: radius.md,
|
||||
border: '2px solid rgba(79, 169, 232, 0.3)',
|
||||
background: 'rgba(255,255,255,0.95)',
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
fontFamily: 'inherit',
|
||||
resize: 'vertical',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Живой предпросмотр */}
|
||||
{input.trim() && (
|
||||
<div style={{
|
||||
marginTop: '12px',
|
||||
padding: '10px 12px',
|
||||
background: 'rgba(79, 169, 232, 0.08)',
|
||||
borderRadius: radius.sm,
|
||||
fontSize: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
}}>
|
||||
<div style={{ fontWeight: 700, color: brand, marginBottom: '2px', fontSize: '11px', textTransform: 'uppercase' }}>
|
||||
Предпросмотр
|
||||
</div>
|
||||
<div><b>PL:</b> {preview.sentence_pl || <span style={{ color: '#E74C3C' }}>обязательно</span>}</div>
|
||||
<div><b>EN:</b> {preview.translation_en || <span style={{ color: subColor }}>—</span>}</div>
|
||||
<div><b>RU:</b> {preview.translation_ru || <span style={{ color: subColor }}>—</span>}</div>
|
||||
<div><b>UK:</b> {preview.translation_uk || <span style={{ color: subColor }}>—</span>}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{ marginTop: '12px', padding: '8px 12px', background: 'rgba(231,76,60,0.08)', borderRadius: radius.sm, color: '#E74C3C', fontSize: '12px', fontWeight: 600 }}>
|
||||
⚠️ {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '16px' }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
style={{ flex: 1, padding: '12px', borderRadius: radius.md, border: '1px solid rgba(0,0,0,0.08)', background: 'rgba(255,255,255,0.8)', color: textColor, fontSize: '13px', fontWeight: 700, cursor: saving ? 'default' : 'pointer', opacity: saving ? 0.5 : 1 }}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !preview.sentence_pl}
|
||||
style={{ flex: 2, padding: '12px', borderRadius: radius.md, border: 'none', background: `linear-gradient(135deg, ${brand}, #2E86C1)`, color: '#FFF', fontSize: '13px', fontWeight: 700, cursor: (saving || !preview.sentence_pl) ? 'default' : 'pointer', opacity: (saving || !preview.sentence_pl) ? 0.5 : 1 }}
|
||||
>
|
||||
{saving ? 'Добавление...' : '➕ Добавить'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextsEditor;
|
||||
@@ -0,0 +1,469 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useLanguage } from '../i18n/LanguageContext';
|
||||
import { fonts, radius } from '../styles/theme';
|
||||
import { getNextTypingSentence, checkTypingSentence, getTypingHint, getTypingProgress, getTTS } from '../api';
|
||||
|
||||
interface TypingModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface TypingSentence {
|
||||
completed: boolean;
|
||||
sentence_id: number;
|
||||
translation: string;
|
||||
level: string;
|
||||
}
|
||||
|
||||
const TypingModal: React.FC<TypingModalProps> = ({ isOpen, onClose }) => {
|
||||
const { t } = useLanguage();
|
||||
const [sentence, setSentence] = useState<TypingSentence | null>(null);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isCorrect, setIsCorrect] = useState<boolean | null>(null);
|
||||
const [showAnswer, setShowAnswer] = useState(false);
|
||||
const [correctAnswer, setCorrectAnswer] = useState('');
|
||||
const [totalCompleted, setTotalCompleted] = useState(0);
|
||||
const [totalSentences, setTotalSentences] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
const brandColor = '#2ECC71';
|
||||
const textColor = '#1A1A2E';
|
||||
const subColor = '#6B7280';
|
||||
const cardBg = 'rgba(255,255,255,0.55)';
|
||||
|
||||
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
||||
background: cardBg,
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 8px 32px rgba(31,38,73,0.10), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
const stopAudio = () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
}
|
||||
if ('speechSynthesis' in window) {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSentence = useCallback(() => {
|
||||
setLoading(true);
|
||||
setIsCorrect(null);
|
||||
setShowAnswer(false);
|
||||
setInputValue('');
|
||||
setCorrectAnswer('');
|
||||
setIsPlaying(false);
|
||||
stopAudio();
|
||||
|
||||
getNextTypingSentence()
|
||||
.then((data: any) => {
|
||||
if (!isMountedRef.current) return;
|
||||
if (data.completed) {
|
||||
setSentence({ completed: true, sentence_id: 0, translation: '', level: '' });
|
||||
} else {
|
||||
setSentence(data);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (isMountedRef.current) setSentence(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (isMountedRef.current) setLoading(false);
|
||||
});
|
||||
|
||||
getTypingProgress()
|
||||
.then((data: any) => {
|
||||
if (isMountedRef.current) {
|
||||
setTotalCompleted(data.completed || 0);
|
||||
setTotalSentences(data.total || 0);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
isMountedRef.current = true;
|
||||
fetchSentence();
|
||||
}
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
stopAudio();
|
||||
};
|
||||
}, [isOpen, fetchSentence]);
|
||||
|
||||
const handleCheck = async () => {
|
||||
if (!sentence || !inputValue.trim()) return;
|
||||
|
||||
try {
|
||||
const data = await checkTypingSentence(sentence.sentence_id, inputValue);
|
||||
setIsCorrect(data.correct);
|
||||
setCorrectAnswer(data.correct_answer);
|
||||
|
||||
if (data.correct) {
|
||||
setTotalCompleted(prev => prev + 1);
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
fetchSentence();
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error checking sentence:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// 🔧 ОБНОВЛЕНО: теперь это toggle.
|
||||
// - Если ответ уже показан — просто прячем, без нового запроса.
|
||||
// - Если скрыт — грузим через API и показываем.
|
||||
const handleShowAnswer = async () => {
|
||||
if (!sentence) return;
|
||||
|
||||
if (showAnswer) {
|
||||
// Уже показан — прячем обратно
|
||||
setShowAnswer(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ещё нет ответа — грузим и показываем
|
||||
if (!correctAnswer) {
|
||||
try {
|
||||
const data = await getTypingHint(sentence.sentence_id);
|
||||
setCorrectAnswer(data.correct_answer);
|
||||
} catch (e) {
|
||||
console.error('Error getting hint:', e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setShowAnswer(true);
|
||||
};
|
||||
|
||||
const playAnswer = async () => {
|
||||
if (!correctAnswer) return;
|
||||
stopAudio();
|
||||
setIsPlaying(true);
|
||||
try {
|
||||
const audioBlob = await getTTS(correctAnswer, 'male');
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
const audio = new Audio(audioUrl);
|
||||
audioRef.current = audio;
|
||||
audio.onended = () => setIsPlaying(false);
|
||||
audio.onerror = () => setIsPlaying(false);
|
||||
await audio.play();
|
||||
} catch (e) {
|
||||
console.error('Error playing TTS:', e);
|
||||
setIsPlaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(20,22,35,0.25)', zIndex: 600,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '20px',
|
||||
backdropFilter: 'blur(4px)', WebkitBackdropFilter: 'blur(4px)',
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes typingModalFadeIn { 0% { opacity: 0; } 100% { opacity: 1; } }
|
||||
@keyframes typingModalSlideUp { 0% { transform: translateY(20px); opacity: 0; } 100% { transform: translateY(0); opacity: 1; } }
|
||||
@keyframes correctPop { 0% { transform: scale(1); } 50% { transform: scale(1.05); } 100% { transform: scale(1); } }
|
||||
@keyframes shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-5px); } 75% { transform: translateX(5px); } }
|
||||
@keyframes pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.1); } }
|
||||
@keyframes answerReveal { 0% { opacity: 0; transform: translateY(-6px); max-height: 0; } 100% { opacity: 1; transform: translateY(0); max-height: 200px; } }
|
||||
`}</style>
|
||||
|
||||
<div
|
||||
style={{
|
||||
...glass({
|
||||
borderRadius: radius.lg,
|
||||
padding: '24px',
|
||||
width: '100%',
|
||||
maxWidth: '420px',
|
||||
maxHeight: '85vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 24px 60px rgba(31,38,73,0.20), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
}),
|
||||
animation: 'typingModalSlideUp 0.3s ease',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Заголовок с кнопкой «Назад» слева */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '16px',
|
||||
paddingBottom: '12px',
|
||||
borderBottom: '1px solid rgba(0,0,0,0.06)',
|
||||
gap: '8px',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', minWidth: 0 }}>
|
||||
{/* 🔧 НОВОЕ: кнопка «Назад» — возвращает в меню учителя */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
...glass({ padding: '8px 14px', borderRadius: radius.sm }),
|
||||
color: textColor,
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.95)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
← {t('back')}
|
||||
</button>
|
||||
<h3 style={{ margin: 0, fontSize: '18px', fontWeight: 800, color: textColor, whiteSpace: 'nowrap' }}>
|
||||
⌨️ {t('typingTitle')}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
...glass({ padding: '8px 14px', borderRadius: radius.sm }),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Прогресс */}
|
||||
<div style={{ textAlign: 'center', marginBottom: '16px', color: subColor, fontSize: '12px' }}>
|
||||
{t('completed')}: {totalCompleted} / {totalSentences}
|
||||
</div>
|
||||
|
||||
{/* Блок показанного ответа — показывается только когда showAnswer === true */}
|
||||
{showAnswer && correctAnswer && (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
background: 'rgba(46,204,113,0.08)',
|
||||
borderRadius: radius.md,
|
||||
marginBottom: '16px',
|
||||
border: '1px solid rgba(46,204,113,0.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
animation: 'answerReveal 0.25s ease',
|
||||
}}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '12px', fontWeight: 700, color: brandColor, marginBottom: '4px' }}>
|
||||
✅ {t('typingShowAnswer')}
|
||||
</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: 700, color: textColor }}>
|
||||
{correctAnswer}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={playAnswer}
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '50%',
|
||||
border: `1px solid ${brandColor}40`,
|
||||
background: isPlaying ? `${brandColor}30` : 'rgba(255,255,255,0.9)',
|
||||
color: brandColor,
|
||||
fontSize: '16px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
animation: isPlaying ? 'pulse 0.5s ease infinite' : 'none',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
🔊
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: subColor, fontSize: '14px' }}>
|
||||
{t('loading')}
|
||||
</div>
|
||||
) : sentence?.completed ? (
|
||||
<div style={{ textAlign: 'center', padding: '30px 10px' }}>
|
||||
<div style={{ fontSize: '56px', marginBottom: '12px' }}>🎉</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: 700, color: textColor }}>
|
||||
{t('allSentencesCompleted')}
|
||||
</div>
|
||||
</div>
|
||||
) : sentence ? (
|
||||
<>
|
||||
{/* Перевод */}
|
||||
<div style={{
|
||||
...glass({
|
||||
padding: '18px',
|
||||
borderRadius: radius.lg,
|
||||
marginBottom: '16px',
|
||||
}),
|
||||
}}>
|
||||
<div style={{ fontSize: '12px', color: subColor, marginBottom: '6px', fontWeight: 500 }}>
|
||||
{t('typingInstructions')}
|
||||
</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 700, color: brandColor, lineHeight: '1.4' }}>
|
||||
{sentence.translation}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Поле ввода */}
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
placeholder={t('typingPlaceholder')}
|
||||
disabled={isCorrect === true}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '14px',
|
||||
borderRadius: radius.lg,
|
||||
border: isCorrect === false
|
||||
? '2px solid #E74C3C'
|
||||
: isCorrect === true
|
||||
? '2px solid #2ECC71'
|
||||
: '1px solid rgba(255,255,255,0.7)',
|
||||
background: isCorrect === true ? 'rgba(46,204,113,0.1)' : 'rgba(255,255,255,0.85)',
|
||||
color: textColor,
|
||||
fontSize: '16px',
|
||||
fontWeight: 600,
|
||||
fontFamily: fonts.main,
|
||||
outline: 'none',
|
||||
textAlign: 'center',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: isCorrect === false ? '0 0 15px rgba(231,76,60,0.3)' : '0 2px 8px rgba(31,38,73,0.06)',
|
||||
animation: isCorrect === false ? 'shake 0.4s ease' : 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Статус */}
|
||||
{isCorrect === true && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
marginTop: '14px',
|
||||
padding: '10px',
|
||||
background: 'rgba(46,204,113,0.1)',
|
||||
borderRadius: radius.md,
|
||||
animation: 'correctPop 0.3s ease',
|
||||
}}>
|
||||
<span style={{ fontSize: '14px', fontWeight: 700, color: brandColor }}>
|
||||
✅ {t('typingCorrect')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isCorrect === false && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
marginTop: '14px',
|
||||
padding: '10px',
|
||||
background: 'rgba(231,76,60,0.1)',
|
||||
borderRadius: radius.md,
|
||||
}}>
|
||||
<span style={{ fontSize: '14px', fontWeight: 700, color: '#E74C3C' }}>
|
||||
❌ {t('typingIncorrect')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Кнопки */}
|
||||
<div style={{ display: 'flex', gap: '10px', marginTop: '16px' }}>
|
||||
{isCorrect === true ? (
|
||||
<button
|
||||
onClick={fetchSentence}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '14px',
|
||||
borderRadius: radius.lg,
|
||||
border: 'none',
|
||||
background: `linear-gradient(135deg, ${brandColor}, #27AE60)`,
|
||||
color: '#FFFFFF',
|
||||
fontSize: '15px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
boxShadow: `0 4px 15px ${brandColor}40`,
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.96)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
{t('next')} →
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{/* 🔧 ОБНОВЛЕНО: toggle-кнопка «Показать ответ» / «Скрыть ответ» */}
|
||||
<button
|
||||
onClick={handleShowAnswer}
|
||||
style={{
|
||||
flex: 1,
|
||||
...glass({ padding: '14px', borderRadius: radius.lg }),
|
||||
color: showAnswer ? brandColor : textColor,
|
||||
border: showAnswer ? `1px solid ${brandColor}40` : '1px solid rgba(255,255,255,0.7)',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.96)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
{showAnswer
|
||||
? `🙈 ${t('typingHideAnswer')}`
|
||||
: `👁️ ${t('typingShowAnswer')}`}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCheck}
|
||||
disabled={!inputValue.trim()}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '14px',
|
||||
borderRadius: radius.lg,
|
||||
border: 'none',
|
||||
background: inputValue.trim()
|
||||
? `linear-gradient(135deg, ${brandColor}, #27AE60)`
|
||||
: 'rgba(0,0,0,0.1)',
|
||||
color: '#FFFFFF',
|
||||
fontSize: '14px',
|
||||
fontWeight: 700,
|
||||
cursor: inputValue.trim() ? 'pointer' : 'default',
|
||||
opacity: inputValue.trim() ? 1 : 0.5,
|
||||
}}
|
||||
onTouchStart={(e) => { if (inputValue.trim()) e.currentTarget.style.transform = 'scale(0.96)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
✓ {t('check')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '30px', color: subColor, fontSize: '14px' }}>
|
||||
{t('loading')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TypingModal;
|
||||
@@ -0,0 +1,584 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { radius } from '../styles/theme';
|
||||
import {
|
||||
getWordLevels,
|
||||
getLevelWords,
|
||||
updateWord,
|
||||
createWord,
|
||||
type ContentWordLevel,
|
||||
type ContentWord,
|
||||
} from '../api';
|
||||
|
||||
interface WordsEditorProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const WordsEditor: React.FC<WordsEditorProps> = ({ isOpen, onClose }) => {
|
||||
const [levels, setLevels] = useState<ContentWordLevel[]>([]);
|
||||
const [selectedLevel, setSelectedLevel] = useState<string | null>(null);
|
||||
const [words, setWords] = useState<ContentWord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [editingWord, setEditingWord] = useState<ContentWord | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
// Загрузка уровней при открытии
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setSelectedLevel(null);
|
||||
setWords([]);
|
||||
setSearch('');
|
||||
setError(null);
|
||||
|
||||
getWordLevels()
|
||||
.then((data) => setLevels(data.levels))
|
||||
.catch((e) => {
|
||||
console.error('[WordsEditor] levels load failed:', e);
|
||||
setError('Не удалось загрузить уровни');
|
||||
});
|
||||
}, [isOpen]);
|
||||
|
||||
// Загрузка слов при выборе уровня / изменении поиска
|
||||
useEffect(() => {
|
||||
if (!selectedLevel) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getLevelWords(selectedLevel, search || undefined)
|
||||
.then((data) => setWords(data.words))
|
||||
.catch((e) => {
|
||||
console.error('[WordsEditor] words load failed:', e);
|
||||
setError('Не удалось загрузить слова');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [selectedLevel, search]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const textColor = '#1A1A2E';
|
||||
const subColor = '#6B7280';
|
||||
const cardBg = 'rgba(255,255,255,0.55)';
|
||||
const brand = '#8B5CF6';
|
||||
|
||||
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
||||
background: cardBg,
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 8px 32px rgba(31,38,73,0.10), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(20,22,35,0.55)', zIndex: 2200,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '20px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
...glass({
|
||||
borderRadius: radius.lg,
|
||||
padding: '20px',
|
||||
width: '100%',
|
||||
maxWidth: '560px',
|
||||
maxHeight: '90vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{/* Шапка */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '16px',
|
||||
paddingBottom: '12px',
|
||||
borderBottom: '1px solid rgba(0,0,0,0.06)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<h3 style={{ margin: 0, fontSize: '17px', fontWeight: 800, color: textColor }}>
|
||||
📝 {selectedLevel ? `Слова ${selectedLevel}` : 'Выбор уровня'}
|
||||
</h3>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{selectedLevel && (
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: radius.sm,
|
||||
border: 'none',
|
||||
background: `linear-gradient(135deg, ${brand}, #6B46C1)`,
|
||||
color: '#FFF',
|
||||
fontSize: '13px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
+ Добавить
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
...glass({ padding: '8px 14px', borderRadius: radius.sm }),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Контент */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px',
|
||||
paddingBottom: '12px',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
minHeight: 0,
|
||||
}}>
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '10px 14px',
|
||||
background: 'rgba(231,76,60,0.08)',
|
||||
borderRadius: radius.md,
|
||||
color: '#E74C3C',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
⚠️ {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Список уровней */}
|
||||
{!selectedLevel && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setSelectedLevel(null)}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
{levels.map((lvl) => (
|
||||
<button
|
||||
key={lvl.code}
|
||||
onClick={() => setSelectedLevel(lvl.code)}
|
||||
style={{
|
||||
...glass({ padding: '14px 16px', borderRadius: radius.md }),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: '15px', fontWeight: 800, color: textColor }}>
|
||||
{lvl.code} — {lvl.name_ru}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: subColor, marginTop: '2px' }}>
|
||||
{lvl.words_count} слов
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontSize: '20px', color: subColor }}>›</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Список слов уровня */}
|
||||
{selectedLevel && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => { setSelectedLevel(null); setSearch(''); }}
|
||||
style={{
|
||||
...glass({ padding: '8px 14px', borderRadius: radius.sm }),
|
||||
color: textColor,
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
alignSelf: 'flex-start',
|
||||
}}
|
||||
>
|
||||
← К уровням
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="🔍 Поиск по слову или переводу..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
borderRadius: radius.md,
|
||||
border: '1px solid rgba(0,0,0,0.08)',
|
||||
background: 'rgba(255,255,255,0.8)',
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', padding: '20px', color: subColor, fontSize: '13px' }}>
|
||||
Загрузка...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && words.map((w) => (
|
||||
<button
|
||||
key={w.id}
|
||||
onClick={() => setEditingWord(w)}
|
||||
style={{
|
||||
...glass({ padding: '12px 14px', borderRadius: radius.md }),
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: '4px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '15px', fontWeight: 800, color: textColor }}>
|
||||
{w.word_pl}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: subColor, display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
|
||||
{w.translation_en && <span>🇬🇧 {w.translation_en}</span>}
|
||||
{w.translation_ru && <span>🇷🇺 {w.translation_ru}</span>}
|
||||
{w.translation_uk && <span>🇺🇦 {w.translation_uk}</span>}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{!loading && words.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '20px', color: subColor, fontSize: '13px' }}>
|
||||
{search ? 'Ничего не найдено' : 'Слов пока нет'}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Модалка редактирования слова */}
|
||||
{editingWord && (
|
||||
<WordEditModal
|
||||
word={editingWord}
|
||||
onClose={() => setEditingWord(null)}
|
||||
onSaved={() => {
|
||||
setEditingWord(null);
|
||||
if (selectedLevel) {
|
||||
getLevelWords(selectedLevel, search || undefined).then((data) => setWords(data.words));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Модалка добавления слова */}
|
||||
{isCreating && selectedLevel && (
|
||||
<WordCreateModal
|
||||
level={selectedLevel}
|
||||
onClose={() => setIsCreating(false)}
|
||||
onSaved={() => {
|
||||
setIsCreating(false);
|
||||
getLevelWords(selectedLevel, search || undefined).then((data) => setWords(data.words));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============ Модалка редактирования ============
|
||||
|
||||
const WordEditModal: React.FC<{
|
||||
word: ContentWord;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}> = ({ word, onClose, onSaved }) => {
|
||||
const [wordPl, setWordPl] = useState(word.word_pl);
|
||||
const [en, setEn] = useState(word.translation_en);
|
||||
const [ru, setRu] = useState(word.translation_ru);
|
||||
const [uk, setUk] = useState(word.translation_uk);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!wordPl.trim()) {
|
||||
setError('Польское слово обязательно');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await updateWord(word.id, {
|
||||
word_pl: wordPl.trim(),
|
||||
translation_en: en.trim(),
|
||||
translation_ru: ru.trim(),
|
||||
translation_uk: uk.trim(),
|
||||
});
|
||||
onSaved();
|
||||
} catch (e: any) {
|
||||
console.error('[WordEditModal] save failed:', e);
|
||||
setError(e?.message || 'Не удалось сохранить');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const textColor = '#1A1A2E';
|
||||
const subColor = '#6B7280';
|
||||
const brand = '#8B5CF6';
|
||||
|
||||
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
||||
background: 'rgba(255,255,255,0.85)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 8px 32px rgba(31,38,73,0.10)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
borderRadius: radius.sm,
|
||||
border: '1px solid rgba(0,0,0,0.08)',
|
||||
background: 'rgba(255,255,255,0.9)',
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
color: subColor,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
marginBottom: '4px',
|
||||
display: 'block',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(20,22,35,0.6)', zIndex: 2300,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '20px',
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget && !saving) onClose(); }}
|
||||
>
|
||||
<div style={{ ...glass({ borderRadius: radius.lg, padding: '20px', width: '100%', maxWidth: '440px' }) }}>
|
||||
<h3 style={{ margin: '0 0 16px 0', fontSize: '16px', fontWeight: 800, color: textColor }}>
|
||||
✏️ Редактировать слово
|
||||
</h3>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
<div>
|
||||
<label style={labelStyle}>🇵🇱 Польское слово *</label>
|
||||
<input style={inputStyle} value={wordPl} onChange={(e) => setWordPl(e.target.value)} disabled={saving} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>🇬🇧 English</label>
|
||||
<input style={inputStyle} value={en} onChange={(e) => setEn(e.target.value)} disabled={saving} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>🇷🇺 Русский</label>
|
||||
<input style={inputStyle} value={ru} onChange={(e) => setRu(e.target.value)} disabled={saving} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>🇺🇦 Українська</label>
|
||||
<input style={inputStyle} value={uk} onChange={(e) => setUk(e.target.value)} disabled={saving} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ marginTop: '12px', padding: '8px 12px', background: 'rgba(231,76,60,0.08)', borderRadius: radius.sm, color: '#E74C3C', fontSize: '12px', fontWeight: 600 }}>
|
||||
⚠️ {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '16px' }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
style={{ flex: 1, padding: '12px', borderRadius: radius.md, border: '1px solid rgba(0,0,0,0.08)', background: 'rgba(255,255,255,0.8)', color: textColor, fontSize: '13px', fontWeight: 700, cursor: saving ? 'default' : 'pointer', opacity: saving ? 0.5 : 1 }}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
style={{ flex: 2, padding: '12px', borderRadius: radius.md, border: 'none', background: `linear-gradient(135deg, ${brand}, #6B46C1)`, color: '#FFF', fontSize: '13px', fontWeight: 700, cursor: saving ? 'default' : 'pointer', opacity: saving ? 0.5 : 1 }}
|
||||
>
|
||||
{saving ? 'Сохранение...' : '💾 Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============ Модалка добавления через запятую ============
|
||||
|
||||
const WordCreateModal: React.FC<{
|
||||
level: string;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}> = ({ level, onClose, onSaved }) => {
|
||||
const [input, setInput] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Живой предпросмотр: парсим "PL, EN, RU, UK"
|
||||
const parts = input.split(',').map((s) => s.trim());
|
||||
const preview = {
|
||||
word_pl: parts[0] || '',
|
||||
translation_en: parts[1] || '',
|
||||
translation_ru: parts[2] || '',
|
||||
translation_uk: parts[3] || '',
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!preview.word_pl) {
|
||||
setError('Польское слово обязательно');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await createWord(level, {
|
||||
word_pl: preview.word_pl,
|
||||
translation_en: preview.translation_en,
|
||||
translation_ru: preview.translation_ru,
|
||||
translation_uk: preview.translation_uk,
|
||||
});
|
||||
onSaved();
|
||||
} catch (e: any) {
|
||||
console.error('[WordCreateModal] save failed:', e);
|
||||
setError(e?.message || 'Не удалось добавить');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const textColor = '#1A1A2E';
|
||||
const subColor = '#6B7280';
|
||||
const brand = '#8B5CF6';
|
||||
|
||||
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
||||
background: 'rgba(255,255,255,0.85)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 8px 32px rgba(31,38,73,0.10)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(20,22,35,0.6)', zIndex: 2300,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '20px',
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget && !saving) onClose(); }}
|
||||
>
|
||||
<div style={{ ...glass({ borderRadius: radius.lg, padding: '20px', width: '100%', maxWidth: '440px' }) }}>
|
||||
<h3 style={{ margin: '0 0 8px 0', fontSize: '16px', fontWeight: 800, color: textColor }}>
|
||||
➕ Добавить слово в {level}
|
||||
</h3>
|
||||
<div style={{ fontSize: '12px', color: subColor, marginBottom: '12px' }}>
|
||||
Введи через запятую: <b>PL, EN, RU, UK</b>
|
||||
<br />
|
||||
Например: <code>kot, cat, кот, кіт</code>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="kot, cat, кот, кіт"
|
||||
disabled={saving}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 14px',
|
||||
borderRadius: radius.md,
|
||||
border: '2px solid rgba(139, 92, 246, 0.3)',
|
||||
background: 'rgba(255,255,255,0.95)',
|
||||
color: textColor,
|
||||
fontSize: '15px',
|
||||
fontWeight: 600,
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Живой предпросмотр */}
|
||||
{input.trim() && (
|
||||
<div style={{
|
||||
marginTop: '12px',
|
||||
padding: '10px 12px',
|
||||
background: 'rgba(139, 92, 246, 0.08)',
|
||||
borderRadius: radius.sm,
|
||||
fontSize: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
}}>
|
||||
<div style={{ fontWeight: 700, color: brand, marginBottom: '2px', fontSize: '11px', textTransform: 'uppercase' }}>
|
||||
Предпросмотр
|
||||
</div>
|
||||
<div><b>PL:</b> {preview.word_pl || <span style={{ color: '#E74C3C' }}>обязательно</span>}</div>
|
||||
<div><b>EN:</b> {preview.translation_en || <span style={{ color: subColor }}>—</span>}</div>
|
||||
<div><b>RU:</b> {preview.translation_ru || <span style={{ color: subColor }}>—</span>}</div>
|
||||
<div><b>UK:</b> {preview.translation_uk || <span style={{ color: subColor }}>—</span>}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{ marginTop: '12px', padding: '8px 12px', background: 'rgba(231,76,60,0.08)', borderRadius: radius.sm, color: '#E74C3C', fontSize: '12px', fontWeight: 600 }}>
|
||||
⚠️ {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '16px' }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
style={{ flex: 1, padding: '12px', borderRadius: radius.md, border: '1px solid rgba(0,0,0,0.08)', background: 'rgba(255,255,255,0.8)', color: textColor, fontSize: '13px', fontWeight: 700, cursor: saving ? 'default' : 'pointer', opacity: saving ? 0.5 : 1 }}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !preview.word_pl}
|
||||
style={{ flex: 2, padding: '12px', borderRadius: radius.md, border: 'none', background: `linear-gradient(135deg, ${brand}, #6B46C1)`, color: '#FFF', fontSize: '13px', fontWeight: 700, cursor: (saving || !preview.word_pl) ? 'default' : 'pointer', opacity: (saving || !preview.word_pl) ? 0.5 : 1 }}
|
||||
>
|
||||
{saving ? 'Добавление...' : '➕ Добавить'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WordsEditor;
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLanguage } from '../../i18n/LanguageContext';
|
||||
import { radius } from '../../styles/theme';
|
||||
|
||||
interface AppsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const AppsModal: React.FC<AppsModalProps> = ({ isOpen, onClose }) => {
|
||||
const { t } = useLanguage();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const accentColor = '#4FA9E8';
|
||||
const textColor = '#1A1A2E';
|
||||
const subColor = '#6B7280';
|
||||
const cardBg = 'rgba(255,255,255,0.55)';
|
||||
|
||||
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
||||
background: cardBg,
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 8px 32px rgba(31,38,73,0.10), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = 0;
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(20,22,35,0.35)',
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '20px',
|
||||
backdropFilter: 'blur(6px)',
|
||||
WebkitBackdropFilter: 'blur(6px)',
|
||||
animation: 'appsFadeIn 0.2s ease',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes appsFadeIn {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
@keyframes appsSlideUp {
|
||||
0% { transform: translateY(20px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes appsPopIn {
|
||||
0% { transform: scale(0.4); opacity: 0; }
|
||||
60% { transform: scale(1.1); opacity: 1; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
@keyframes appsFloat {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-6px); }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
...glass({
|
||||
borderRadius: radius.lg,
|
||||
padding: '24px',
|
||||
width: '100%',
|
||||
maxWidth: '400px',
|
||||
maxHeight: '80vh',
|
||||
overflowY: 'auto',
|
||||
boxShadow: '0 24px 60px rgba(31,38,73,0.25), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
}),
|
||||
animation: 'appsSlideUp 0.3s ease',
|
||||
}}
|
||||
>
|
||||
{/* ============ Заголовок ============ */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '20px',
|
||||
paddingBottom: '14px',
|
||||
borderBottom: '1px solid rgba(0,0,0,0.06)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
{/* 🔧 ФИКС iOS 27: lineHeight 1.2 для эмодзи в заголовке */}
|
||||
<span style={{ fontSize: '24px', lineHeight: '1.2', display: 'inline-block' }}>📱</span>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '18px',
|
||||
fontWeight: 800,
|
||||
color: textColor,
|
||||
letterSpacing: '-0.2px',
|
||||
}}
|
||||
>
|
||||
{t('appsModalTitle')}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
style={{
|
||||
...glass({
|
||||
padding: '8px 14px',
|
||||
borderRadius: radius.sm,
|
||||
}),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(0.9)';
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ============ Пустое состояние ============ */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '32px 16px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{/* 🔧 ФИКС iOS 27: lineHeight 1.2 + padding для большого эмодзи */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '64px',
|
||||
lineHeight: '1.2',
|
||||
padding: '8px 0',
|
||||
marginBottom: '20px',
|
||||
animation: 'appsPopIn 0.5s cubic-bezier(0.34, 1.56, 0.64, 1), appsFloat 3s ease-in-out 0.5s infinite',
|
||||
filter: `drop-shadow(0 8px 20px ${accentColor}40)`,
|
||||
userSelect: 'none',
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
📱
|
||||
</div>
|
||||
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '15px',
|
||||
fontWeight: 500,
|
||||
color: subColor,
|
||||
lineHeight: '1.5',
|
||||
maxWidth: '280px',
|
||||
}}
|
||||
>
|
||||
{t('appsModalEmpty')}
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '6px',
|
||||
marginTop: '24px',
|
||||
}}
|
||||
>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
background: accentColor,
|
||||
opacity: 0.3 + i * 0.2,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ============ Кнопка "Понятно" ============ */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
width: '100%',
|
||||
marginTop: '16px',
|
||||
padding: '14px 20px',
|
||||
borderRadius: radius.md,
|
||||
border: 'none',
|
||||
background: `linear-gradient(135deg, ${accentColor}, #3E9BE0)`,
|
||||
color: '#FFFFFF',
|
||||
fontSize: '15px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
boxShadow: `0 6px 20px ${accentColor}40`,
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
letterSpacing: '0.2px',
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(0.97)';
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
{t('modalGotIt')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppsModal;
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLanguage } from '../../i18n/LanguageContext';
|
||||
import { radius } from '../../styles/theme';
|
||||
|
||||
interface ContactsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ContactsModal: React.FC<ContactsModalProps> = ({ isOpen, onClose }) => {
|
||||
const { t } = useLanguage();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const accentColor = '#8B5CF6';
|
||||
const textColor = '#1A1A2E';
|
||||
const subColor = '#6B7280';
|
||||
const cardBg = 'rgba(255,255,255,0.55)';
|
||||
|
||||
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
||||
background: cardBg,
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 8px 32px rgba(31,38,73,0.10), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = 0;
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(20,22,35,0.35)',
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '20px',
|
||||
backdropFilter: 'blur(6px)',
|
||||
WebkitBackdropFilter: 'blur(6px)',
|
||||
animation: 'contactsFadeIn 0.2s ease',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes contactsFadeIn {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
@keyframes contactsSlideUp {
|
||||
0% { transform: translateY(20px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes contactsPopIn {
|
||||
0% { transform: scale(0.4); opacity: 0; }
|
||||
60% { transform: scale(1.1); opacity: 1; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
@keyframes contactsFloat {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-6px); }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
...glass({
|
||||
borderRadius: radius.lg,
|
||||
padding: '24px',
|
||||
width: '100%',
|
||||
maxWidth: '400px',
|
||||
maxHeight: '80vh',
|
||||
overflowY: 'auto',
|
||||
boxShadow: '0 24px 60px rgba(31,38,73,0.25), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
}),
|
||||
animation: 'contactsSlideUp 0.3s ease',
|
||||
}}
|
||||
>
|
||||
{/* ============ Заголовок ============ */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '20px',
|
||||
paddingBottom: '14px',
|
||||
borderBottom: '1px solid rgba(0,0,0,0.06)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
{/* 🔧 ФИКС iOS 27: lineHeight 1.2 для эмодзи в заголовке */}
|
||||
<span style={{ fontSize: '24px', lineHeight: '1.2', display: 'inline-block' }}>📞</span>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '18px',
|
||||
fontWeight: 800,
|
||||
color: textColor,
|
||||
letterSpacing: '-0.2px',
|
||||
}}
|
||||
>
|
||||
{t('contactsModalTitle')}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
style={{
|
||||
...glass({
|
||||
padding: '8px 14px',
|
||||
borderRadius: radius.sm,
|
||||
}),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(0.9)';
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ============ Пустое состояние ============ */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '32px 16px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{/* 🔧 ФИКС iOS 27: lineHeight 1.2 + padding для большого эмодзи */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '64px',
|
||||
lineHeight: '1.2',
|
||||
padding: '8px 0',
|
||||
marginBottom: '20px',
|
||||
animation: 'contactsPopIn 0.5s cubic-bezier(0.34, 1.56, 0.64, 1), contactsFloat 3s ease-in-out 0.5s infinite',
|
||||
filter: `drop-shadow(0 8px 20px ${accentColor}40)`,
|
||||
userSelect: 'none',
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
📞
|
||||
</div>
|
||||
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '15px',
|
||||
fontWeight: 500,
|
||||
color: subColor,
|
||||
lineHeight: '1.5',
|
||||
maxWidth: '280px',
|
||||
}}
|
||||
>
|
||||
{t('contactsModalEmpty')}
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '6px',
|
||||
marginTop: '24px',
|
||||
}}
|
||||
>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
background: accentColor,
|
||||
opacity: 0.3 + i * 0.2,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ============ Кнопка "Понятно" ============ */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
width: '100%',
|
||||
marginTop: '16px',
|
||||
padding: '14px 20px',
|
||||
borderRadius: radius.md,
|
||||
border: 'none',
|
||||
background: `linear-gradient(135deg, ${accentColor}, #6B46C1)`,
|
||||
color: '#FFFFFF',
|
||||
fontSize: '15px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
boxShadow: `0 6px 20px ${accentColor}40`,
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
letterSpacing: '0.2px',
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(0.97)';
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
{t('modalGotIt')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactsModal;
|
||||
@@ -0,0 +1,378 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useLanguage } from '../../i18n/LanguageContext';
|
||||
import { radius } from '../../styles/theme';
|
||||
|
||||
interface NewsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** Флаг админа — приходит из MenuPage (уже есть в MenuPage) */
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
const NewsModal: React.FC<NewsModalProps> = ({ isOpen, onClose, isAdmin = false }) => {
|
||||
const { t } = useLanguage();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [showAdminStub, setShowAdminStub] = useState(false);
|
||||
|
||||
const accentColor = '#E8B93F';
|
||||
const accentDark = '#CE9A1E';
|
||||
const textColor = '#1A1A2E';
|
||||
const subColor = '#6B7280';
|
||||
const cardBg = 'rgba(255,255,255,0.55)';
|
||||
|
||||
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
||||
background: cardBg,
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 8px 32px rgba(31,38,73,0.10), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
// Сброс скролла и админ-заглушки при каждом открытии
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (scrollRef.current) scrollRef.current.scrollTop = 0;
|
||||
setShowAdminStub(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Escape
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(20,22,35,0.35)',
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '20px',
|
||||
backdropFilter: 'blur(6px)',
|
||||
WebkitBackdropFilter: 'blur(6px)',
|
||||
animation: 'newsFadeIn 0.2s ease',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes newsFadeIn {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
@keyframes newsSlideUp {
|
||||
0% { transform: translateY(20px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes newsPopIn {
|
||||
0% { transform: scale(0.4); opacity: 0; }
|
||||
60% { transform: scale(1.1); opacity: 1; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
@keyframes newsFloat {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-6px); }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
...glass({
|
||||
borderRadius: radius.lg,
|
||||
padding: '24px',
|
||||
width: '100%',
|
||||
maxWidth: '400px',
|
||||
maxHeight: '80vh',
|
||||
overflowY: 'auto',
|
||||
boxShadow: '0 24px 60px rgba(31,38,73,0.25), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
}),
|
||||
animation: 'newsSlideUp 0.3s ease',
|
||||
}}
|
||||
>
|
||||
{/* ============ Заголовок ============ */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '20px',
|
||||
paddingBottom: '14px',
|
||||
borderBottom: '1px solid rgba(0,0,0,0.06)',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', minWidth: 0 }}>
|
||||
{/* 🔧 ФИКС iOS 27: lineHeight 1.2 для эмодзи в заголовке */}
|
||||
<span style={{ fontSize: '24px', flexShrink: 0, lineHeight: '1.2', display: 'inline-block' }}>📰</span>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '18px',
|
||||
fontWeight: 800,
|
||||
color: textColor,
|
||||
letterSpacing: '-0.2px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{t('newsModalTitle')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '6px', flexShrink: 0 }}>
|
||||
{/* ➕ Кнопка добавления — только админу */}
|
||||
{isAdmin && !showAdminStub && (
|
||||
<button
|
||||
onClick={() => setShowAdminStub(true)}
|
||||
aria-label="Add news"
|
||||
title="Add news"
|
||||
style={{
|
||||
...glass({
|
||||
padding: '8px 12px',
|
||||
borderRadius: radius.sm,
|
||||
}),
|
||||
color: accentDark,
|
||||
fontSize: '16px',
|
||||
fontWeight: 800,
|
||||
cursor: 'pointer',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
lineHeight: '1.2', // 🔧 ФИКС iOS 27
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '36px',
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(0.9)';
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
➕
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Кнопка закрытия */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
style={{
|
||||
...glass({
|
||||
padding: '8px 14px',
|
||||
borderRadius: radius.sm,
|
||||
}),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(0.9)';
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ============ Админ-заглушка (по кнопке ➕) ============ */}
|
||||
{showAdminStub ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '32px 16px',
|
||||
textAlign: 'center',
|
||||
animation: 'newsPopIn 0.3s ease',
|
||||
}}
|
||||
>
|
||||
{/* 🔧 ФИКС iOS 27: lineHeight 1.2 + padding для эмодзи 56px */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '56px',
|
||||
lineHeight: '1.2',
|
||||
padding: '8px 0',
|
||||
marginBottom: '16px',
|
||||
filter: `drop-shadow(0 8px 20px ${accentColor}40)`,
|
||||
userSelect: 'none',
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
🛠️
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 700,
|
||||
color: accentDark,
|
||||
marginBottom: '8px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
>
|
||||
Admin mode
|
||||
</div>
|
||||
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
color: subColor,
|
||||
lineHeight: '1.5',
|
||||
maxWidth: '280px',
|
||||
}}
|
||||
>
|
||||
News management is coming soon.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={() => setShowAdminStub(false)}
|
||||
style={{
|
||||
marginTop: '20px',
|
||||
padding: '10px 20px',
|
||||
borderRadius: radius.md,
|
||||
border: `1px solid ${accentColor}40`,
|
||||
background: `${accentColor}15`,
|
||||
color: accentDark,
|
||||
fontSize: '13px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(0.96)';
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
← {t('back')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* ============ Пустое состояние для всех ============ */
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '32px 16px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{/* 🔧 ФИКС iOS 27: lineHeight 1.2 + padding для большого эмодзи */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '64px',
|
||||
lineHeight: '1.2',
|
||||
padding: '8px 0',
|
||||
marginBottom: '20px',
|
||||
animation: 'newsPopIn 0.5s cubic-bezier(0.34, 1.56, 0.64, 1), newsFloat 3s ease-in-out 0.5s infinite',
|
||||
filter: `drop-shadow(0 8px 20px ${accentColor}40)`,
|
||||
userSelect: 'none',
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
📰
|
||||
</div>
|
||||
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '15px',
|
||||
fontWeight: 500,
|
||||
color: subColor,
|
||||
lineHeight: '1.5',
|
||||
maxWidth: '280px',
|
||||
}}
|
||||
>
|
||||
{t('newsModalEmpty')}
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '6px',
|
||||
marginTop: '24px',
|
||||
}}
|
||||
>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
background: accentColor,
|
||||
opacity: 0.3 + i * 0.2,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ============ Кнопка "Понятно" — только когда не админ-заглушка ============ */}
|
||||
{!showAdminStub && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
width: '100%',
|
||||
marginTop: '16px',
|
||||
padding: '14px 20px',
|
||||
borderRadius: radius.md,
|
||||
border: 'none',
|
||||
background: `linear-gradient(135deg, ${accentColor}, ${accentDark})`,
|
||||
color: '#FFFFFF',
|
||||
fontSize: '15px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
boxShadow: `0 6px 20px ${accentColor}40`,
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
letterSpacing: '0.2px',
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(0.97)';
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
{t('modalGotIt')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsModal;
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLanguage } from '../../i18n/LanguageContext';
|
||||
import { radius } from '../../styles/theme';
|
||||
|
||||
interface PartnersModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const PartnersModal: React.FC<PartnersModalProps> = ({ isOpen, onClose }) => {
|
||||
const { t } = useLanguage();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const brandColor = '#2ECC71';
|
||||
const textColor = '#1A1A2E';
|
||||
const subColor = '#6B7280';
|
||||
const cardBg = 'rgba(255,255,255,0.55)';
|
||||
|
||||
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
||||
background: cardBg,
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 8px 32px rgba(31,38,73,0.10), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = 0;
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(20,22,35,0.35)',
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '20px',
|
||||
backdropFilter: 'blur(6px)',
|
||||
WebkitBackdropFilter: 'blur(6px)',
|
||||
animation: 'partnersFadeIn 0.2s ease',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes partnersFadeIn {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
@keyframes partnersSlideUp {
|
||||
0% { transform: translateY(20px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes partnersPopIn {
|
||||
0% { transform: scale(0.4); opacity: 0; }
|
||||
60% { transform: scale(1.1); opacity: 1; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
@keyframes partnersFloat {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-6px); }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
...glass({
|
||||
borderRadius: radius.lg,
|
||||
padding: '24px',
|
||||
width: '100%',
|
||||
maxWidth: '400px',
|
||||
maxHeight: '80vh',
|
||||
overflowY: 'auto',
|
||||
boxShadow: '0 24px 60px rgba(31,38,73,0.25), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
}),
|
||||
animation: 'partnersSlideUp 0.3s ease',
|
||||
}}
|
||||
>
|
||||
{/* ============ Заголовок ============ */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '20px',
|
||||
paddingBottom: '14px',
|
||||
borderBottom: '1px solid rgba(0,0,0,0.06)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
{/* 🔧 ФИКС iOS 27: lineHeight 1.2 для эмодзи в заголовке */}
|
||||
<span style={{ fontSize: '24px', lineHeight: '1.2', display: 'inline-block' }}>🤝</span>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '18px',
|
||||
fontWeight: 800,
|
||||
color: textColor,
|
||||
letterSpacing: '-0.2px',
|
||||
}}
|
||||
>
|
||||
{t('partnersModalTitle')}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
style={{
|
||||
...glass({
|
||||
padding: '8px 14px',
|
||||
borderRadius: radius.sm,
|
||||
}),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(0.9)';
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ============ Пустое состояние ============ */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '32px 16px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{/* 🔧 ФИКС iOS 27: lineHeight 1.2 + padding для большого эмодзи */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '64px',
|
||||
lineHeight: '1.2',
|
||||
padding: '8px 0',
|
||||
marginBottom: '20px',
|
||||
animation: 'partnersPopIn 0.5s cubic-bezier(0.34, 1.56, 0.64, 1), partnersFloat 3s ease-in-out 0.5s infinite',
|
||||
filter: `drop-shadow(0 8px 20px ${brandColor}40)`,
|
||||
userSelect: 'none',
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
🤝
|
||||
</div>
|
||||
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '15px',
|
||||
fontWeight: 500,
|
||||
color: subColor,
|
||||
lineHeight: '1.5',
|
||||
maxWidth: '280px',
|
||||
}}
|
||||
>
|
||||
{t('partnersModalEmpty')}
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '6px',
|
||||
marginTop: '24px',
|
||||
}}
|
||||
>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
background: brandColor,
|
||||
opacity: 0.3 + i * 0.2,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ============ Кнопка "Понятно" ============ */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
width: '100%',
|
||||
marginTop: '16px',
|
||||
padding: '14px 20px',
|
||||
borderRadius: radius.md,
|
||||
border: 'none',
|
||||
background: `linear-gradient(135deg, ${brandColor}, #27AE60)`,
|
||||
color: '#FFFFFF',
|
||||
fontSize: '15px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
boxShadow: `0 6px 20px ${brandColor}40`,
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
letterSpacing: '0.2px',
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(0.97)';
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
{t('modalGotIt')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PartnersModal;
|
||||
@@ -0,0 +1,175 @@
|
||||
export interface AlphabetLetter {
|
||||
upper: string;
|
||||
lower: string;
|
||||
pronunciation_pl: string;
|
||||
pronunciation_ru: string;
|
||||
pronunciation_uk: string;
|
||||
pronunciation_en: string;
|
||||
example_pl: string;
|
||||
example_ru: string;
|
||||
example_uk: string;
|
||||
example_en: string;
|
||||
}
|
||||
|
||||
export const polishAlphabet: AlphabetLetter[] = [
|
||||
{
|
||||
upper: "A", lower: "a",
|
||||
pronunciation_pl: "a", pronunciation_ru: "а", pronunciation_uk: "а", pronunciation_en: "a",
|
||||
example_pl: "auto", example_ru: "машина", example_uk: "автомобіль", example_en: "car"
|
||||
},
|
||||
{
|
||||
upper: "Ą", lower: "ą",
|
||||
pronunciation_pl: "on / om", pronunciation_ru: "он / ом", pronunciation_uk: "он / ом", pronunciation_en: "on / om",
|
||||
example_pl: "kąt", example_ru: "угол", example_uk: "кут", example_en: "angle"
|
||||
},
|
||||
{
|
||||
upper: "B", lower: "b",
|
||||
pronunciation_pl: "b", pronunciation_ru: "б", pronunciation_uk: "б", pronunciation_en: "b",
|
||||
example_pl: "but", example_ru: "ботинок", example_uk: "черевик", example_en: "shoe"
|
||||
},
|
||||
{
|
||||
upper: "C", lower: "c",
|
||||
pronunciation_pl: "c", pronunciation_ru: "ц", pronunciation_uk: "ц", pronunciation_en: "ts",
|
||||
example_pl: "cukier", example_ru: "сахар", example_uk: "цукор", example_en: "sugar"
|
||||
},
|
||||
{
|
||||
upper: "Ć", lower: "ć",
|
||||
pronunciation_pl: "miękkie 'ć'", pronunciation_ru: "мягкое 'чь'", pronunciation_uk: "м'яке 'чь'", pronunciation_en: "soft 'ch'",
|
||||
example_pl: "ćma", example_ru: "моль", example_uk: "міль", example_en: "moth"
|
||||
},
|
||||
{
|
||||
upper: "D", lower: "d",
|
||||
pronunciation_pl: "d", pronunciation_ru: "д", pronunciation_uk: "д", pronunciation_en: "d",
|
||||
example_pl: "dom", example_ru: "дом", example_uk: "будинок", example_en: "house"
|
||||
},
|
||||
{
|
||||
upper: "E", lower: "e",
|
||||
pronunciation_pl: "e", pronunciation_ru: "э", pronunciation_uk: "е", pronunciation_en: "e",
|
||||
example_pl: "ekran", example_ru: "экран", example_uk: "екран", example_en: "screen"
|
||||
},
|
||||
{
|
||||
upper: "Ę", lower: "ę",
|
||||
pronunciation_pl: "en / em", pronunciation_ru: "эн / ем", pronunciation_uk: "ен / ем", pronunciation_en: "en / em",
|
||||
example_pl: "zęby", example_ru: "зубы", example_uk: "зуби", example_en: "teeth"
|
||||
},
|
||||
{
|
||||
upper: "F", lower: "f",
|
||||
pronunciation_pl: "f", pronunciation_ru: "ф", pronunciation_uk: "ф", pronunciation_en: "f",
|
||||
example_pl: "film", example_ru: "фильм", example_uk: "фільм", example_en: "movie"
|
||||
},
|
||||
{
|
||||
upper: "G", lower: "g",
|
||||
pronunciation_pl: "g", pronunciation_ru: "г", pronunciation_uk: "г", pronunciation_en: "g",
|
||||
example_pl: "gra", example_ru: "игра", example_uk: "гра", example_en: "game"
|
||||
},
|
||||
{
|
||||
upper: "H", lower: "h",
|
||||
pronunciation_pl: "h", pronunciation_ru: "х", pronunciation_uk: "х", pronunciation_en: "h",
|
||||
example_pl: "hotel", example_ru: "отель", example_uk: "готель", example_en: "hotel"
|
||||
},
|
||||
{
|
||||
upper: "I", lower: "i",
|
||||
pronunciation_pl: "i", pronunciation_ru: "и", pronunciation_uk: "і", pronunciation_en: "ee",
|
||||
example_pl: "igła", example_ru: "игла", example_uk: "голка", example_en: "needle"
|
||||
},
|
||||
{
|
||||
upper: "J", lower: "j",
|
||||
pronunciation_pl: "j", pronunciation_ru: "й", pronunciation_uk: "й", pronunciation_en: "y",
|
||||
example_pl: "jabłko", example_ru: "яблоко", example_uk: "яблуко", example_en: "apple"
|
||||
},
|
||||
{
|
||||
upper: "K", lower: "k",
|
||||
pronunciation_pl: "k", pronunciation_ru: "к", pronunciation_uk: "к", pronunciation_en: "k",
|
||||
example_pl: "kot", example_ru: "кот", example_uk: "кіт", example_en: "cat"
|
||||
},
|
||||
{
|
||||
upper: "L", lower: "l",
|
||||
pronunciation_pl: "l", pronunciation_ru: "л", pronunciation_uk: "л", pronunciation_en: "l",
|
||||
example_pl: "las", example_ru: "лес", example_uk: "ліс", example_en: "forest"
|
||||
},
|
||||
{
|
||||
upper: "Ł", lower: "ł",
|
||||
pronunciation_pl: "nosowe 'u'", pronunciation_ru: "у (носовое)", pronunciation_uk: "у (носове)", pronunciation_en: "nasal 'u'",
|
||||
example_pl: "łódź", example_ru: "лодка", example_uk: "човен", example_en: "boat"
|
||||
},
|
||||
{
|
||||
upper: "M", lower: "m",
|
||||
pronunciation_pl: "m", pronunciation_ru: "м", pronunciation_uk: "м", pronunciation_en: "m",
|
||||
example_pl: "mama", example_ru: "мама", example_uk: "мама", example_en: "mother"
|
||||
},
|
||||
{
|
||||
upper: "N", lower: "n",
|
||||
pronunciation_pl: "n", pronunciation_ru: "н", pronunciation_uk: "н", pronunciation_en: "n",
|
||||
example_pl: "noc", example_ru: "ночь", example_uk: "ніч", example_en: "night"
|
||||
},
|
||||
{
|
||||
upper: "Ń", lower: "ń",
|
||||
pronunciation_pl: "miękkie 'n'", pronunciation_ru: "нь", pronunciation_uk: "нь", pronunciation_en: "soft 'n'",
|
||||
example_pl: "koń", example_ru: "конь", example_uk: "кінь", example_en: "horse"
|
||||
},
|
||||
{
|
||||
upper: "O", lower: "o",
|
||||
pronunciation_pl: "o", pronunciation_ru: "о", pronunciation_uk: "о", pronunciation_en: "o",
|
||||
example_pl: "oko", example_ru: "глаз", example_uk: "око", example_en: "eye"
|
||||
},
|
||||
{
|
||||
upper: "Ó", lower: "ó",
|
||||
pronunciation_pl: "u", pronunciation_ru: "у", pronunciation_uk: "у", pronunciation_en: "oo",
|
||||
example_pl: "góra", example_ru: "гора", example_uk: "гора", example_en: "mountain"
|
||||
},
|
||||
{
|
||||
upper: "P", lower: "p",
|
||||
pronunciation_pl: "p", pronunciation_ru: "п", pronunciation_uk: "п", pronunciation_en: "p",
|
||||
example_pl: "pies", example_ru: "собака", example_uk: "собака", example_en: "dog"
|
||||
},
|
||||
{
|
||||
upper: "R", lower: "r",
|
||||
pronunciation_pl: "r", pronunciation_ru: "р", pronunciation_uk: "р", pronunciation_en: "r (rolled)",
|
||||
example_pl: "rower", example_ru: "велосипед", example_uk: "велосипед", example_en: "bicycle"
|
||||
},
|
||||
{
|
||||
upper: "S", lower: "s",
|
||||
pronunciation_pl: "s", pronunciation_ru: "с", pronunciation_uk: "с", pronunciation_en: "s",
|
||||
example_pl: "samochód", example_ru: "машина", example_uk: "автомобіль", example_en: "car"
|
||||
},
|
||||
{
|
||||
upper: "Ś", lower: "ś",
|
||||
pronunciation_pl: "miękkie 'ś'", pronunciation_ru: "мягкое 'щ'", pronunciation_uk: "м'яке 'щ'", pronunciation_en: "soft 'sh'",
|
||||
example_pl: "śnieg", example_ru: "снег", example_uk: "сніг", example_en: "snow"
|
||||
},
|
||||
{
|
||||
upper: "T", lower: "t",
|
||||
pronunciation_pl: "t", pronunciation_ru: "т", pronunciation_uk: "т", pronunciation_en: "t",
|
||||
example_pl: "telefon", example_ru: "телефон", example_uk: "телефон", example_en: "telephone"
|
||||
},
|
||||
{
|
||||
upper: "U", lower: "u",
|
||||
pronunciation_pl: "u", pronunciation_ru: "у", pronunciation_uk: "у", pronunciation_en: "oo",
|
||||
example_pl: "ulica", example_ru: "улица", example_uk: "вулиця", example_en: "street"
|
||||
},
|
||||
{
|
||||
upper: "W", lower: "w",
|
||||
pronunciation_pl: "w", pronunciation_ru: "в", pronunciation_uk: "в", pronunciation_en: "v",
|
||||
example_pl: "woda", example_ru: "вода", example_uk: "вода", example_en: "water"
|
||||
},
|
||||
{
|
||||
upper: "Y", lower: "y",
|
||||
pronunciation_pl: "y", pronunciation_ru: "ы", pronunciation_uk: "и", pronunciation_en: "i (like in 'bit')",
|
||||
example_pl: "ryba", example_ru: "рыба", example_uk: "риба", example_en: "fish"
|
||||
},
|
||||
{
|
||||
upper: "Z", lower: "z",
|
||||
pronunciation_pl: "z", pronunciation_ru: "з", pronunciation_uk: "з", pronunciation_en: "z",
|
||||
example_pl: "zamek", example_ru: "замок", example_uk: "замок", example_en: "castle"
|
||||
},
|
||||
{
|
||||
upper: "Ź", lower: "ź",
|
||||
pronunciation_pl: "miękkie 'ź'", pronunciation_ru: "мягкое 'жь'", pronunciation_uk: "м'яке 'жь'", pronunciation_en: "soft 'zh'",
|
||||
example_pl: "źle", example_ru: "плохо", example_uk: "погано", example_en: "badly"
|
||||
},
|
||||
{
|
||||
upper: "Ż", lower: "ż",
|
||||
pronunciation_pl: "ż", pronunciation_ru: "ж", pronunciation_uk: "ж", pronunciation_en: "zh (like 's' in 'treasure')",
|
||||
example_pl: "żaba", example_ru: "лягушка", example_uk: "жаба", example_en: "frog"
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,123 @@
|
||||
export interface Diacritic {
|
||||
letter: string;
|
||||
pronunciation: string;
|
||||
description_pl: string;
|
||||
description_ru: string;
|
||||
description_uk: string;
|
||||
description_en: string;
|
||||
example_pl: string;
|
||||
example_ru: string;
|
||||
example_uk: string;
|
||||
example_en: string;
|
||||
}
|
||||
|
||||
export const polishDiacritics: Diacritic[] = [
|
||||
{
|
||||
letter: "Ą ą",
|
||||
pronunciation: "он / ом",
|
||||
description_pl: "Dźwięk nosowy. Przed B i P czyta się jak 'om'.",
|
||||
description_ru: "Носовой звук. Перед B и P читается как 'ом'.",
|
||||
description_uk: "Носовий звук. Перед B і P читається як 'ом'.",
|
||||
description_en: "Nasal sound. Before B and P, it's pronounced like 'om'.",
|
||||
example_pl: "kąt",
|
||||
example_ru: "угол",
|
||||
example_uk: "кут",
|
||||
example_en: "angle"
|
||||
},
|
||||
{
|
||||
letter: "Ę ę",
|
||||
pronunciation: "эн / ем",
|
||||
description_pl: "Dźwięk nosowy. Na końcu słowa często brzmi prawie jak 'e'.",
|
||||
description_ru: "Носовой звук. В конце слова часто звучит почти как 'э'.",
|
||||
description_uk: "Носовий звук. У кінці слова часто звучить майже як 'е'.",
|
||||
description_en: "Nasal sound. At the end of a word, it often sounds almost like 'e'.",
|
||||
example_pl: "zęby",
|
||||
example_ru: "зубы",
|
||||
example_uk: "зуби",
|
||||
example_en: "teeth"
|
||||
},
|
||||
{
|
||||
letter: "Ć ć",
|
||||
pronunciation: "мягкое 'чь'",
|
||||
description_pl: "Bardzo miękki dźwięk, podobny do rosyjskiego 'чь'.",
|
||||
description_ru: "Очень мягкий звук, похожий на русское 'чь'.",
|
||||
description_uk: "Дуже м'який звук, схожий на російське 'чь'.",
|
||||
description_en: "A very soft sound, similar to the Russian soft 'ch'.",
|
||||
example_pl: "ćma",
|
||||
example_ru: "моль",
|
||||
example_uk: "міль",
|
||||
example_en: "moth"
|
||||
},
|
||||
{
|
||||
letter: "Ś ś",
|
||||
pronunciation: "мягкое 'щ'",
|
||||
description_pl: "Bardzo miękkie 'sz', podobne do rosyjskiego 'щ'.",
|
||||
description_ru: "Очень мягкое 'ш', похожее на русское 'щ'.",
|
||||
description_uk: "Дуже м'яке 'ш', схоже на російське 'щ'.",
|
||||
description_en: "A very soft 'sh' sound, similar to the Russian soft 'shch'.",
|
||||
example_pl: "śnieg",
|
||||
example_ru: "снег",
|
||||
example_uk: "сніг",
|
||||
example_en: "snow"
|
||||
},
|
||||
{
|
||||
letter: "Ź ź",
|
||||
pronunciation: "мягкое 'жь'",
|
||||
description_pl: "Bardzo miękki dźwięk, podobny do rosyjskiego 'жь'.",
|
||||
description_ru: "Очень мягкий звук, похожий на русское 'жь'.",
|
||||
description_uk: "Дуже м'який звук, схожий на російське 'жь'.",
|
||||
description_en: "A very soft sound, similar to the Russian soft 'zh'.",
|
||||
example_pl: "źle",
|
||||
example_ru: "плохо",
|
||||
example_uk: "погано",
|
||||
example_en: "badly"
|
||||
},
|
||||
{
|
||||
letter: "Ń ń",
|
||||
pronunciation: "нь",
|
||||
description_pl: "Miękki dźwięk 'n', podobny do rosyjskiego 'нь'.",
|
||||
description_ru: "Мягкий звук 'н', похожий на русское 'нь'.",
|
||||
description_uk: "М'який звук 'н', схожий на російське 'нь'.",
|
||||
description_en: "A soft 'n' sound, similar to the Russian soft 'n'.",
|
||||
example_pl: "koń",
|
||||
example_ru: "конь",
|
||||
example_uk: "кінь",
|
||||
example_en: "horse"
|
||||
},
|
||||
{
|
||||
letter: "Ó ó",
|
||||
pronunciation: "у",
|
||||
description_pl: "Wymawia się tak samo jak literę U.",
|
||||
description_ru: "Произносится так же, как буква U.",
|
||||
description_uk: "Вимовляється так само, як літера U.",
|
||||
description_en: "Pronounced the same as the letter U.",
|
||||
example_pl: "góra",
|
||||
example_ru: "гора",
|
||||
example_uk: "гора",
|
||||
example_en: "mountain"
|
||||
},
|
||||
{
|
||||
letter: "Ł ł",
|
||||
pronunciation: "у (носовое)",
|
||||
description_pl: "Wymawia się jak nosowe 'u', nie jak rosyjskie Л.",
|
||||
description_ru: "Произносится как носовое 'у', не как русская Л.",
|
||||
description_uk: "Вимовляється як носове 'у', не як російська Л.",
|
||||
description_en: "Pronounced as a nasal 'u', not like the Russian Л.",
|
||||
example_pl: "łódź",
|
||||
example_ru: "лодка",
|
||||
example_uk: "човен",
|
||||
example_en: "boat"
|
||||
},
|
||||
{
|
||||
letter: "Ż ż",
|
||||
pronunciation: "ж",
|
||||
description_pl: "Twardy dźwięk 'ż'. Nie mylić z Ź.",
|
||||
description_ru: "Твёрдый звук 'ж'. Не путать с Ź.",
|
||||
description_uk: "Твердий звук 'ж'. Не плутати з Ź.",
|
||||
description_en: "A hard 'zh' sound. Not to be confused with Ź.",
|
||||
example_pl: "żaba",
|
||||
example_ru: "лягушка",
|
||||
example_uk: "жаба",
|
||||
example_en: "frog"
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,237 @@
|
||||
export interface FalseFriend {
|
||||
id: number;
|
||||
word_pl: string;
|
||||
false_meaning: string;
|
||||
true_meaning: string;
|
||||
}
|
||||
|
||||
export interface ExtendedFalseFriend {
|
||||
id: number;
|
||||
word_pl: string;
|
||||
obvious_meaning: string;
|
||||
additional_meaning: string;
|
||||
}
|
||||
|
||||
// ============ РУССКИЙ СПИСОК ============
|
||||
export const falseFriendsRu: FalseFriend[] = [
|
||||
{ id: 1, word_pl: 'zapomnieć', false_meaning: 'запомнить', true_meaning: 'забыть' },
|
||||
{ id: 2, word_pl: 'zapamiętać', false_meaning: 'запамятовать', true_meaning: 'запомнить' },
|
||||
{ id: 3, word_pl: 'dywan', false_meaning: 'диван', true_meaning: 'ковёр' },
|
||||
{ id: 4, word_pl: 'dziwny', false_meaning: 'дивный', true_meaning: 'странный' },
|
||||
{ id: 5, word_pl: 'dzierżawa', false_meaning: 'держава', true_meaning: 'аренда' },
|
||||
{ id: 6, word_pl: 'gnój', false_meaning: 'гной', true_meaning: 'навоз, помёт' },
|
||||
{ id: 7, word_pl: 'godzić się', false_meaning: 'годиться', true_meaning: 'мириться, примиряться' },
|
||||
{ id: 8, word_pl: 'gotować się', false_meaning: 'готовиться', true_meaning: 'кипеть, вариться' },
|
||||
{ id: 9, word_pl: 'górny', false_meaning: 'горный', true_meaning: 'верхний' },
|
||||
{ id: 10, word_pl: 'kawior', false_meaning: 'ковёр (созвучие)', true_meaning: 'икра' },
|
||||
{ id: 11, word_pl: 'kąpać się', false_meaning: 'капать', true_meaning: 'купаться' },
|
||||
{ id: 12, word_pl: 'kiedy', false_meaning: 'кеды', true_meaning: 'когда' },
|
||||
{ id: 13, word_pl: 'kokarda', false_meaning: 'кокарда', true_meaning: 'бант' },
|
||||
{ id: 14, word_pl: 'komórka', false_meaning: 'каморка', true_meaning: 'клетка (биол.), мобильный телефон' },
|
||||
{ id: 15, word_pl: 'konieczny', false_meaning: '—', true_meaning: 'необходимый, обязательный' },
|
||||
{ id: 16, word_pl: 'garnitur', false_meaning: 'гарнитур (мебель)', true_meaning: 'мужской костюм' },
|
||||
{ id: 17, word_pl: 'głaz', false_meaning: 'глаз', true_meaning: 'скала, валун' },
|
||||
{ id: 18, word_pl: 'czaszka', false_meaning: 'чашка', true_meaning: 'череп' },
|
||||
{ id: 19, word_pl: 'sklep', false_meaning: 'склеп', true_meaning: 'магазин' },
|
||||
{ id: 20, word_pl: 'grób', false_meaning: 'гроб', true_meaning: 'могила' },
|
||||
{ id: 21, word_pl: 'dynia', false_meaning: 'дыня', true_meaning: 'тыква' },
|
||||
{ id: 22, word_pl: 'widmo', false_meaning: 'видимо', true_meaning: 'призрак, привидение; спектр' },
|
||||
{ id: 23, word_pl: 'uroda', false_meaning: 'урод', true_meaning: 'красота' },
|
||||
{ id: 24, word_pl: 'magazyn', false_meaning: 'магазин', true_meaning: 'склад; журнал' },
|
||||
{ id: 25, word_pl: 'potrawa', false_meaning: 'отрава', true_meaning: 'блюдо (кулинарное)' },
|
||||
{ id: 26, word_pl: 'ukrop', false_meaning: 'укроп', true_meaning: 'кипяток' },
|
||||
{ id: 27, word_pl: 'puszka', false_meaning: 'пушка', true_meaning: 'консервная банка' },
|
||||
{ id: 28, word_pl: 'zakaz', false_meaning: 'заказ', true_meaning: 'запрет' },
|
||||
{ id: 29, word_pl: 'zakon', false_meaning: 'закон', true_meaning: 'монашеский орден' },
|
||||
{ id: 30, word_pl: 'zamach', false_meaning: 'замах', true_meaning: 'покушение' },
|
||||
{ id: 31, word_pl: 'zamordowany', false_meaning: 'замордованный', true_meaning: 'убитый' },
|
||||
{ id: 32, word_pl: 'podstawa', false_meaning: 'подстава', true_meaning: 'основа' },
|
||||
{ id: 33, word_pl: 'zasada', false_meaning: 'засада', true_meaning: 'принцип, основа' },
|
||||
{ id: 34, word_pl: 'prosto', false_meaning: 'просто', true_meaning: 'прямо' },
|
||||
{ id: 35, word_pl: 'rano', false_meaning: 'рано', true_meaning: 'утро, утром' },
|
||||
{ id: 36, word_pl: 'jutro', false_meaning: '—', true_meaning: 'завтра' },
|
||||
{ id: 37, word_pl: 'krzesło', false_meaning: 'кресло', true_meaning: 'стул' },
|
||||
{ id: 38, word_pl: 'krawat', false_meaning: 'кровать', true_meaning: 'галстук' },
|
||||
{ id: 39, word_pl: 'familia', false_meaning: 'фамилия', true_meaning: 'семья' },
|
||||
{ id: 40, word_pl: 'fistaszki', false_meaning: 'фисташки', true_meaning: 'арахис' },
|
||||
{ id: 41, word_pl: 'gadać', false_meaning: 'гадать', true_meaning: 'болтать, трепаться' },
|
||||
{ id: 42, word_pl: 'wymagać', false_meaning: 'вымогать', true_meaning: 'требовать, нуждаться' },
|
||||
{ id: 43, word_pl: 'wzór', false_meaning: 'взор', true_meaning: 'образец, пример' },
|
||||
{ id: 44, word_pl: 'zabieg', false_meaning: 'забег', true_meaning: 'процедура; хлопоты' },
|
||||
{ id: 45, word_pl: 'zabór', false_meaning: 'забор', true_meaning: 'захват, завоевание' },
|
||||
{ id: 46, word_pl: 'zachwyt', false_meaning: 'захват', true_meaning: 'восхищение' },
|
||||
{ id: 47, word_pl: 'zaginąć', false_meaning: 'погибнуть', true_meaning: 'потеряться, пропасть' },
|
||||
{ id: 48, word_pl: 'zajadły', false_meaning: 'заядлый', true_meaning: 'яростный, остервенелый' },
|
||||
{ id: 49, word_pl: 'wygodny', false_meaning: 'выгодный', true_meaning: 'удобный' },
|
||||
];
|
||||
|
||||
export const falseFriendsRuBonus: ExtendedFalseFriend[] = [
|
||||
{ id: 1, word_pl: 'akademik', obvious_meaning: 'академик', additional_meaning: 'студенческое общежитие' },
|
||||
{ id: 2, word_pl: 'bałwan', obvious_meaning: 'болван', additional_meaning: 'снеговик; идол' },
|
||||
{ id: 3, word_pl: 'brązowy', obvious_meaning: 'бронзовый', additional_meaning: 'коричневый' },
|
||||
{ id: 4, word_pl: 'cierpieć', obvious_meaning: 'терпеть', additional_meaning: 'страдать' },
|
||||
{ id: 5, word_pl: 'dlaczego', obvious_meaning: 'зачем', additional_meaning: 'почему' },
|
||||
{ id: 6, word_pl: 'gniazdo', obvious_meaning: 'гнездо', additional_meaning: 'розетка' },
|
||||
{ id: 7, word_pl: 'jagoda', obvious_meaning: 'ягода', additional_meaning: 'черника' },
|
||||
{ id: 8, word_pl: 'kij', obvious_meaning: 'кий (бильярдный)', additional_meaning: 'любая палка, клюшка' },
|
||||
{ id: 9, word_pl: 'kolega', obvious_meaning: 'коллега', additional_meaning: 'друг, приятель' },
|
||||
{ id: 10, word_pl: 'kontakt', obvious_meaning: 'контакт', additional_meaning: 'розетка' },
|
||||
{ id: 11, word_pl: 'koło', obvious_meaning: 'круг', additional_meaning: 'колесо' },
|
||||
{ id: 12, word_pl: 'kuchnia', obvious_meaning: 'кухня', additional_meaning: 'кухонная плита' },
|
||||
{ id: 13, word_pl: 'kwas', obvious_meaning: 'квас', additional_meaning: 'кислота' },
|
||||
{ id: 14, word_pl: 'kwota', obvious_meaning: 'квота', additional_meaning: 'сумма (денег)' },
|
||||
{ id: 15, word_pl: 'nauka', obvious_meaning: 'наука', additional_meaning: 'учёба' },
|
||||
{ id: 16, word_pl: 'pilot', obvious_meaning: 'пилот', additional_meaning: 'пульт; экскурсовод' },
|
||||
{ id: 17, word_pl: 'prawo', obvious_meaning: 'право', additional_meaning: 'закон' },
|
||||
{ id: 18, word_pl: 'restauracja', obvious_meaning: 'реставрация', additional_meaning: 'ресторан' },
|
||||
{ id: 19, word_pl: 'surowy', obvious_meaning: 'суровый', additional_meaning: 'сырой' },
|
||||
{ id: 20, word_pl: 'sztuka', obvious_meaning: 'штука', additional_meaning: 'искусство; пьеса' },
|
||||
{ id: 21, word_pl: 'słodki', obvious_meaning: 'сладкий', additional_meaning: 'пресный, несолёный' },
|
||||
{ id: 22, word_pl: 'ważny', obvious_meaning: 'важный', additional_meaning: 'действительный (о документе)' },
|
||||
{ id: 23, word_pl: 'zły', obvious_meaning: 'злой', additional_meaning: 'плохой; неподходящий' },
|
||||
];
|
||||
|
||||
// ============ УКРАИНСКИЙ СПИСОК ============
|
||||
export const falseFriendsUk: FalseFriend[] = [
|
||||
{ id: 1, word_pl: 'Arka', false_meaning: 'арка', true_meaning: 'ковчег' },
|
||||
{ id: 2, word_pl: 'Awans', false_meaning: 'аванс', true_meaning: 'підвищення, просування' },
|
||||
{ id: 3, word_pl: 'Bakalie', false_meaning: 'бакалія', true_meaning: 'сухофрукти і горіхи' },
|
||||
{ id: 4, word_pl: 'Baśń', false_meaning: 'байка', true_meaning: 'казка' },
|
||||
{ id: 5, word_pl: 'Belka', false_meaning: 'білка', true_meaning: 'балка' },
|
||||
{ id: 6, word_pl: 'Bohater', false_meaning: 'богатир', true_meaning: 'герой' },
|
||||
{ id: 7, word_pl: 'Broń', false_meaning: 'бронь', true_meaning: 'зброя' },
|
||||
{ id: 8, word_pl: 'Błysk', false_meaning: 'блиск', true_meaning: 'спалах, проблиск (або блешня)' },
|
||||
{ id: 9, word_pl: 'Błędny', false_meaning: 'блідий', true_meaning: 'помилковий, хибний' },
|
||||
{ id: 10, word_pl: 'Ciecierzyca', false_meaning: 'сочевиця', true_meaning: 'нут' },
|
||||
{ id: 11, word_pl: 'Czaszka', false_meaning: 'чашка', true_meaning: 'череп' },
|
||||
{ id: 12, word_pl: 'Dawka', false_meaning: 'давка', true_meaning: 'доза' },
|
||||
{ id: 13, word_pl: 'Dogadać się', false_meaning: 'здогадатися', true_meaning: 'порозумітися' },
|
||||
{ id: 14, word_pl: 'Dowolny', false_meaning: 'задоволений', true_meaning: 'довільний' },
|
||||
{ id: 15, word_pl: 'Dynia', false_meaning: 'диня', true_meaning: 'гарбуз' },
|
||||
{ id: 16, word_pl: 'Dywan', false_meaning: 'диван', true_meaning: 'килим' },
|
||||
{ id: 17, word_pl: 'Dzieło', false_meaning: 'діло', true_meaning: 'витвір' },
|
||||
{ id: 18, word_pl: 'Gadać', false_meaning: 'гадати', true_meaning: 'базікати, теревенити' },
|
||||
{ id: 19, word_pl: 'Gospodarka', false_meaning: 'господарка', true_meaning: 'економіка (або господарство)' },
|
||||
{ id: 20, word_pl: 'Grubość', false_meaning: 'грубість', true_meaning: 'товщина' },
|
||||
{ id: 21, word_pl: 'Grzywna', false_meaning: 'гривня', true_meaning: 'штраф' },
|
||||
{ id: 22, word_pl: 'Imiennik', false_meaning: 'іменник', true_meaning: 'тезко (або однофамілець)' },
|
||||
{ id: 23, word_pl: 'Jajecznica', false_meaning: 'яєчня (глазунья)', true_meaning: 'скрембл' },
|
||||
{ id: 24, word_pl: 'Kierownica', false_meaning: 'керівниця', true_meaning: 'кермо' },
|
||||
{ id: 25, word_pl: 'Kit', false_meaning: 'кіт', true_meaning: 'мастика' },
|
||||
{ id: 26, word_pl: 'Konieczność', false_meaning: 'кінцівка', true_meaning: 'необхідність' },
|
||||
{ id: 27, word_pl: 'Krzesło', false_meaning: 'крісло', true_meaning: 'стілець' },
|
||||
{ id: 28, word_pl: 'Kubek', false_meaning: 'кубок', true_meaning: 'кухоль' },
|
||||
{ id: 29, word_pl: 'Laskowy', false_meaning: 'лагідний', true_meaning: 'лісовий' },
|
||||
{ id: 30, word_pl: 'Magazyn', false_meaning: 'магазин', true_meaning: 'склад (або журнал)' },
|
||||
{ id: 31, word_pl: 'Mecz', false_meaning: 'меч', true_meaning: 'матч' },
|
||||
{ id: 32, word_pl: 'Mylny', false_meaning: 'мильний', true_meaning: 'помилковий' },
|
||||
{ id: 33, word_pl: 'Nagły', false_meaning: 'нахабний', true_meaning: 'раптовий, неочікуваний' },
|
||||
{ id: 34, word_pl: 'Naprawić', false_meaning: 'направити', true_meaning: 'виправити, налагодити' },
|
||||
{ id: 35, word_pl: 'Niewidomy', false_meaning: 'невідомий', true_meaning: 'незрячий' },
|
||||
{ id: 36, word_pl: 'Nocnik', false_meaning: 'нічник', true_meaning: 'горщик (туалетний)' },
|
||||
{ id: 37, word_pl: 'Objawy', false_meaning: "об'яви", true_meaning: 'симптоми' },
|
||||
{ id: 38, word_pl: 'Obywatel', false_meaning: 'обиватель', true_meaning: 'громадянин' },
|
||||
{ id: 39, word_pl: 'Oddychanie', false_meaning: 'відпочинок', true_meaning: 'дихання' },
|
||||
{ id: 40, word_pl: 'Owoce', false_meaning: 'овочі', true_meaning: 'фрукти' },
|
||||
{ id: 41, word_pl: 'Palacz', false_meaning: 'кат', true_meaning: 'курець (або кочегар)' },
|
||||
{ id: 42, word_pl: 'Pamiętnik', false_meaning: "пам'ятник", true_meaning: 'щоденник (або мемуари)' },
|
||||
{ id: 43, word_pl: 'Pensja', false_meaning: 'пенсія', true_meaning: 'зарплата' },
|
||||
{ id: 44, word_pl: 'Pieczen', false_meaning: 'печінка', true_meaning: "печеня (тушковане м'ясо)" },
|
||||
{ id: 45, word_pl: 'Pieczywo', false_meaning: 'печиво', true_meaning: 'випічка' },
|
||||
{ id: 46, word_pl: 'Plecy', false_meaning: 'плечі', true_meaning: 'спина' },
|
||||
{ id: 47, word_pl: 'Pojazd', false_meaning: 'поїзд', true_meaning: 'транспортний засіб' },
|
||||
{ id: 48, word_pl: 'Polny', false_meaning: 'повний', true_meaning: 'польовий' },
|
||||
{ id: 49, word_pl: 'Portfel', false_meaning: 'портфель', true_meaning: 'гаманець' },
|
||||
{ id: 50, word_pl: 'Przypadek', false_meaning: 'припадок', true_meaning: 'випадок (або відмінок)' },
|
||||
{ id: 51, word_pl: 'Puszka', false_meaning: 'пушка', true_meaning: 'бляшанка' },
|
||||
{ id: 52, word_pl: 'Pyszny', false_meaning: 'пишний', true_meaning: 'дуже смачний' },
|
||||
{ id: 53, word_pl: 'Płyn', false_meaning: 'плин', true_meaning: 'рідина' },
|
||||
{ id: 54, word_pl: 'Rano', false_meaning: 'рано', true_meaning: 'ранок (або вранці)' },
|
||||
{ id: 55, word_pl: 'Rodzice', false_meaning: 'родичі', true_meaning: 'батьки' },
|
||||
{ id: 56, word_pl: 'Rogatka', false_meaning: 'рогатка', true_meaning: 'шлагбаум' },
|
||||
{ id: 57, word_pl: 'Rosół', false_meaning: 'розсіл', true_meaning: 'бульйон' },
|
||||
{ id: 58, word_pl: 'Rozbierać się', false_meaning: 'розбиратися', true_meaning: 'роздягатися' },
|
||||
{ id: 59, word_pl: 'Rozkaz', false_meaning: 'розповідь', true_meaning: 'наказ, команда' },
|
||||
{ id: 60, word_pl: 'Rozwaga', false_meaning: 'розвага', true_meaning: 'розсудливість' },
|
||||
{ id: 61, word_pl: 'Samodzielny', false_meaning: 'саморобний', true_meaning: 'самостійний' },
|
||||
{ id: 62, word_pl: 'Ser', false_meaning: 'сер', true_meaning: 'сир' },
|
||||
{ id: 63, word_pl: 'Sklep', false_meaning: 'склеп', true_meaning: 'магазин' },
|
||||
{ id: 64, word_pl: 'Skoro', false_meaning: 'скоро', true_meaning: 'оскільки' },
|
||||
{ id: 65, word_pl: 'Sople', false_meaning: 'соплі', true_meaning: 'бурульки' },
|
||||
{ id: 66, word_pl: 'Sprzątać', false_meaning: 'заховати', true_meaning: 'прибирати (наводити порядок)' },
|
||||
{ id: 67, word_pl: 'Statek', false_meaning: 'статок', true_meaning: 'судно, корабель' },
|
||||
{ id: 68, word_pl: 'Szczyt', false_meaning: 'щит', true_meaning: 'вершина, верх (або саміт)' },
|
||||
{ id: 69, word_pl: 'Szlak', false_meaning: 'шлак', true_meaning: 'шлях, маршрут' },
|
||||
{ id: 70, word_pl: 'Szyny', false_meaning: 'шини', true_meaning: 'рейки' },
|
||||
{ id: 71, word_pl: 'Tlen', false_meaning: 'тлін', true_meaning: 'кисень' },
|
||||
{ id: 72, word_pl: 'Umowa', false_meaning: 'умова', true_meaning: 'угода, договір' },
|
||||
{ id: 73, word_pl: 'Umysł', false_meaning: 'умисел', true_meaning: 'розум' },
|
||||
{ id: 74, word_pl: 'Urok', false_meaning: 'урок', true_meaning: 'очарування' },
|
||||
{ id: 75, word_pl: 'Uznać', false_meaning: 'дізнатися', true_meaning: 'визнати' },
|
||||
{ id: 76, word_pl: 'Ułamek', false_meaning: 'уламок', true_meaning: 'дріб (або доля, частина)' },
|
||||
{ id: 77, word_pl: 'Wizerunek', false_meaning: 'візерунок', true_meaning: 'зображення (або імідж)' },
|
||||
{ id: 78, word_pl: 'Wniosek', false_meaning: 'внесок', true_meaning: 'висновок (або заява, пропозиція)' },
|
||||
{ id: 79, word_pl: 'Woń', false_meaning: 'сморід', true_meaning: 'аромат' },
|
||||
{ id: 80, word_pl: 'Wrzucać', false_meaning: 'вручати', true_meaning: 'вкидати' },
|
||||
{ id: 81, word_pl: 'Wygoda', false_meaning: 'вигода', true_meaning: 'зручність' },
|
||||
{ id: 82, word_pl: 'Wyzywać', false_meaning: 'визивати', true_meaning: 'обзивати' },
|
||||
{ id: 83, word_pl: 'Zabieg', false_meaning: 'забіг', true_meaning: 'процедура' },
|
||||
{ id: 84, word_pl: 'Zachowywać', false_meaning: 'заховувати', true_meaning: 'зберігати (або поводитись)' },
|
||||
{ id: 85, word_pl: 'Zakaz', false_meaning: 'замовлення', true_meaning: 'заборона' },
|
||||
{ id: 86, word_pl: 'Zamierzać', false_meaning: 'замерзати', true_meaning: 'мати намір, збиратися' },
|
||||
{ id: 87, word_pl: 'Zapomnieć', false_meaning: "запам'ятати", true_meaning: 'забути' },
|
||||
{ id: 88, word_pl: 'Zapowiedź', false_meaning: 'заповідь', true_meaning: 'оголошення, анонс' },
|
||||
{ id: 89, word_pl: 'Zasób', false_meaning: 'засіб', true_meaning: 'запас, ресурс' },
|
||||
{ id: 90, word_pl: 'Zawód', false_meaning: 'завод', true_meaning: 'професія' },
|
||||
{ id: 91, word_pl: 'Zdanie', false_meaning: 'будівля', true_meaning: 'речення (або думка, погляд)' },
|
||||
{ id: 92, word_pl: 'Zgadzać się', false_meaning: 'згадати', true_meaning: 'погоджуватись' },
|
||||
{ id: 93, word_pl: 'Śliwki', false_meaning: 'вершки', true_meaning: 'сливи' },
|
||||
{ id: 94, word_pl: 'Śpiączka', false_meaning: 'сплячка', true_meaning: 'кома (втрата свідомості)' },
|
||||
{ id: 95, word_pl: 'Żyletka', false_meaning: 'жилетка', true_meaning: 'лезо' },
|
||||
{ id: 96, word_pl: 'Żywot', false_meaning: 'живіт', true_meaning: 'життя' },
|
||||
];
|
||||
|
||||
export const falseFriendsUkBonus: ExtendedFalseFriend[] = [
|
||||
{ id: 1, word_pl: 'Akademik', obvious_meaning: 'академік', additional_meaning: 'гуртожиток' },
|
||||
{ id: 2, word_pl: 'Bałwan', obvious_meaning: 'бовдур', additional_meaning: 'сніговик; також ідол' },
|
||||
{ id: 3, word_pl: 'Brązowy', obvious_meaning: 'бронзовий', additional_meaning: 'коричневий' },
|
||||
{ id: 4, word_pl: 'Cierpieć', obvious_meaning: 'терпіти', additional_meaning: 'страждати' },
|
||||
{ id: 5, word_pl: 'Dlaczego', obvious_meaning: 'навіщо', additional_meaning: 'чому' },
|
||||
{ id: 6, word_pl: 'Gniazdo', obvious_meaning: 'гніздо', additional_meaning: 'розетка' },
|
||||
{ id: 7, word_pl: 'Góra', obvious_meaning: 'гора', additional_meaning: 'верх' },
|
||||
{ id: 8, word_pl: 'Jagoda', obvious_meaning: 'ягода', additional_meaning: 'чорниця' },
|
||||
{ id: 9, word_pl: 'Jama', obvious_meaning: 'яма', additional_meaning: 'порожнина' },
|
||||
{ id: 10, word_pl: 'Kij', obvious_meaning: 'кий', additional_meaning: 'ключка; кран для пива' },
|
||||
{ id: 11, word_pl: 'Kolega', obvious_meaning: 'колега', additional_meaning: 'друг, приятель' },
|
||||
{ id: 12, word_pl: 'Kontakt', obvious_meaning: 'контакт', additional_meaning: 'розетка' },
|
||||
{ id: 13, word_pl: 'Koło', obvious_meaning: 'коло', additional_meaning: 'колесо' },
|
||||
{ id: 14, word_pl: 'Kuchnia', obvious_meaning: 'кухня', additional_meaning: 'кухонна плита' },
|
||||
{ id: 15, word_pl: 'Kurka', obvious_meaning: 'курка', additional_meaning: 'лисичка (гриб)' },
|
||||
{ id: 16, word_pl: 'Kwas', obvious_meaning: 'квас', additional_meaning: 'кислота' },
|
||||
{ id: 17, word_pl: 'Kwota', obvious_meaning: 'квота', additional_meaning: 'сума (грошей)' },
|
||||
{ id: 18, word_pl: 'Nauka', obvious_meaning: 'наука', additional_meaning: 'навчання' },
|
||||
{ id: 19, word_pl: 'Opuścić', obvious_meaning: 'опустити', additional_meaning: 'покинути' },
|
||||
{ id: 20, word_pl: 'Parkiet', obvious_meaning: 'паркет', additional_meaning: 'танцпол; біржа' },
|
||||
{ id: 21, word_pl: 'Pilot', obvious_meaning: 'пілот', additional_meaning: 'пульт; екскурсовод' },
|
||||
{ id: 22, word_pl: 'Pluton', obvious_meaning: 'Плутон', additional_meaning: 'плутоній; взвод' },
|
||||
{ id: 23, word_pl: 'Po', obvious_meaning: 'по', additional_meaning: 'після' },
|
||||
{ id: 24, word_pl: 'Prawo', obvious_meaning: 'право', additional_meaning: 'закон' },
|
||||
{ id: 25, word_pl: 'Prosto', obvious_meaning: 'просто', additional_meaning: 'прямо' },
|
||||
{ id: 26, word_pl: 'Przystawka', obvious_meaning: 'приставка', additional_meaning: 'закуска' },
|
||||
{ id: 27, word_pl: 'Restauracja', obvious_meaning: 'реставрація', additional_meaning: 'ресторан' },
|
||||
{ id: 28, word_pl: 'Sprawiedliwość', obvious_meaning: 'справедливість', additional_meaning: 'правосуддя' },
|
||||
{ id: 29, word_pl: 'Surowy', obvious_meaning: 'суворий', additional_meaning: 'сирий' },
|
||||
{ id: 30, word_pl: 'Sztuka', obvious_meaning: 'штука', additional_meaning: "мистецтво; п'єса" },
|
||||
{ id: 31, word_pl: 'Słodki', obvious_meaning: 'солодкий', additional_meaning: 'прісний' },
|
||||
{ id: 32, word_pl: 'Terminowy', obvious_meaning: 'терміновий', additional_meaning: 'вчасний, пунктуальний' },
|
||||
{ id: 33, word_pl: 'Trampolina', obvious_meaning: 'трамплін', additional_meaning: 'батут' },
|
||||
{ id: 34, word_pl: 'Twierdzenie', obvious_meaning: 'твердження', additional_meaning: 'теорема' },
|
||||
{ id: 35, word_pl: 'Upadek', obvious_meaning: 'упадок', additional_meaning: 'падіння' },
|
||||
{ id: 36, word_pl: 'Ważny', obvious_meaning: 'важливий', additional_meaning: 'дійсний' },
|
||||
{ id: 37, word_pl: 'Wiatrak', obvious_meaning: 'вітряк', additional_meaning: 'вентилятор' },
|
||||
{ id: 38, word_pl: 'Wymazać', obvious_meaning: 'вимазати', additional_meaning: 'стерти, усунути' },
|
||||
{ id: 39, word_pl: 'Wypadek', obvious_meaning: 'випадок', additional_meaning: 'аварія' },
|
||||
{ id: 40, word_pl: 'Zakładać', obvious_meaning: 'закладати', additional_meaning: 'вдягати; встановлювати' },
|
||||
{ id: 41, word_pl: 'Zapasy', obvious_meaning: 'запаси', additional_meaning: 'боротьба (спорт)' },
|
||||
{ id: 42, word_pl: 'Zły', obvious_meaning: 'злий', additional_meaning: 'поганий; непідхожий' },
|
||||
];
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
|
||||
export interface AchievementNotification {
|
||||
id: string;
|
||||
icon: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const useAchievements = () => {
|
||||
const [achievementQueue, setAchievementQueue] = useState<AchievementNotification[]>([]);
|
||||
const [activeAchievement, setActiveAchievement] = useState<AchievementNotification | null>(null);
|
||||
const achievementTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
const showAchievement = useCallback((ach: AchievementNotification) => {
|
||||
if (!isMountedRef.current) return;
|
||||
setAchievementQueue(prev => [...prev, ach]);
|
||||
}, []);
|
||||
|
||||
const processQueue = useCallback(() => {
|
||||
setAchievementQueue(prev => {
|
||||
if (prev.length === 0) return prev;
|
||||
const [next, ...rest] = prev;
|
||||
setActiveAchievement(next);
|
||||
|
||||
if (achievementTimeoutRef.current) clearTimeout(achievementTimeoutRef.current);
|
||||
achievementTimeoutRef.current = setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
setActiveAchievement(null);
|
||||
setAchievementQueue(rest);
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
return rest;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeAchievement === null && achievementQueue.length > 0) {
|
||||
processQueue();
|
||||
}
|
||||
}, [activeAchievement, achievementQueue, processQueue]);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
if (achievementTimeoutRef.current) clearTimeout(achievementTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const resetAchievements = useCallback(() => {
|
||||
setAchievementQueue([]);
|
||||
setActiveAchievement(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
activeAchievement,
|
||||
showAchievement,
|
||||
resetAchievements,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useLanguage } from '../i18n/LanguageContext';
|
||||
|
||||
/**
|
||||
* Хук для локализации объектов из БД.
|
||||
*
|
||||
* Использование:
|
||||
* const getLocalized = useLocalized();
|
||||
* getLocalized(lesson, 'title') // вернёт title_ru для русского
|
||||
* getLocalized(option, 'text') // вернёт text_ru для русского
|
||||
*
|
||||
* Логика:
|
||||
* 1. Если есть поле `{field}_{language}` — берём его
|
||||
* 2. Иначе fallback на `{field}_en`
|
||||
* 3. Иначе пустая строка
|
||||
*/
|
||||
export const useLocalized = () => {
|
||||
const { language } = useLanguage();
|
||||
|
||||
return (item: any, field: string): string => {
|
||||
if (!item) return '';
|
||||
const key = `${field}_${language}`;
|
||||
const fallback = `${field}_en`;
|
||||
return item[key] || item[fallback] || '';
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import React, { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
||||
import { getTranslation } from './translations';
|
||||
import { setLanguage } from '../api';
|
||||
import { getUserId } from '../telegram';
|
||||
|
||||
interface LanguageContextType {
|
||||
language: string;
|
||||
setLang: (lang: string) => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
const LanguageContext = createContext<LanguageContextType>({
|
||||
language: 'pl',
|
||||
setLang: () => {},
|
||||
t: (key: string) => key,
|
||||
});
|
||||
|
||||
export const useLanguage = () => useContext(LanguageContext);
|
||||
|
||||
export const LanguageProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [language, setLanguageState] = useState(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const langFromUrl = urlParams.get('lang');
|
||||
const langFromStorage = localStorage.getItem('filwords_lang');
|
||||
return langFromUrl || langFromStorage || 'pl';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('filwords_lang', language);
|
||||
if (getUserId()) {
|
||||
setLanguage(language).catch(() => {});
|
||||
}
|
||||
}, [language]);
|
||||
|
||||
const setLang = (lang: string) => {
|
||||
if (['pl', 'ru', 'uk', 'en'].includes(lang)) {
|
||||
setLanguageState(lang);
|
||||
}
|
||||
};
|
||||
|
||||
const t = (key: string): string => {
|
||||
return getTranslation(language, key);
|
||||
};
|
||||
|
||||
return (
|
||||
<LanguageContext.Provider value={{ language, setLang, t }}>
|
||||
{children}
|
||||
</LanguageContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,934 @@
|
||||
export const translations: Record<string, Record<string, string>> = {
|
||||
pl: {
|
||||
menu: "Menu",
|
||||
play: "Filwords",
|
||||
games: "Gry",
|
||||
pronunciation: "Wymowa",
|
||||
tapToListen: "Kliknij na słowo, aby usłyszeć wymowę",
|
||||
leaderboard: "Ranking",
|
||||
settings: "Ustawienia",
|
||||
back: "Powrót",
|
||||
level: "Poziom",
|
||||
score: "Punkty",
|
||||
hints: "Podpowiedzi",
|
||||
firstLetter: "Pierwsza litera",
|
||||
showWords: "Pokaż słowa",
|
||||
completed: "Ukończono",
|
||||
search: "Szukaj",
|
||||
loading: "Ładowanie...",
|
||||
noLevels: "Brak poziomów",
|
||||
sentenceBuilder: "Ułóż zdanie",
|
||||
comingSoon: "Wkrótce",
|
||||
selectLanguage: "Wybierz język",
|
||||
yourScore: "Twój wynik",
|
||||
levelsCompleted: "Ukończone poziomy",
|
||||
rank: "Miejsce",
|
||||
player: "Gracz",
|
||||
congratulations: "Gratulacje!",
|
||||
levelCompleted: "Poziom ukończony!",
|
||||
nextLevel: "Następny poziom",
|
||||
foundWords: "Znalezione słowa",
|
||||
noPlayers: "Brak graczy",
|
||||
beFirst: "Bądź pierwszy!",
|
||||
wordNotFound: "Nie znaleziono słowa",
|
||||
buildSentence: "Ułóż zdanie ze słów:",
|
||||
correctAnswer: "Poprawnie!",
|
||||
tryAgain: "Spróbuj jeszcze raz!",
|
||||
tapWords: "Klikaj w słowa aby ułożyć zdanie...",
|
||||
reset: "Reset",
|
||||
check: "Sprawdź",
|
||||
allSentencesCompleted: "Wszystkie zdania ukończone!",
|
||||
remaining: "pozostało",
|
||||
noMoreHints: "Wykorzystane",
|
||||
noMoreFirstLetterHints: "Brak podpowiedzi pierwszej litery",
|
||||
freeToUse: "Darmowe",
|
||||
tapToClose: "Kliknij aby zamknąć",
|
||||
alreadyCompleted: "Już ukończono!",
|
||||
alreadyCompletedDesc: "Ten poziom został już ukończony. Punkty zostały przyznane wcześniej.",
|
||||
backToMenu: "Powrót do menu",
|
||||
achievement: "Osiągnięcie",
|
||||
wordSearchRulesTitle: "📝 Zasady gry Filwordy",
|
||||
wordSearchRulesContent: "Jak grać w Filwordy:\n\nNa planszy ukryte są słowa w języku polskim.\nSłowa mogą być ułożone w górę, w dół, w lewo lub w prawo (bez skosów).\nAby znaleźć słowo, przeciągnij palcem po literach od pierwszej do ostatniej.\nOdnalezione słowa zostaną podświetlone na zielono.\n\nPodpowiedzi:\n\n💡 Pierwsza litera: pokazuje pierwszą literę losowego słowa (3 użycia na poziom).\n\n📖 Pokaż słowa: wyświetla wszystkie słowa z danego poziomu wraz z tłumaczeniem (bez ograniczeń).\n\nPunkty:\n\nZa ukończenie poziomu otrzymujesz 100 punktów minus 10 za każdą używaną podpowiedź.\nIm mniej podpowiedzi użyjesz, tym więcej punktów zdobędziesz!\n\nPowodzenia w nauce języka polskiego! 🇵🇱",
|
||||
sentenceRulesTitle: "📝 Zasady gry Ułóż zdanie",
|
||||
sentenceRulesContent: "Jak grać w Ułóż zdanie:\n\nZobaczysz tłumaczenie zdania na swój język.\nSłowa będą ułożone w losowej kolejności.\nKlikaj słowa, aby ułożyć poprawne zdanie.\nWybrane słowa pojawią się w polu układania w kolejności kliknięcia.\nAby usunąć słowo z pola, kliknij je ponownie.\n\nSprawdzanie:\n\n✓ Kliknij Sprawdź, gdy ułożysz zdanie.\n\n✓ Jeśli odpowiedź jest poprawna, otrzymasz punkty i przejdziesz do następnego zdania.\n\n✗ Jeśli odpowiedź jest błędna, słowa wrócą na swoje miejsce. Spróbuj ponownie!\n\nPunkty:\n\n• 3-4 słowa: 50 punktów\n• 5-6 słów: 75 punktów\n• 7 i więcej słów: 100 punktów\n\nPodpowiedź:\n\n💡 Pokaż zdanie: 3 użycia, pokazuje poprawne zdanie na 2,5 sekundy.\n\nWskazówka: Uważnie przeczytaj tłumaczenie i pamiętaj o kolejności wyrazów w języku polskim!",
|
||||
wordQuizRulesTitle: "🎯 Zasady gry Wybierz słowo",
|
||||
wordQuizRulesContent: "Jak grać w Wybierz słowo:\n\nZobaczysz słowo w swoim języku.\nWybierz poprawny przekład na język polski spośród trzech opcji.\n\nPunktacja:\n\nZa poprawną odpowiedź bez błędów otrzymujesz 50 punktów.\nKażda pomyłka odejmuje 20 punktów.\nPo dwóch pomyłkach zostanie tylko poprawna opcja, a ty otrzymasz 10 punktów.\n\nPowodzenia! 🎯",
|
||||
bubbleWords: "Bubbles",
|
||||
bubbleWordsRulesTitle: "🫧 Zasady gry Bubbles",
|
||||
bubbleWordsRulesContent: "Jak grać w Bubbles:\n\nNa ekranie pojawiają się bąbelki z częściami słów.\nKlikaj bąbelki, aby zebrać części i ułożyć z nich polskie słowa.\nGdy zbierzesz wszystkie części słowa, bąbelki znikną!\n\nPodpowiedzi:\n\n💡 Pierwsza litera: 3 użycia na poziom.\n📖 Pokaż słowa: wyświetla wszystkie słowa z tłumaczeniem.\n\nPunkty:\n\nZa ukończenie poziomu otrzymujesz 100 punktów minus 10 za każdą używaną podpowiedź.\n\nPowodzenia! 🫧",
|
||||
pronunciationRulesTitle: "🔊 Zasady wymowy",
|
||||
pronunciationRulesContent: "Jak ćwiczyć wymowę:\n\nKliknij na kartę ze słowem, aby usłyszeć jego wymowę.\nSłowo zostanie odtworzone przez lektora.\n\nPoziomy:\n\nSłowa są dobierane z różnych poziomów (A1-C2).\nIm wyższy poziom, tym trudniejsze słowa.\n\nWskazówka:\n\nSłuchaj uważnie i powtarzaj na głos, aby poprawić swoją wymowę!\n\nPowodzenia! 🇵🇱",
|
||||
flashcards: "Gramatyka",
|
||||
texts: "Teksty",
|
||||
selectLevel: "Wybierz poziom:",
|
||||
levelA1Name: "Początkujący",
|
||||
levelA2Name: "Podstawowy",
|
||||
levelB1Name: "Średniozaawansowany",
|
||||
levelB2Name: "Wyższy średni",
|
||||
levelC1Name: "Zaawansowany",
|
||||
levelC2Name: "Biegły",
|
||||
toLevels: "Poziomy",
|
||||
translate: "Tłumacz",
|
||||
showOriginal: "Pokaż oryginał",
|
||||
next: "Dalej",
|
||||
wordQuiz: "Wybierz słowo",
|
||||
chooseTranslation: "Wybierz poprawny przekład:",
|
||||
wordsGuessed: "Odgadnięte słowa",
|
||||
outOfLives: "Koniec żyć!",
|
||||
points: "punktów",
|
||||
mistakes: "Błędy",
|
||||
sentenceHint: "Podpowiedź",
|
||||
sentenceHintUsed: "Podpowiedź wykorzystana",
|
||||
achievementCategoryWordsearch: "🎮 Filwordy",
|
||||
achievementCategorySentence: "📝 Ułóż zdanie",
|
||||
achievementCategoryWordquiz: "🎯 Wybierz słowo",
|
||||
achievementCategoryBubblewords: "🫧 Bubbles",
|
||||
achievementCategoryGeneral: "🏆 Ogólne",
|
||||
teacher: "Nauczyciel",
|
||||
teacherWelcome: "Twój osobisty nauczyciel polskiego!",
|
||||
teacherStart: "Rozpocznij lekcję",
|
||||
teacherAnswer: "Odpowiedź",
|
||||
teacherExplain: "Wyjaśnij",
|
||||
teacherTranslate: "Przetłumacz",
|
||||
teacherExample: "Przykład",
|
||||
teacherAnalytics: "Analityka",
|
||||
teacherCompleted: "Lekcja zakończona!",
|
||||
teacherPlaceholder: "Wpisz odpowiedź...",
|
||||
teacherMenu: "Menu",
|
||||
teacherReset: "Zacznij od nowa",
|
||||
teacherTranslateToPolish: "Przetłumacz na polski",
|
||||
teacherDiagnostic: "Sprawdź poziom",
|
||||
teacherContinue: "Kontynuuj",
|
||||
teacherSend: "Wyślij",
|
||||
teacherWrongAnswer: "Twoja odpowiedź",
|
||||
teacherCorrectAnswer: "Prawidłowa odpowiedź",
|
||||
teacherWhy: "Dlaczego",
|
||||
teacherShowAnswer: "Pokaż poprawną odpowiedź",
|
||||
teacherClose: "Zamknij",
|
||||
typingTitle: "Pisanie",
|
||||
typingInstructions: "Przepisz zdanie po polsku:",
|
||||
typingPlaceholder: "Wpisz po polsku...",
|
||||
typingShowAnswer: "Pokaż odpowiedź",
|
||||
typingHideAnswer: "Ukryj odpowiedź",
|
||||
typingCorrect: "Dobrze! Następne zdanie...",
|
||||
typingIncorrect: "Spróbuj jeszcze raz!",
|
||||
teacherNewLesson: "Następna lekcja",
|
||||
teacherAIError: "Nie udało się uzyskać odpowiedzi. Spróbuj ponownie.",
|
||||
teacherTestCompleted: "📊 Test zakończony!",
|
||||
practiceTitle: "Trening słabych tematów",
|
||||
practiceCompleted: "Trening zakończony!",
|
||||
practiceCorrectAnswers: "poprawnych odpowiedzi",
|
||||
testTitle: "Test końcowy",
|
||||
testQuestion: "Pytanie",
|
||||
testCorrectAnswers: "poprawnych odpowiedzi",
|
||||
testBackToTopic: "Powrót do tematu",
|
||||
testChecking: "Sprawdzanie...",
|
||||
testFinish: "Zakończ test",
|
||||
testExamplesButton: "Daj przykłady",
|
||||
testPracticeButton: "Trenuj słabe tematy",
|
||||
testExamplesTitle: "Przykłady",
|
||||
testNoExamples: "Nie udało się pobrać przykładów",
|
||||
testPracticeNoWeak: "Brak słabych tematów, świetna robota!",
|
||||
topicLesson: "Lekcja",
|
||||
topicFinalTest: "Test końcowy",
|
||||
hintButton: "Podpowiedź",
|
||||
hintUsed: "Podpowiedź wykorzystana",
|
||||
correctOption: "Prawidłowa odpowiedź",
|
||||
whyLabel: "Dlaczego",
|
||||
nextSentence: "Następne zdanie",
|
||||
nextQuestion: "Dalej",
|
||||
teacherMenuStart: "Rozpocznij lekcję",
|
||||
teacherMenuAlphabet: "Alfabet",
|
||||
teacherMenuRules: "Zasady",
|
||||
teacherMenuCheatsheets: "Ściągi",
|
||||
teacherMenuTranslate: "Przetłumacz na polski",
|
||||
teacherMenuExample: "Przykład",
|
||||
teacherMenuAnalytics: "Analityka",
|
||||
teacherMenuReset: "Zacznij od nowa",
|
||||
teacherMenuProgress: "Postęp",
|
||||
teacherMenuTopicsCompleted: "Tematy ukończone",
|
||||
teacherMenuOf: "z",
|
||||
teacherMenuPhrase: "Fraza dnia",
|
||||
teacherMenuTranslation: "Tłumaczenie",
|
||||
teacherMenuGrammar: "Gramatyka",
|
||||
teacherMenuFalseFriends: "Fałszywi przyjaciele",
|
||||
teacherMenuSeems: "Wydaje się",
|
||||
teacherMenuActually: "W rzeczywistości",
|
||||
teacherMenuMain: "Główne",
|
||||
teacherMenuBonus: "Bonus",
|
||||
teacherMenuObvious: "Oczywiste",
|
||||
teacherMenuAdditional: "Dodatkowe",
|
||||
// ============ Onboarding ============
|
||||
onboardingStep1Title: "Witaj! Jestem Marek.",
|
||||
onboardingStep1Text: "Jestem twoim osobistym nauczycielem polskiego.\nBędę prowadzić cię krok po kroku, od pierwszego słowa do swobodnej rozmowy.",
|
||||
onboardingStep2Title: "Dział Nauczyciel",
|
||||
onboardingStep2Text: "Tu zaczyna się prawdziwa nauka.\nLekcje, testy, gramatyka, fraza dnia, wszystko w jednym miejscu.\n\nOdpowiadaj na pytania, a ja ocenię twoją odpowiedź i wyjaśnię błędy.",
|
||||
onboardingStep3Title: "Gry",
|
||||
onboardingStep3Text: "Nie lubisz siedzieć nad książkami? Zagraj!\n\nCztery gry: Filwords (znajdź słowa w siatce), Ułóż zdanie (buduj zdania z klocków), Wybierz słowo (tłumaczenie), Bąbelki (zbieraj części słów).\n\nSłowa zapamiętują się same.",
|
||||
onboardingStep4Title: "Pisanie i wymowa",
|
||||
onboardingStep4Text: "Pisanie: przepisuj zdania po polsku, żeby zapamiętać pisownię.\n\nWymowa: słuchaj, jak brzmią polskie słowa, i powtarzaj na głos.\n\nTe dwa działy robią z ciebie prawdziwego użytkownika języka.",
|
||||
onboardingStep5Title: "Zaczynamy!",
|
||||
onboardingStep5Text: "Twój poziom: A1. Możesz go zmienić w menu, kliknij przycisk z poziomem w prawym górnym rogu.\n\nZacznij od działu Nauczyciel, tam czeka na ciebie pierwsza lekcja.\n\nPowodzenia!",
|
||||
onboardingNext: "Dalej",
|
||||
onboardingStart: "Zaczynamy!",
|
||||
onboardingRestart: "Zobacz jeszcze raz",
|
||||
onboardingSkip: "Pomiń",
|
||||
// ============ Carousel ============
|
||||
carouselPartners: "Partnerzy",
|
||||
carouselPartnersDesc: "Sprawdzone firmy",
|
||||
carouselContacts: "Przydatne kontakty",
|
||||
carouselContactsDesc: "Wynajem, dokumenty, prawnicy",
|
||||
carouselApps: "Przydatne aplikacje",
|
||||
carouselAppsDesc: "Aplikacje do nauki",
|
||||
carouselNews: "Nowości",
|
||||
carouselNewsDesc: "Co nowego w Filwords",
|
||||
partnersModalTitle: "Partnerzy",
|
||||
partnersModalEmpty: "Wkrótce pojawią się tu nasi partnerzy",
|
||||
contactsModalTitle: "Przydatne kontakty",
|
||||
contactsModalEmpty: "Wkrótce dodamy przydatne kontakty",
|
||||
appsModalTitle: "Przydatne aplikacje",
|
||||
appsModalEmpty: "Wkrótce dodamy listę aplikacji",
|
||||
newsModalTitle: "Nowości i ogłoszenia",
|
||||
newsModalEmpty: "Wkrótce pojawią się tu nowości",
|
||||
modalGotIt: "Rozumiem",
|
||||
// ============ Feed ============
|
||||
feedEmpty: "Na razie brak postów",
|
||||
feedOpenLink: "Otwórz link",
|
||||
feedContactsBannerTitle: "Masz propozycję?",
|
||||
feedContactsBannerSubtitle: "Napisz do nas →",
|
||||
// 🔧 НОВОЕ: баннер «По вопросам рекламы» для Партнёров
|
||||
feedPartnersBannerTitle: "W sprawie reklamy",
|
||||
feedPartnersBannerSubtitle: "Napisz do nas →",
|
||||
// 🔧 НОВОЕ (Шаг 6): админ-строка ввода
|
||||
feedAdminPlaceholder: "Napisz post...",
|
||||
feedAdminSend: "Wyślij",
|
||||
feedAdminAttach: "Załącz zdjęcie",
|
||||
feedAdminTranslate: "Przetłumacz",
|
||||
feedAdminPreview: "Podgląd tłumaczenia",
|
||||
feedAdminPublish: "Opublikuj",
|
||||
feedAdminCancel: "Anuluj",
|
||||
feedAdminEdit: "Edytuj",
|
||||
feedAdminDelete: "Usuń",
|
||||
feedAdminDeleteConfirm: "Usunąć ten post?",
|
||||
feedAdminDeleteYes: "Tak, usuń",
|
||||
feedAdminDeleteNo: "Nie",
|
||||
feedAdminPublishing: "Publikowanie...",
|
||||
feedAdminTranslating: "Tłumaczenie...",
|
||||
feedAdminUploadingImage: "Przesyłanie zdjęcia...",
|
||||
feedAdminMaxImages: "Maksymalnie 3 zdjęcia",
|
||||
feedAdminTooManyImages: "Za dużo zdjęć (maks. 3)",
|
||||
feedAdminImageTooBig: "Zdjęcie jest za duże (maks. 5 MB)",
|
||||
feedAdminImageBadType: "Dozwolone tylko: JPEG, PNG, WEBP, GIF",
|
||||
feedAdminTextRequired: "Wpisz tekst posta",
|
||||
feedAdminPinned: "Przypnij",
|
||||
feedAdminExpiresAt: "Data wygaśnięcia (opcjonalnie)",
|
||||
feedAdminLinkUrl: "Link (opcjonalnie)",
|
||||
feedAdminTelegramUrl: "Telegram URL (opcjonalnie)",
|
||||
feedAdminSourceLang: "Język źródłowy",
|
||||
feedAdminTranslateAgain: "Przetłumacz ponownie",
|
||||
feedAdminErrorTranslate: "Nie udało się przetłumaczyć",
|
||||
feedAdminErrorPublish: "Nie udało się opublikować",
|
||||
feedAdminErrorDelete: "Nie udało się usunąć",
|
||||
feedAdminSuccess: "Gotowe!",
|
||||
},
|
||||
ru: {
|
||||
menu: "Меню",
|
||||
play: "Filwords",
|
||||
games: "Игры",
|
||||
pronunciation: "Произношение",
|
||||
tapToListen: "Нажми на слово, чтобы услышать произношение",
|
||||
leaderboard: "Рейтинг",
|
||||
settings: "Настройки",
|
||||
back: "Назад",
|
||||
level: "Уровень",
|
||||
score: "Очки",
|
||||
hints: "Подсказки",
|
||||
firstLetter: "Первая буква",
|
||||
showWords: "Показать слова",
|
||||
completed: "Пройдено",
|
||||
search: "Поиск",
|
||||
loading: "Загрузка...",
|
||||
noLevels: "Нет уровней",
|
||||
sentenceBuilder: "Составь предложение",
|
||||
comingSoon: "Скоро",
|
||||
selectLanguage: "Выберите язык",
|
||||
yourScore: "Ваш счёт",
|
||||
levelsCompleted: "Пройдено уровней",
|
||||
rank: "Место",
|
||||
player: "Игрок",
|
||||
congratulations: "Поздравляем!",
|
||||
levelCompleted: "Уровень пройден!",
|
||||
nextLevel: "Следующий уровень",
|
||||
foundWords: "Найденные слова",
|
||||
noPlayers: "Пока нет игроков",
|
||||
beFirst: "Стань первым!",
|
||||
wordNotFound: "Слово не найдено",
|
||||
buildSentence: "Составь предложение из слов:",
|
||||
correctAnswer: "Правильно!",
|
||||
tryAgain: "Попробуй ещё раз!",
|
||||
tapWords: "Нажимай на слова чтобы составить предложение...",
|
||||
reset: "Сбросить",
|
||||
check: "Проверить",
|
||||
allSentencesCompleted: "Все предложения пройдены!",
|
||||
remaining: "осталось",
|
||||
noMoreHints: "Использованы",
|
||||
noMoreFirstLetterHints: "Подсказки первой буквы закончились",
|
||||
freeToUse: "Бесплатно",
|
||||
tapToClose: "Нажми чтобы закрыть",
|
||||
alreadyCompleted: "Уже пройдено!",
|
||||
alreadyCompletedDesc: "Этот уровень был пройден ранее. Очки уже начислены.",
|
||||
backToMenu: "Вернуться в меню",
|
||||
achievement: "Достижение",
|
||||
wordSearchRulesTitle: "📝 Правила игры Филворды",
|
||||
wordSearchRulesContent: "Как играть в Филворды:\n\nНа игровом поле спрятаны слова на польском языке.\nСлова могут идти вверх, вниз, влево или вправо (без диагоналей).\nЧтобы найти слово, проведите пальцем по буквам от первой до последней.\nНайденные слова подсвечиваются зелёным цветом.\n\nПодсказки:\n\n💡 Первая буква: показывает первую букву случайного слова (3 использования на уровень).\n\n📖 Показать слова: показывает все слова уровня с переводом (без ограничений).\n\nОчки:\n\nЗа прохождение уровня начисляется 100 очков минус 10 за каждую использованную подсказку.\nЧем меньше подсказок используете, тем больше очков получите!\n\nУдачи в изучении польского языка! 🇵🇱",
|
||||
sentenceRulesTitle: "📝 Правила игры Составь предложение",
|
||||
sentenceRulesContent: "Как играть в Составь предложение:\n\nВы увидите перевод предложения на ваш язык.\nСлова перемешаны в случайном порядке.\nНажимайте на слова, чтобы составить правильное предложение.\nСлова появятся в строке сборки в порядке нажатия.\nЧтобы убрать слово из строки, нажмите на него.\n\nПроверка:\n\n✓ Нажмите Проверить, когда составите предложение.\n\n✓ Если всё правильно, получите очки и перейдёте к следующему предложению.\n\n✗ Если есть ошибка, слова вернутся обратно. Попробуйте снова!\n\nОчки:\n\n• 3-4 слова: 50 очков\n• 5-6 слов: 75 очков\n• 7 и более слов: 100 очков\n\nПодсказка:\n\n💡 Показать предложение: 3 использования, показывает правильное предложение на 2,5 секунды.\n\nСовет: Внимательно читайте перевод и помните о порядке слов в польском языке!",
|
||||
wordQuizRulesTitle: "🎯 Правила игры Выбери слово",
|
||||
wordQuizRulesContent: "Как играть в Выбери слово:\n\nВы увидите слово на своём языке.\nВыберите правильный перевод на польский из трёх вариантов.\n\nОчки:\n\nЗа правильный ответ без ошибок вы получаете 50 очков.\nКаждая ошибка отнимает 20 очков.\nПосле двух ошибок останется только правильный вариант, и вы получите 10 очков.\n\nУдачи! 🎯",
|
||||
bubbleWords: "Bubbles",
|
||||
bubbleWordsRulesTitle: "🫧 Правила игры Bubbles",
|
||||
bubbleWordsRulesContent: "Как играть в Bubbles:\n\nНа экране появляются пузыри с частями слов.\nНажимайте на пузыри, чтобы собрать части и составить из них польские слова.\nКогда соберёте все части слова, пузыри исчезнут!\n\nПодсказки:\n\n💡 Первая буква: 3 использования на уровень.\n📖 Показать слова: показывает все слова с переводом.\n\nОчки:\n\nЗа прохождение уровня вы получаете 100 очков минус 10 за каждую использованную подсказку.\n\nУдачи! 🫧",
|
||||
pronunciationRulesTitle: "🔊 Правила произношения",
|
||||
pronunciationRulesContent: "Как тренировать произношение:\n\nНажми на карточку со словом, чтобы услышать его произношение.\nСлово будет воспроизведено диктором.\n\nУровни:\n\nСлова подбираются из разных уровней (A1-C2).\nЧем выше уровень, тем сложнее слова.\n\nСовет:\n\nСлушай внимательно и повторяй вслух, чтобы улучшить своё произношение!\n\nУдачи! 🇵🇱",
|
||||
flashcards: "Грамматика",
|
||||
texts: "Тексты",
|
||||
selectLevel: "Выберите уровень:",
|
||||
levelA1Name: "Начинающий",
|
||||
levelA2Name: "Базовый",
|
||||
levelB1Name: "Средний",
|
||||
levelB2Name: "Выше среднего",
|
||||
levelC1Name: "Продвинутый",
|
||||
levelC2Name: "Профессиональный",
|
||||
toLevels: "Уровни",
|
||||
translate: "Перевести",
|
||||
showOriginal: "Показать оригинал",
|
||||
next: "Далее",
|
||||
wordQuiz: "Выбери слово",
|
||||
chooseTranslation: "Выбери правильный перевод:",
|
||||
wordsGuessed: "Угадано слов",
|
||||
outOfLives: "Жизни закончились!",
|
||||
points: "очков",
|
||||
mistakes: "Ошибки",
|
||||
sentenceHint: "Подсказка",
|
||||
sentenceHintUsed: "Подсказка использована",
|
||||
achievementCategoryWordsearch: "🎮 Филворды",
|
||||
achievementCategorySentence: "📝 Составь предложение",
|
||||
achievementCategoryWordquiz: "🎯 Выбери слово",
|
||||
achievementCategoryBubblewords: "🫧 Bubbles",
|
||||
achievementCategoryGeneral: "🏆 Общие",
|
||||
teacher: "Учитель",
|
||||
teacherWelcome: "Твой личный учитель польского!",
|
||||
teacherStart: "Начать урок",
|
||||
teacherAnswer: "Ответить",
|
||||
teacherExplain: "Объясни",
|
||||
teacherTranslate: "Переведи",
|
||||
teacherExample: "Пример",
|
||||
teacherAnalytics: "Аналитика",
|
||||
teacherCompleted: "Урок завершён!",
|
||||
teacherPlaceholder: "Введите ответ...",
|
||||
teacherMenu: "Меню",
|
||||
teacherReset: "Начать заново",
|
||||
teacherTranslateToPolish: "Переведи на польский",
|
||||
teacherDiagnostic: "Узнать уровень",
|
||||
teacherContinue: "Продолжить",
|
||||
teacherSend: "Отправить",
|
||||
teacherWrongAnswer: "Твой ответ",
|
||||
teacherCorrectAnswer: "Правильный ответ",
|
||||
teacherWhy: "Почему",
|
||||
teacherShowAnswer: "Показать правильный ответ",
|
||||
teacherClose: "Закрыть",
|
||||
typingTitle: "Печать",
|
||||
typingInstructions: "Напечатай предложение на польском:",
|
||||
typingPlaceholder: "Напечатай на польском...",
|
||||
typingShowAnswer: "Показать ответ",
|
||||
typingHideAnswer: "Скрыть ответ",
|
||||
typingCorrect: "Правильно! Следующее предложение...",
|
||||
typingIncorrect: "Попробуй ещё раз!",
|
||||
teacherNewLesson: "Следующий урок",
|
||||
teacherAIError: "Не удалось получить ответ. Попробуй ещё раз.",
|
||||
teacherTestCompleted: "📊 Тест завершён!",
|
||||
practiceTitle: "Тренировка слабых тем",
|
||||
practiceCompleted: "Тренировка завершена!",
|
||||
practiceCorrectAnswers: "правильных ответов",
|
||||
testTitle: "Финальный тест",
|
||||
testQuestion: "Вопрос",
|
||||
testCorrectAnswers: "правильных ответов",
|
||||
testBackToTopic: "Вернуться к теме",
|
||||
testChecking: "Проверка...",
|
||||
testFinish: "Завершить тест",
|
||||
testExamplesButton: "Дай примеры по теме",
|
||||
testPracticeButton: "Потренировать слабые темы",
|
||||
testExamplesTitle: "Примеры по теме",
|
||||
testNoExamples: "Не удалось получить примеры",
|
||||
testPracticeNoWeak: "Нет слабых навыков, отличная работа!",
|
||||
topicLesson: "Урок",
|
||||
topicFinalTest: "Финальный тест",
|
||||
hintButton: "Подсказка",
|
||||
hintUsed: "Подсказка использована",
|
||||
correctOption: "Правильный ответ",
|
||||
whyLabel: "Почему",
|
||||
nextSentence: "Следующее предложение",
|
||||
nextQuestion: "Далее",
|
||||
teacherMenuStart: "Начать урок",
|
||||
teacherMenuAlphabet: "Алфавит",
|
||||
teacherMenuRules: "Правила",
|
||||
teacherMenuCheatsheets: "Шпаргалки",
|
||||
teacherMenuTranslate: "Переведи на польский",
|
||||
teacherMenuExample: "Пример",
|
||||
teacherMenuAnalytics: "Аналитика",
|
||||
teacherMenuReset: "Начать заново",
|
||||
teacherMenuProgress: "Прогресс",
|
||||
teacherMenuTopicsCompleted: "Тем пройдено",
|
||||
teacherMenuOf: "из",
|
||||
teacherMenuPhrase: "Фраза дня",
|
||||
teacherMenuTranslation: "Перевод",
|
||||
teacherMenuGrammar: "Грамматика",
|
||||
teacherMenuFalseFriends: "Ложные друзья",
|
||||
teacherMenuSeems: "Кажется",
|
||||
teacherMenuActually: "На самом деле",
|
||||
teacherMenuMain: "Основные",
|
||||
teacherMenuBonus: "Бонус",
|
||||
teacherMenuObvious: "Очевидное",
|
||||
teacherMenuAdditional: "Дополнительное",
|
||||
// ============ Onboarding ============
|
||||
onboardingStep1Title: "Привет! Я Марек.",
|
||||
onboardingStep1Text: "Я твой личный учитель польского.\nБуду вести тебя шаг за шагом, от первого слова до свободного разговора.",
|
||||
onboardingStep2Title: "Раздел Учитель",
|
||||
onboardingStep2Text: "Здесь начинается настоящая учёба.\nУроки, тесты, грамматика, фраза дня, всё в одном месте.\n\nОтвечай на вопросы, а я оценю твой ответ и объясню ошибки.",
|
||||
onboardingStep3Title: "Игры",
|
||||
onboardingStep3Text: "Не любишь сидеть над учебниками? Тогда играй!\n\nЧетыре игры: Filwords (найди слова на поле), Ułóż zdanie (собери предложение из блоков), Wybierz słowo (перевод), Bąbelki (собирай части слов).\n\nСлова запоминаются сами собой.",
|
||||
onboardingStep4Title: "Письмо и произношение",
|
||||
onboardingStep4Text: "Письмо: переписывай предложения по-польски, чтобы запомнить написание.\n\nПроизношение: слушай, как звучат польские слова, и повторяй вслух.\n\nЭти два раздела делают тебя настоящим пользователем языка.",
|
||||
onboardingStep5Title: "Начинаем!",
|
||||
onboardingStep5Text: "Твой уровень: A1. Можешь изменить его в меню, нажми на кнопку с уровнем в правом верхнем углу.\n\nНачни с раздела «Учитель», там тебя ждёт первый урок.\n\nУдачи!",
|
||||
onboardingNext: "Далее",
|
||||
onboardingStart: "Начинаем!",
|
||||
onboardingRestart: "Посмотреть ещё раз",
|
||||
onboardingSkip: "Пропустить",
|
||||
// ============ Carousel ============
|
||||
carouselPartners: "Партнёры",
|
||||
carouselPartnersDesc: "Проверенные компании",
|
||||
carouselContacts: "Полезные контакты",
|
||||
carouselContactsDesc: "Аренда, документы, юристы",
|
||||
carouselApps: "Полезные приложения",
|
||||
carouselAppsDesc: "Приложения для учёбы",
|
||||
carouselNews: "Новости",
|
||||
carouselNewsDesc: "Что нового в Filwords",
|
||||
partnersModalTitle: "Партнёры",
|
||||
partnersModalEmpty: "Скоро здесь появятся наши партнёры",
|
||||
contactsModalTitle: "Полезные контакты",
|
||||
contactsModalEmpty: "Скоро добавим полезные контакты",
|
||||
appsModalTitle: "Полезные приложения",
|
||||
appsModalEmpty: "Скоро добавим список приложений",
|
||||
newsModalTitle: "Новости и анонсы",
|
||||
newsModalEmpty: "Скоро здесь появятся новости",
|
||||
modalGotIt: "Понятно",
|
||||
// ============ Feed ============
|
||||
feedEmpty: "Пока нет постов",
|
||||
feedOpenLink: "Открыть ссылку",
|
||||
feedContactsBannerTitle: "Есть предложение?",
|
||||
feedContactsBannerSubtitle: "Написать нам →",
|
||||
// 🔧 НОВОЕ: баннер «По вопросам рекламы» для Партнёров
|
||||
feedPartnersBannerTitle: "По вопросам рекламы",
|
||||
feedPartnersBannerSubtitle: "Написать нам →",
|
||||
// 🔧 НОВОЕ (Шаг 6): админ-строка ввода
|
||||
feedAdminPlaceholder: "Написать пост...",
|
||||
feedAdminSend: "Отправить",
|
||||
feedAdminAttach: "Прикрепить фото",
|
||||
feedAdminTranslate: "Перевести",
|
||||
feedAdminPreview: "Превью перевода",
|
||||
feedAdminPublish: "Опубликовать",
|
||||
feedAdminCancel: "Отмена",
|
||||
feedAdminEdit: "Редактировать",
|
||||
feedAdminDelete: "Удалить",
|
||||
feedAdminDeleteConfirm: "Удалить этот пост?",
|
||||
feedAdminDeleteYes: "Да, удалить",
|
||||
feedAdminDeleteNo: "Нет",
|
||||
feedAdminPublishing: "Публикую...",
|
||||
feedAdminTranslating: "Перевожу...",
|
||||
feedAdminUploadingImage: "Загружаю фото...",
|
||||
feedAdminMaxImages: "Максимум 3 фото",
|
||||
feedAdminTooManyImages: "Слишком много фото (макс. 3)",
|
||||
feedAdminImageTooBig: "Фото слишком большое (макс. 5 МБ)",
|
||||
feedAdminImageBadType: "Разрешены только: JPEG, PNG, WEBP, GIF",
|
||||
feedAdminTextRequired: "Введите текст поста",
|
||||
feedAdminPinned: "Закрепить",
|
||||
feedAdminExpiresAt: "Дата истечения (опционально)",
|
||||
feedAdminLinkUrl: "Ссылка (опционально)",
|
||||
feedAdminTelegramUrl: "Telegram URL (опционально)",
|
||||
feedAdminSourceLang: "Язык источника",
|
||||
feedAdminTranslateAgain: "Пересоздать перевод",
|
||||
feedAdminErrorTranslate: "Не удалось перевести",
|
||||
feedAdminErrorPublish: "Не удалось опубликовать",
|
||||
feedAdminErrorDelete: "Не удалось удалить",
|
||||
feedAdminSuccess: "Готово!",
|
||||
},
|
||||
uk: {
|
||||
menu: "Меню",
|
||||
play: "Filwords",
|
||||
games: "Ігри",
|
||||
pronunciation: "Вимова",
|
||||
tapToListen: "Натисни на слово, щоб почути вимову",
|
||||
leaderboard: "Рейтинг",
|
||||
settings: "Налаштування",
|
||||
back: "Назад",
|
||||
level: "Рівень",
|
||||
score: "Бали",
|
||||
hints: "Підказки",
|
||||
firstLetter: "Перша літера",
|
||||
showWords: "Показати слова",
|
||||
completed: "Пройдено",
|
||||
search: "Пошук",
|
||||
loading: "Завантаження...",
|
||||
noLevels: "Немає рівнів",
|
||||
sentenceBuilder: "Склади речення",
|
||||
comingSoon: "Скоро",
|
||||
selectLanguage: "Виберіть мову",
|
||||
yourScore: "Ваш рахунок",
|
||||
levelsCompleted: "Пройдено рівнів",
|
||||
rank: "Місце",
|
||||
player: "Гравець",
|
||||
congratulations: "Вітаємо!",
|
||||
levelCompleted: "Рівень пройдено!",
|
||||
nextLevel: "Наступний рівень",
|
||||
foundWords: "Знайдені слова",
|
||||
noPlayers: "Поки немає гравців",
|
||||
beFirst: "Стань першим!",
|
||||
wordNotFound: "Слово не знайдено",
|
||||
buildSentence: "Склади речення зі слів:",
|
||||
correctAnswer: "Правильно!",
|
||||
tryAgain: "Спробуй ще раз!",
|
||||
tapWords: "Натискай на слова щоб скласти речення...",
|
||||
reset: "Скинути",
|
||||
check: "Перевірити",
|
||||
allSentencesCompleted: "Всі речення пройдено!",
|
||||
remaining: "залишилось",
|
||||
noMoreHints: "Використано",
|
||||
noMoreFirstLetterHints: "Підказки першої літери закінчились",
|
||||
freeToUse: "Безкоштовно",
|
||||
tapToClose: "Натисни щоб закрити",
|
||||
alreadyCompleted: "Вже пройдено!",
|
||||
alreadyCompletedDesc: "Цей рівень був пройдений раніше. Бали вже нараховані.",
|
||||
backToMenu: "Повернутися в меню",
|
||||
achievement: "Досягнення",
|
||||
wordSearchRulesTitle: "📝 Правила гри Філворди",
|
||||
wordSearchRulesContent: "Як грати у Філворди:\n\nНа ігровому полі заховані слова польською мовою.\nСлова можуть розташовуватися вгору, вниз, ліворуч або праворуч (без діагоналей).\nЩоб знайти слово, проведіть пальцем по літерах від першої до останньої.\nЗнайдені слова підсвічуються зеленим кольором.\n\nПідказки:\n\n💡 Перша літера: показує першу літеру випадкового слова (3 використання на рівень).\n\n📖 Показати слова: показує всі слова рівня з перекладом (без обмежень).\n\nБали:\n\nЗа проходження рівня нараховується 100 балів мінус 10 за кожну використану підказку.\nЧим менше підказок ви використаєте, тим більше балів отримаєте!\n\nУспіхів у вивченні польської мови! 🇵🇱",
|
||||
sentenceRulesTitle: "📝 Правила гри Склади речення",
|
||||
sentenceRulesContent: "Як грати у Склади речення:\n\nВи побачите переклад речення своєю мовою.\nСлова будуть перемішані у випадковому порядку.\nНатискайте на слова, щоб скласти правильне речення.\nОбрані слова з'являться в рядку складання у порядку натискання.\nЩоб прибрати слово з рядка, натисніть на нього ще раз.\n\nПеревірка:\n\n✓ Натисніть Перевірити, коли складете речення.\n\n✓ Якщо відповідь правильна, ви отримаєте бали та перейдете до наступного речення.\n\n✗ Якщо відповідь неправильна, слова повернуться назад. Спробуйте ще раз!\n\nБали:\n\n• 3-4 слова: 50 балів\n• 5-6 слів: 75 балів\n• 7 і більше слів: 100 балів\n\nПідказка:\n\n💡 Показати речення: 3 використання, показує правильне речення на 2,5 секунди.\n\nПорада: Уважно читайте переклад і пам'ятайте про порядок слів у польській мові!",
|
||||
wordQuizRulesTitle: "🎯 Правила гри Вибери слово",
|
||||
wordQuizRulesContent: "Як грати в Вибери слово:\n\nВи побачите слово своєю мовою.\nВиберіть правильний переклад польською з трьох варіантів.\n\nБали:\n\nЗа правильну відповідь без помилок ви отримуєте 50 балів.\nКожна помилка віднімає 20 балів.\nПісля двох помилок залишиться тільки правильний варіант, і ви отримаєте 10 балів.\n\nУспіхів! 🎯",
|
||||
bubbleWords: "Bubbles",
|
||||
bubbleWordsRulesTitle: "🫧 Правила гри Bubbles",
|
||||
bubbleWordsRulesContent: "Як грати в Bubbles:\n\nНа екрані з'являються бульбашки з частинами слів.\nНатискайте на бульбашки, щоб зібрати частини та скласти з них польські слова.\nКоли зберете всі частини слова, бульбашки зникнуть!\n\nПідказки:\n\n💡 Перша літера: 3 використання на рівень.\n📖 Показати слова: показує всі слова з перекладом.\n\nБали:\n\nЗа проходження рівня ви отримуєте 100 балів мінус 10 за кожну використану підказку.\n\nУспіхів! 🫧",
|
||||
pronunciationRulesTitle: "🔊 Правила вимови",
|
||||
pronunciationRulesContent: "Як тренувати вимову:\n\nНатисни на картку зі словом, щоб почути його вимову.\nСлово буде відтворено диктором.\n\nРівні:\n\nСлова підбираються з різних рівнів (A1-C2).\nЧим вищий рівень, тим складніші слова.\n\nПорада:\n\nСлухай уважно та повторюй вголос, щоб покращити свою вимову!\n\nУспіхів! 🇵🇱",
|
||||
flashcards: "Граматика",
|
||||
texts: "Тексти",
|
||||
selectLevel: "Виберіть рівень:",
|
||||
levelA1Name: "Початківець",
|
||||
levelA2Name: "Базовий",
|
||||
levelB1Name: "Середній",
|
||||
levelB2Name: "Вище середнього",
|
||||
levelC1Name: "Просунутий",
|
||||
levelC2Name: "Професійний",
|
||||
toLevels: "Рівні",
|
||||
translate: "Перекласти",
|
||||
showOriginal: "Показати оригінал",
|
||||
next: "Далі",
|
||||
wordQuiz: "Вибери слово",
|
||||
chooseTranslation: "Вибери правильний переклад:",
|
||||
wordsGuessed: "Відгадано слів",
|
||||
outOfLives: "Життя закінчились!",
|
||||
points: "балів",
|
||||
mistakes: "Помилки",
|
||||
sentenceHint: "Підказка",
|
||||
sentenceHintUsed: "Підказка використана",
|
||||
achievementCategoryWordsearch: "🎮 Філворди",
|
||||
achievementCategorySentence: "📝 Склади речення",
|
||||
achievementCategoryWordquiz: "🎯 Вибери слово",
|
||||
achievementCategoryBubblewords: "🫧 Bubbles",
|
||||
achievementCategoryGeneral: "🏆 Загальні",
|
||||
teacher: "Вчитель",
|
||||
teacherWelcome: "Твій особистий вчитель польської!",
|
||||
teacherStart: "Почати урок",
|
||||
teacherAnswer: "Відповісти",
|
||||
teacherExplain: "Поясни",
|
||||
teacherTranslate: "Переклади",
|
||||
teacherExample: "Приклад",
|
||||
teacherAnalytics: "Аналітика",
|
||||
teacherCompleted: "Урок завершено!",
|
||||
teacherPlaceholder: "Введіть відповідь...",
|
||||
teacherMenu: "Меню",
|
||||
teacherReset: "Почати заново",
|
||||
teacherTranslateToPolish: "Переклади на польську",
|
||||
teacherDiagnostic: "Дізнатися рівень",
|
||||
teacherContinue: "Продовжити",
|
||||
teacherSend: "Надіслати",
|
||||
teacherWrongAnswer: "Твоя відповідь",
|
||||
teacherCorrectAnswer: "Правильна відповідь",
|
||||
teacherWhy: "Чому",
|
||||
teacherShowAnswer: "Показати правильну відповідь",
|
||||
teacherClose: "Закрити",
|
||||
typingTitle: "Друк",
|
||||
typingInstructions: "Надрукуй речення польською:",
|
||||
typingPlaceholder: "Надрукуй польською...",
|
||||
typingShowAnswer: "Показати відповідь",
|
||||
typingHideAnswer: "Приховати відповідь",
|
||||
typingCorrect: "Правильно! Наступне речення...",
|
||||
typingIncorrect: "Спробуй ще раз!",
|
||||
teacherNewLesson: "Наступний урок",
|
||||
teacherAIError: "Не вдалося отримати відповідь. Спробуй ще раз.",
|
||||
teacherTestCompleted: "📊 Тест завершено!",
|
||||
practiceTitle: "Тренування слабких тем",
|
||||
practiceCompleted: "Тренування завершено!",
|
||||
practiceCorrectAnswers: "правильних відповідей",
|
||||
testTitle: "Фінальний тест",
|
||||
testQuestion: "Питання",
|
||||
testCorrectAnswers: "правильних відповідей",
|
||||
testBackToTopic: "Повернутися до теми",
|
||||
testChecking: "Перевірка...",
|
||||
testFinish: "Завершити тест",
|
||||
testExamplesButton: "Дай приклади",
|
||||
testPracticeButton: "Тренуй слабкі теми",
|
||||
testExamplesTitle: "Приклади",
|
||||
testNoExamples: "Не вдалося отримати приклади",
|
||||
testPracticeNoWeak: "Немає слабких тем, чудова робота!",
|
||||
topicLesson: "Урок",
|
||||
topicFinalTest: "Фінальний тест",
|
||||
hintButton: "Підказка",
|
||||
hintUsed: "Підказку використано",
|
||||
correctOption: "Правильна відповідь",
|
||||
whyLabel: "Чому",
|
||||
nextSentence: "Наступне речення",
|
||||
nextQuestion: "Далі",
|
||||
teacherMenuStart: "Почати урок",
|
||||
teacherMenuAlphabet: "Алфавіт",
|
||||
teacherMenuRules: "Правила",
|
||||
teacherMenuCheatsheets: "Шпаргалки",
|
||||
teacherMenuTranslate: "Переклади на польську",
|
||||
teacherMenuExample: "Приклад",
|
||||
teacherMenuAnalytics: "Аналітика",
|
||||
teacherMenuReset: "Почати заново",
|
||||
teacherMenuProgress: "Прогрес",
|
||||
teacherMenuTopicsCompleted: "Теми пройдено",
|
||||
teacherMenuOf: "з",
|
||||
teacherMenuPhrase: "Фраза дня",
|
||||
teacherMenuTranslation: "Переклад",
|
||||
teacherMenuGrammar: "Граматика",
|
||||
teacherMenuFalseFriends: "Хибні друзі",
|
||||
teacherMenuSeems: "Здається",
|
||||
teacherMenuActually: "Насправді",
|
||||
teacherMenuMain: "Основні",
|
||||
teacherMenuBonus: "Бонус",
|
||||
teacherMenuObvious: "Очевидне",
|
||||
teacherMenuAdditional: "Додаткове",
|
||||
// ============ Onboarding ============
|
||||
onboardingStep1Title: "Привіт! Я Марек.",
|
||||
onboardingStep1Text: "Я твій особистий вчитель польської.\nБуду вести тебе крок за кроком, від першого слова до вільної розмови.",
|
||||
onboardingStep2Title: "Розділ Вчитель",
|
||||
onboardingStep2Text: "Тут починається справжнє навчання.\nУроки, тести, граматика, фраза дня, все в одному місці.\n\nВідповідай на запитання, а я оцінюю твою відповідь і поясню помилки.",
|
||||
onboardingStep3Title: "Ігри",
|
||||
onboardingStep3Text: "Не любиш сидіти над підручниками? Тоді грай!\n\nЧотири гри: Filwords (знайди слова на полі), Ułóż zdanie (склади речення з блоків), Wybierz słowo (переклад), Bąbelki (збирай частини слів).\n\nСлова запам'ятовуються самі собою.",
|
||||
onboardingStep4Title: "Письмо і вимова",
|
||||
onboardingStep4Text: "Письмо: переписуй речення польською, щоб запам'ятати написання.\n\nВимова: слухай, як звучать польські слова, і повторюй уголос.\n\nЦі два розділи роблять з тебе справжнього користувача мови.",
|
||||
onboardingStep5Title: "Починаємо!",
|
||||
onboardingStep5Text: "Твій рівень: A1. Можеш змінити його в меню, натисни на кнопку з рівнем у правому верхньому куті.\n\nПочни з розділу «Вчитель», там на тебе чекає перший урок.\n\nУдачі!",
|
||||
onboardingNext: "Далі",
|
||||
onboardingStart: "Починаємо!",
|
||||
onboardingRestart: "Переглянути ще раз",
|
||||
onboardingSkip: "Пропустити",
|
||||
// ============ Carousel ============
|
||||
carouselPartners: "Партнери",
|
||||
carouselPartnersDesc: "Перевірені компанії",
|
||||
carouselContacts: "Корисні контакти",
|
||||
carouselContactsDesc: "Оренда, документи, юристи",
|
||||
carouselApps: "Корисні додатки",
|
||||
carouselAppsDesc: "Додатки для навчання",
|
||||
carouselNews: "Новини",
|
||||
carouselNewsDesc: "Що нового у Filwords",
|
||||
partnersModalTitle: "Партнери",
|
||||
partnersModalEmpty: "Скоро тут з'являться наші партнери",
|
||||
contactsModalTitle: "Корисні контакти",
|
||||
contactsModalEmpty: "Скоро додамо корисні контакти",
|
||||
appsModalTitle: "Корисні додатки",
|
||||
appsModalEmpty: "Скоро додамо список додатків",
|
||||
newsModalTitle: "Новини та анонси",
|
||||
newsModalEmpty: "Скоро тут з'являться новини",
|
||||
modalGotIt: "Зрозуміло",
|
||||
// ============ Feed ============
|
||||
feedEmpty: "Поки немає постів",
|
||||
feedOpenLink: "Відкрити посилання",
|
||||
feedContactsBannerTitle: "Є пропозиція?",
|
||||
feedContactsBannerSubtitle: "Написати нам →",
|
||||
// 🔧 НОВОЕ: баннер «По вопросам рекламы» для Партнёров
|
||||
feedPartnersBannerTitle: "Щодо реклами",
|
||||
feedPartnersBannerSubtitle: "Написати нам →",
|
||||
// 🔧 НОВОЕ (Шаг 6): админ-строка ввода
|
||||
feedAdminPlaceholder: "Написати пост...",
|
||||
feedAdminSend: "Надіслати",
|
||||
feedAdminAttach: "Прикріпити фото",
|
||||
feedAdminTranslate: "Перекласти",
|
||||
feedAdminPreview: "Попередній перегляд перекладу",
|
||||
feedAdminPublish: "Опублікувати",
|
||||
feedAdminCancel: "Скасувати",
|
||||
feedAdminEdit: "Редагувати",
|
||||
feedAdminDelete: "Видалити",
|
||||
feedAdminDeleteConfirm: "Видалити цей пост?",
|
||||
feedAdminDeleteYes: "Так, видалити",
|
||||
feedAdminDeleteNo: "Ні",
|
||||
feedAdminPublishing: "Публікую...",
|
||||
feedAdminTranslating: "Перекладаю...",
|
||||
feedAdminUploadingImage: "Завантажую фото...",
|
||||
feedAdminMaxImages: "Максимум 3 фото",
|
||||
feedAdminTooManyImages: "Занадто багато фото (макс. 3)",
|
||||
feedAdminImageTooBig: "Фото занадто велике (макс. 5 МБ)",
|
||||
feedAdminImageBadType: "Дозволені лише: JPEG, PNG, WEBP, GIF",
|
||||
feedAdminTextRequired: "Введіть текст поста",
|
||||
feedAdminPinned: "Закріпити",
|
||||
feedAdminExpiresAt: "Дата закінчення (опціонально)",
|
||||
feedAdminLinkUrl: "Посилання (опціонально)",
|
||||
feedAdminTelegramUrl: "Telegram URL (опціонально)",
|
||||
feedAdminSourceLang: "Мова джерела",
|
||||
feedAdminTranslateAgain: "Перестворити переклад",
|
||||
feedAdminErrorTranslate: "Не вдалося перекласти",
|
||||
feedAdminErrorPublish: "Не вдалося опублікувати",
|
||||
feedAdminErrorDelete: "Не вдалося видалити",
|
||||
feedAdminSuccess: "Готово!",
|
||||
},
|
||||
en: {
|
||||
menu: "Menu",
|
||||
play: "Filwords",
|
||||
games: "Games",
|
||||
pronunciation: "Pronunciation",
|
||||
tapToListen: "Tap the word to hear pronunciation",
|
||||
leaderboard: "Leaderboard",
|
||||
settings: "Settings",
|
||||
back: "Back",
|
||||
level: "Level",
|
||||
score: "Score",
|
||||
hints: "Hints",
|
||||
firstLetter: "First letter",
|
||||
showWords: "Show words",
|
||||
completed: "Completed",
|
||||
search: "Search",
|
||||
loading: "Loading...",
|
||||
noLevels: "No levels",
|
||||
sentenceBuilder: "Build a sentence",
|
||||
comingSoon: "Coming soon",
|
||||
selectLanguage: "Select language",
|
||||
yourScore: "Your score",
|
||||
levelsCompleted: "Levels completed",
|
||||
rank: "Rank",
|
||||
player: "Player",
|
||||
congratulations: "Congratulations!",
|
||||
levelCompleted: "Level completed!",
|
||||
nextLevel: "Next level",
|
||||
foundWords: "Found words",
|
||||
noPlayers: "No players yet",
|
||||
beFirst: "Be the first!",
|
||||
wordNotFound: "Word not found",
|
||||
buildSentence: "Build a sentence from the words:",
|
||||
correctAnswer: "Correct!",
|
||||
tryAgain: "Try again!",
|
||||
tapWords: "Tap words to build a sentence...",
|
||||
reset: "Reset",
|
||||
check: "Check",
|
||||
allSentencesCompleted: "All sentences completed!",
|
||||
remaining: "left",
|
||||
noMoreHints: "Used up",
|
||||
noMoreFirstLetterHints: "No more first letter hints",
|
||||
freeToUse: "Free to use",
|
||||
tapToClose: "Tap to close",
|
||||
alreadyCompleted: "Already completed!",
|
||||
alreadyCompletedDesc: "This level was completed earlier. Points already awarded.",
|
||||
backToMenu: "Back to menu",
|
||||
achievement: "Achievement",
|
||||
wordSearchRulesTitle: "📝 Word Search Game Rules",
|
||||
wordSearchRulesContent: "How to play Word Search:\n\nThe game board contains hidden Polish words.\nWords can be placed up, down, left, or right (no diagonal words).\nTo find a word, swipe your finger across the letters from the first letter to the last.\nFound words will be highlighted in green.\n\nHints:\n\n💡 First Letter: reveals the first letter of a random word (3 uses per level).\n\n📖 Show Words: displays all words in the level along with their translations (unlimited use).\n\nPoints:\n\nCompleting a level awards 100 points minus 10 for each hint used.\nThe fewer hints you use, the more points you'll earn!\n\nGood luck learning Polish! 🇵🇱",
|
||||
sentenceRulesTitle: "📝 Sentence Builder Game Rules",
|
||||
sentenceRulesContent: "How to play Sentence Builder:\n\nYou will see a translation of the sentence in your language.\nThe words will be shuffled into a random order.\nTap the words to build the correct sentence.\nSelected words will appear in the sentence bar in the order you tap them.\nTo remove a word from the sentence bar, tap it again.\n\nChecking your answer:\n\n✓ Tap Check when your sentence is complete.\n\n✓ If your answer is correct, you'll earn points and move on to the next sentence.\n\n✗ If your answer is incorrect, the words will return to their original positions. Try again!\n\nPoints:\n\n• 3-4 words: 50 points\n• 5-6 words: 75 points\n• 7+ words: 100 points\n\nHint:\n\n💡 Show Sentence: 3 uses, shows the correct sentence for 2.5 seconds.\n\nTip: Read the translation carefully and pay attention to Polish word order!",
|
||||
wordQuizRulesTitle: "🎯 Word Match Game Rules",
|
||||
wordQuizRulesContent: "How to play Word Match:\n\nYou will see a word in your language.\nChoose the correct Polish translation from three options.\n\nPoints:\n\nA correct answer without mistakes gives you 50 points.\nEach mistake costs 20 points.\nAfter two mistakes, only the correct option remains, and you get 10 points.\n\nGood luck! 🎯",
|
||||
bubbleWords: "Bubbles",
|
||||
bubbleWordsRulesTitle: "🫧 Bubbles Game Rules",
|
||||
bubbleWordsRulesContent: "How to play Bubbles:\n\nBubbles with word parts appear on the screen.\nTap the bubbles to collect parts and form Polish words.\nWhen you collect all parts of a word, the bubbles disappear!\n\nHints:\n\n💡 First Letter: 3 uses per level.\n📖 Show Words: displays all words with translations.\n\nPoints:\n\nCompleting a level gives you 100 points minus 10 for each hint used.\n\nGood luck! 🫧",
|
||||
pronunciationRulesTitle: "🔊 Pronunciation Rules",
|
||||
pronunciationRulesContent: "How to practice pronunciation:\n\nTap the word card to hear its pronunciation.\nThe word will be played by a native speaker.\n\nLevels:\n\nWords are selected from different levels (A1-C2).\nThe higher the level, the more difficult the words.\n\nTip:\n\nListen carefully and repeat aloud to improve your pronunciation!\n\nGood luck! 🇵🇱",
|
||||
flashcards: "Grammar",
|
||||
texts: "Texts",
|
||||
selectLevel: "Select level:",
|
||||
levelA1Name: "Beginner",
|
||||
levelA2Name: "Elementary",
|
||||
levelB1Name: "Intermediate",
|
||||
levelB2Name: "Upper Intermediate",
|
||||
levelC1Name: "Advanced",
|
||||
levelC2Name: "Proficient",
|
||||
toLevels: "Levels",
|
||||
translate: "Translate",
|
||||
showOriginal: "Show original",
|
||||
next: "Next",
|
||||
wordQuiz: "Word Match",
|
||||
chooseTranslation: "Choose the correct translation:",
|
||||
wordsGuessed: "Words guessed",
|
||||
outOfLives: "Out of lives!",
|
||||
points: "points",
|
||||
mistakes: "Mistakes",
|
||||
sentenceHint: "Hint",
|
||||
sentenceHintUsed: "Hint used",
|
||||
achievementCategoryWordsearch: "🎮 Word Search",
|
||||
achievementCategorySentence: "📝 Sentence Builder",
|
||||
achievementCategoryWordquiz: "🎯 Word Match",
|
||||
achievementCategoryBubblewords: "🫧 Bubbles",
|
||||
achievementCategoryGeneral: "🏆 General",
|
||||
teacher: "Teacher",
|
||||
teacherWelcome: "Your personal Polish teacher!",
|
||||
teacherStart: "Start lesson",
|
||||
teacherAnswer: "Answer",
|
||||
teacherExplain: "Explain",
|
||||
teacherTranslate: "Translate",
|
||||
teacherExample: "Example",
|
||||
teacherAnalytics: "Analytics",
|
||||
teacherCompleted: "Lesson completed!",
|
||||
teacherPlaceholder: "Type your answer...",
|
||||
teacherMenu: "Menu",
|
||||
teacherReset: "Start over",
|
||||
teacherTranslateToPolish: "Translate to Polish",
|
||||
teacherDiagnostic: "Check level",
|
||||
teacherContinue: "Continue",
|
||||
teacherSend: "Send",
|
||||
teacherWrongAnswer: "Your answer",
|
||||
teacherCorrectAnswer: "Correct answer",
|
||||
teacherWhy: "Why",
|
||||
teacherShowAnswer: "Show correct answer",
|
||||
teacherClose: "Close",
|
||||
typingTitle: "Typing",
|
||||
typingInstructions: "Type the sentence in Polish:",
|
||||
typingPlaceholder: "Type in Polish...",
|
||||
typingShowAnswer: "Show answer",
|
||||
typingHideAnswer: "Hide answer",
|
||||
typingCorrect: "Correct! Next sentence...",
|
||||
typingIncorrect: "Try again!",
|
||||
teacherNewLesson: "Next lesson",
|
||||
teacherAIError: "Failed to get a response. Try again.",
|
||||
teacherTestCompleted: "📊 Test completed!",
|
||||
practiceTitle: "Weak Topics Practice",
|
||||
practiceCompleted: "Practice completed!",
|
||||
practiceCorrectAnswers: "correct answers",
|
||||
testTitle: "Final Test",
|
||||
testQuestion: "Question",
|
||||
testCorrectAnswers: "correct answers",
|
||||
testBackToTopic: "Back to topic",
|
||||
testChecking: "Checking...",
|
||||
testFinish: "Finish test",
|
||||
testExamplesButton: "Give examples",
|
||||
testPracticeButton: "Practice weak topics",
|
||||
testExamplesTitle: "Examples",
|
||||
testNoExamples: "Failed to load examples",
|
||||
testPracticeNoWeak: "No weak topics, great job!",
|
||||
topicLesson: "Lesson",
|
||||
topicFinalTest: "Final Test",
|
||||
hintButton: "Hint",
|
||||
hintUsed: "Hint used",
|
||||
correctOption: "Correct answer",
|
||||
whyLabel: "Why",
|
||||
nextSentence: "Next sentence",
|
||||
nextQuestion: "Next",
|
||||
teacherMenuStart: "Start lesson",
|
||||
teacherMenuAlphabet: "Alphabet",
|
||||
teacherMenuRules: "Rules",
|
||||
teacherMenuCheatsheets: "Cheatsheets",
|
||||
teacherMenuTranslate: "Translate to Polish",
|
||||
teacherMenuExample: "Example",
|
||||
teacherMenuAnalytics: "Analytics",
|
||||
teacherMenuReset: "Start over",
|
||||
teacherMenuProgress: "Progress",
|
||||
teacherMenuTopicsCompleted: "Topics completed",
|
||||
teacherMenuOf: "of",
|
||||
teacherMenuPhrase: "Phrase of the day",
|
||||
teacherMenuTranslation: "Translation",
|
||||
teacherMenuGrammar: "Grammar",
|
||||
teacherMenuFalseFriends: "False friends",
|
||||
teacherMenuSeems: "Seems like",
|
||||
teacherMenuActually: "Actually",
|
||||
teacherMenuMain: "Main",
|
||||
teacherMenuBonus: "Bonus",
|
||||
teacherMenuObvious: "Obvious",
|
||||
teacherMenuAdditional: "Additional",
|
||||
// ============ Onboarding ============
|
||||
onboardingStep1Title: "Hey! I'm Marek.",
|
||||
onboardingStep1Text: "I'm your personal Polish teacher.\nI'll guide you step by step, from your very first word to a real conversation.",
|
||||
onboardingStep2Title: "The Teacher section",
|
||||
onboardingStep2Text: "This is where the real learning happens.\nLessons, quizzes, grammar, phrase of the day, all in one place.\n\nAnswer the questions, and I'll check your answer and explain any mistakes.",
|
||||
onboardingStep3Title: "Games",
|
||||
onboardingStep3Text: "Not a fan of sitting over textbooks? Then play!\n\nFour games: Filwords (word search), Ułóż zdanie (build sentences from blocks), Wybierz słowo (translation), Bąbelki (collect word parts).\n\nWords stick without you even trying.",
|
||||
onboardingStep4Title: "Writing and pronunciation",
|
||||
onboardingStep4Text: "Writing: copy out Polish sentences to nail down spelling.\n\nPronunciation: listen to how Polish words sound and repeat out loud.\n\nThese two sections turn you into a real language user.",
|
||||
onboardingStep5Title: "Let's go!",
|
||||
onboardingStep5Text: "Your level: A1. You can change it in the menu, tap the level button in the top right corner.\n\nStart with the Teacher section, your first lesson is waiting there.\n\nGood luck!",
|
||||
onboardingNext: "Next",
|
||||
onboardingStart: "Let's go!",
|
||||
onboardingRestart: "Watch again",
|
||||
onboardingSkip: "Skip",
|
||||
// ============ Carousel ============
|
||||
carouselPartners: "Partners",
|
||||
carouselPartnersDesc: "Trusted companies",
|
||||
carouselContacts: "Useful contacts",
|
||||
carouselContactsDesc: "Rentals, documents, lawyers",
|
||||
carouselApps: "Useful apps",
|
||||
carouselAppsDesc: "Apps for learning",
|
||||
carouselNews: "News",
|
||||
carouselNewsDesc: "What's new in Filwords",
|
||||
partnersModalTitle: "Partners",
|
||||
partnersModalEmpty: "Our partners will appear here soon",
|
||||
contactsModalTitle: "Useful contacts",
|
||||
contactsModalEmpty: "We'll add useful contacts soon",
|
||||
appsModalTitle: "Useful apps",
|
||||
appsModalEmpty: "We'll add a list of apps soon",
|
||||
newsModalTitle: "News and announcements",
|
||||
newsModalEmpty: "News will appear here soon",
|
||||
modalGotIt: "Got it",
|
||||
// ============ Feed ============
|
||||
feedEmpty: "No posts yet",
|
||||
feedOpenLink: "Open link",
|
||||
feedContactsBannerTitle: "Have a suggestion?",
|
||||
feedContactsBannerSubtitle: "Write to us →",
|
||||
// 🔧 НОВОЕ: баннер «По вопросам рекламы» для Партнёров
|
||||
feedPartnersBannerTitle: "Advertising inquiries",
|
||||
feedPartnersBannerSubtitle: "Write to us →",
|
||||
// 🔧 НОВОЕ (Шаг 6): админ-строка ввода
|
||||
feedAdminPlaceholder: "Write a post...",
|
||||
feedAdminSend: "Send",
|
||||
feedAdminAttach: "Attach image",
|
||||
feedAdminTranslate: "Translate",
|
||||
feedAdminPreview: "Translation preview",
|
||||
feedAdminPublish: "Publish",
|
||||
feedAdminCancel: "Cancel",
|
||||
feedAdminEdit: "Edit",
|
||||
feedAdminDelete: "Delete",
|
||||
feedAdminDeleteConfirm: "Delete this post?",
|
||||
feedAdminDeleteYes: "Yes, delete",
|
||||
feedAdminDeleteNo: "No",
|
||||
feedAdminPublishing: "Publishing...",
|
||||
feedAdminTranslating: "Translating...",
|
||||
feedAdminUploadingImage: "Uploading image...",
|
||||
feedAdminMaxImages: "Maximum 3 images",
|
||||
feedAdminTooManyImages: "Too many images (max 3)",
|
||||
feedAdminImageTooBig: "Image is too large (max 5 MB)",
|
||||
feedAdminImageBadType: "Allowed only: JPEG, PNG, WEBP, GIF",
|
||||
feedAdminTextRequired: "Enter post text",
|
||||
feedAdminPinned: "Pin",
|
||||
feedAdminExpiresAt: "Expires at (optional)",
|
||||
feedAdminLinkUrl: "Link (optional)",
|
||||
feedAdminTelegramUrl: "Telegram URL (optional)",
|
||||
feedAdminSourceLang: "Source language",
|
||||
feedAdminTranslateAgain: "Re-translate",
|
||||
feedAdminErrorTranslate: "Failed to translate",
|
||||
feedAdminErrorPublish: "Failed to publish",
|
||||
feedAdminErrorDelete: "Failed to delete",
|
||||
feedAdminSuccess: "Done!",
|
||||
},
|
||||
};
|
||||
|
||||
export const getTranslation = (lang: string, key: string): string => {
|
||||
return translations[lang]?.[key] || translations["pl"]?.[key] || key;
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,816 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLanguage } from '../i18n/LanguageContext';
|
||||
import { fonts, radius } from '../styles/theme';
|
||||
import { getBubbleLevel, completeBubbleLevel, getBubbleProgress, fetchProgress } from '../api';
|
||||
import RulesModal from '../components/RulesModal';
|
||||
import { useAchievements } from '../hooks/useAchievements';
|
||||
|
||||
interface BubbleWord {
|
||||
word_pl: string;
|
||||
translation: string;
|
||||
parts: string[];
|
||||
}
|
||||
|
||||
interface Bubble {
|
||||
text: string;
|
||||
word_pl: string;
|
||||
}
|
||||
|
||||
const BubbleWordsPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useLanguage();
|
||||
const [words, setWords] = useState<BubbleWord[]>([]);
|
||||
const [bubbles, setBubbles] = useState<Bubble[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [totalScore, setTotalScore] = useState(0);
|
||||
const [hintsUsed, setHintsUsed] = useState(0);
|
||||
const [firstLetterHintsUsed, setFirstLetterHintsUsed] = useState(0);
|
||||
const [showWordModal, setShowWordModal] = useState(false);
|
||||
const [selectedBubbles, setSelectedBubbles] = useState<Bubble[]>([]);
|
||||
const [foundWords, setFoundWords] = useState<string[]>([]);
|
||||
const [completed, setCompleted] = useState(false);
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
const [showRules, setShowRules] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [levelsCompleted, setLevelsCompleted] = useState(0);
|
||||
const [bubbleLevel, setBubbleLevel] = useState(1);
|
||||
const [currentLevel, setCurrentLevel] = useState('A1');
|
||||
const [animationsReady, setAnimationsReady] = useState(false);
|
||||
const [hintHighlightedBubble, setHintHighlightedBubble] = useState<string | null>(null);
|
||||
const { activeAchievement, showAchievement, resetAchievements } = useAchievements();
|
||||
const isMountedRef = useRef(true);
|
||||
const messageTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const hintTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const startTimeRef = useRef<number>(0);
|
||||
const rulesShownRef = useRef(false);
|
||||
|
||||
const maxFirstLetterHints = 3;
|
||||
|
||||
const brandColor = '#2ECC71';
|
||||
const palette = { bubble: '#4FA9E8' };
|
||||
const textColor = '#1A1A2E';
|
||||
const subColor = '#6B7280';
|
||||
const bgColor = '#F4F5F7';
|
||||
|
||||
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
||||
background: 'rgba(255,255,255,0.55)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 8px 32px rgba(31,38,73,0.10), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
// Автопоказ правил при первом входе
|
||||
if (!rulesShownRef.current) {
|
||||
rulesShownRef.current = true;
|
||||
const rulesSeen = localStorage.getItem('filwords_rules_bubble');
|
||||
if (!rulesSeen) {
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
setShowRules(true);
|
||||
localStorage.setItem('filwords_rules_bubble', '1');
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
if (messageTimeoutRef.current) clearTimeout(messageTimeoutRef.current);
|
||||
if (hintTimeoutRef.current) clearTimeout(hintTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchLevel = useCallback(() => {
|
||||
setLoading(true);
|
||||
setSelectedBubbles([]);
|
||||
setFoundWords([]);
|
||||
setCompleted(false);
|
||||
setIsTransitioning(false);
|
||||
setHintsUsed(0);
|
||||
setFirstLetterHintsUsed(0);
|
||||
setShowWordModal(false);
|
||||
setAnimationsReady(false);
|
||||
setHintHighlightedBubble(null);
|
||||
resetAchievements();
|
||||
startTimeRef.current = Date.now();
|
||||
|
||||
getBubbleLevel()
|
||||
.then((data: any) => {
|
||||
if (isMountedRef.current) {
|
||||
setWords(data.words || []);
|
||||
setBubbles(data.bubbles || []);
|
||||
setBubbleLevel(data.level || 1);
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current) setAnimationsReady(true);
|
||||
}, 50);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (isMountedRef.current) navigate('/menu');
|
||||
})
|
||||
.finally(() => {
|
||||
if (isMountedRef.current) setLoading(false);
|
||||
});
|
||||
|
||||
fetchProgress()
|
||||
.then((data: any) => {
|
||||
if (isMountedRef.current) {
|
||||
setTotalScore(data.total_score || 0);
|
||||
if (data.current_level) {
|
||||
setCurrentLevel(data.current_level);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
getBubbleProgress()
|
||||
.then((data: any) => {
|
||||
if (isMountedRef.current) setLevelsCompleted(data.levels_completed || 0);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [navigate, resetAchievements]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLevel();
|
||||
}, [fetchLevel]);
|
||||
|
||||
const showMessage = (msg: string) => {
|
||||
if (!isMountedRef.current) return;
|
||||
setMessage(msg);
|
||||
if (messageTimeoutRef.current) clearTimeout(messageTimeoutRef.current);
|
||||
messageTimeoutRef.current = setTimeout(() => {
|
||||
if (isMountedRef.current) setMessage(null);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const handleBubbleClick = useCallback((bubble: Bubble) => {
|
||||
if (completed || isTransitioning) return;
|
||||
if (foundWords.includes(bubble.word_pl)) return;
|
||||
|
||||
setSelectedBubbles(prev => {
|
||||
const alreadySelected = prev.find(b => b.text === bubble.text && b.word_pl === bubble.word_pl);
|
||||
if (alreadySelected) {
|
||||
return prev.filter(b => !(b.text === bubble.text && b.word_pl === bubble.word_pl));
|
||||
}
|
||||
return [...prev, bubble];
|
||||
});
|
||||
|
||||
if (window.Telegram?.WebApp?.HapticFeedback) {
|
||||
window.Telegram.WebApp.HapticFeedback.impactOccurred('light');
|
||||
}
|
||||
}, [completed, isTransitioning, foundWords]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedBubbles.length === 0) return;
|
||||
|
||||
const grouped: Record<string, string> = {};
|
||||
selectedBubbles.forEach(b => {
|
||||
if (!grouped[b.word_pl]) grouped[b.word_pl] = '';
|
||||
grouped[b.word_pl] += b.text;
|
||||
});
|
||||
|
||||
for (const word of words) {
|
||||
const assembled = grouped[word.word_pl] || '';
|
||||
if (assembled === word.word_pl && !foundWords.includes(word.word_pl)) {
|
||||
const newFoundWords = [...foundWords, word.word_pl];
|
||||
setFoundWords(newFoundWords);
|
||||
setSelectedBubbles(prev => prev.filter(b => b.word_pl !== word.word_pl));
|
||||
|
||||
showMessage(`✅ ${word.word_pl}`);
|
||||
|
||||
if (window.Telegram?.WebApp?.HapticFeedback) {
|
||||
window.Telegram.WebApp.HapticFeedback.impactOccurred('medium');
|
||||
}
|
||||
|
||||
if (newFoundWords.length === words.length && !isTransitioning) {
|
||||
setIsTransitioning(true);
|
||||
setCompleted(true);
|
||||
|
||||
const completionTime = Math.floor((Date.now() - startTimeRef.current) / 1000);
|
||||
|
||||
if (window.Telegram?.WebApp?.HapticFeedback) {
|
||||
window.Telegram.WebApp.HapticFeedback.impactOccurred('heavy');
|
||||
}
|
||||
|
||||
completeBubbleLevel(hintsUsed, words.length, completionTime)
|
||||
.then((data: any) => {
|
||||
if (isMountedRef.current) {
|
||||
setTotalScore(prev => prev + (data.score || 0));
|
||||
setLevelsCompleted(prev => prev + 1);
|
||||
fetchProgress().catch(() => {});
|
||||
|
||||
if (data.new_achievements && data.new_achievements.length > 0) {
|
||||
data.new_achievements.forEach((ach: any) => {
|
||||
showAchievement({
|
||||
id: ach.code,
|
||||
icon: ach.icon,
|
||||
name: ach.name,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current) fetchLevel();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [selectedBubbles, words, foundWords, isTransitioning, hintsUsed, fetchLevel, showAchievement]);
|
||||
|
||||
const handleFirstLetterHint = () => {
|
||||
if (firstLetterHintsUsed >= maxFirstLetterHints) {
|
||||
setErrorMessage('⚠️ ' + (t('noMoreFirstLetterHints') || 'No more hints'));
|
||||
return;
|
||||
}
|
||||
|
||||
const notFoundWords = words.filter(w => !foundWords.includes(w.word_pl));
|
||||
if (notFoundWords.length === 0) return;
|
||||
|
||||
const randomWord = notFoundWords[Math.floor(Math.random() * notFoundWords.length)];
|
||||
const firstPart = randomWord.parts[0];
|
||||
|
||||
const hintKey = `${firstPart}-${randomWord.word_pl}`;
|
||||
setHintHighlightedBubble(hintKey);
|
||||
|
||||
if (hintTimeoutRef.current) clearTimeout(hintTimeoutRef.current);
|
||||
hintTimeoutRef.current = setTimeout(() => {
|
||||
if (isMountedRef.current) setHintHighlightedBubble(null);
|
||||
}, 2000);
|
||||
|
||||
setFirstLetterHintsUsed(prev => prev + 1);
|
||||
setHintsUsed(prev => prev + 1);
|
||||
setErrorMessage(null);
|
||||
};
|
||||
|
||||
const handleShowWords = () => {
|
||||
if (!showWordModal) {
|
||||
setHintsUsed(prev => prev + 1);
|
||||
}
|
||||
setShowWordModal(prev => !prev);
|
||||
};
|
||||
|
||||
const isBubbleSelected = (bubble: Bubble): boolean => {
|
||||
return selectedBubbles.some(b => b.text === bubble.text && b.word_pl === bubble.word_pl);
|
||||
};
|
||||
|
||||
const isBubbleFound = (bubble: Bubble): boolean => {
|
||||
return foundWords.includes(bubble.word_pl);
|
||||
};
|
||||
|
||||
const isHintHighlighted = (bubble: Bubble): boolean => {
|
||||
const key = `${bubble.text}-${bubble.word_pl}`;
|
||||
return hintHighlightedBubble === key;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: bgColor,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: subColor,
|
||||
fontFamily: fonts.main,
|
||||
fontSize: '18px',
|
||||
}}>
|
||||
{t('loading')}...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: bgColor,
|
||||
color: textColor,
|
||||
fontFamily: fonts.main,
|
||||
padding: '16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ position: 'fixed', top: '-80px', right: '-60px', width: '260px', height: '260px', borderRadius: '50%', background: palette.bubble, opacity: 0.22, filter: 'blur(90px)', pointerEvents: 'none', zIndex: 0 }} />
|
||||
<div style={{ position: 'fixed', bottom: '-50px', left: '-40px', width: '220px', height: '220px', borderRadius: '50%', background: brandColor, opacity: 0.16, filter: 'blur(80px)', pointerEvents: 'none', zIndex: 0 }} />
|
||||
|
||||
<style>{`
|
||||
@keyframes bubbleDrop {
|
||||
0% { transform: translateY(-70vh) scale(0.3); opacity: 0; }
|
||||
50% { opacity: 1; }
|
||||
70% { transform: translateY(6px) scale(1.02); }
|
||||
85% { transform: translateY(-3px) scale(0.98); }
|
||||
100% { transform: translateY(0) scale(1); opacity: 1; }
|
||||
}
|
||||
@keyframes bubbleWobble {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-6px); }
|
||||
}
|
||||
@keyframes hintPulse {
|
||||
0%, 100% { box-shadow: 0 0 10px rgba(255, 215, 0, 0.6); }
|
||||
50% { box-shadow: 0 0 25px rgba(255, 215, 0, 1); }
|
||||
}
|
||||
@keyframes fadeInUp {
|
||||
0% { transform: translateY(10px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes messagePopIn {
|
||||
0% { opacity: 0; transform: translateX(-50%) translateY(-10px); }
|
||||
100% { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
@keyframes achievementSlideDown {
|
||||
0% { transform: translateX(-50%) translateY(-80px); opacity: 0; }
|
||||
100% { transform: translateX(-50%) translateY(0); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div style={{ position: 'relative', zIndex: 1, display: 'flex', flexDirection: 'column', flex: 1 }}>
|
||||
|
||||
<RulesModal
|
||||
isOpen={showRules}
|
||||
onClose={() => setShowRules(false)}
|
||||
title={t('bubbleWordsRulesTitle') || '🫧 Bubble Words Rules'}
|
||||
content={t('bubbleWordsRulesContent') || 'Rules content loading...'}
|
||||
/>
|
||||
|
||||
{activeAchievement && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: '16px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 900,
|
||||
background: 'linear-gradient(135deg, #FFD700, #FFA500)',
|
||||
color: '#FFFFFF',
|
||||
padding: '14px 20px',
|
||||
borderRadius: radius.lg,
|
||||
boxShadow: '0 8px 30px rgba(255, 215, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
minWidth: '250px',
|
||||
maxWidth: '90vw',
|
||||
animation: 'achievementSlideDown 0.4s ease',
|
||||
}}>
|
||||
<span style={{ fontSize: '28px', flexShrink: 0 }}>{activeAchievement.icon}</span>
|
||||
<div>
|
||||
<div style={{ fontSize: '11px', fontWeight: 600, opacity: 0.9, letterSpacing: '1px', textTransform: 'uppercase' }}>
|
||||
🏆 {t('achievement') || 'Achievement'}
|
||||
</div>
|
||||
<div style={{ fontSize: '15px', fontWeight: 800, marginTop: '2px' }}>
|
||||
{activeAchievement.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px',
|
||||
animation: 'fadeInUp 0.3s ease',
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<button
|
||||
onClick={() => navigate('/menu')}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '10px 18px',
|
||||
borderRadius: radius.md,
|
||||
}),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.95)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
← {t('back')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowRules(true)}
|
||||
style={{
|
||||
...glass({
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: radius.md,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}),
|
||||
color: textColor,
|
||||
fontSize: '18px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s ease',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.95)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '14px', color: subColor }}>
|
||||
{t('level')} {bubbleLevel}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'right', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 800,
|
||||
color: brandColor,
|
||||
background: `${brandColor}15`,
|
||||
padding: '4px 8px',
|
||||
borderRadius: radius.sm,
|
||||
border: `1px solid ${brandColor}40`,
|
||||
}}>
|
||||
{currentLevel}
|
||||
</span>
|
||||
<div>
|
||||
<div style={{ fontSize: '11px', color: subColor, marginBottom: '2px' }}>
|
||||
{t('score')}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: 800,
|
||||
color: brandColor,
|
||||
}}>
|
||||
{totalScore}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '60px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
padding: '10px 20px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: '#FFFFFF',
|
||||
background: '#F39C12',
|
||||
borderRadius: radius.md,
|
||||
boxShadow: '0 4px 12px rgba(243,156,18,0.4)',
|
||||
whiteSpace: 'nowrap',
|
||||
zIndex: 10,
|
||||
animation: 'messagePopIn 0.25s ease',
|
||||
}}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '60px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
padding: '10px 20px',
|
||||
fontSize: '16px',
|
||||
fontWeight: 700,
|
||||
color: '#FFFFFF',
|
||||
background: brandColor,
|
||||
borderRadius: radius.md,
|
||||
boxShadow: `0 4px 12px ${brandColor}40`,
|
||||
whiteSpace: 'nowrap',
|
||||
zIndex: 10,
|
||||
animation: 'messagePopIn 0.25s ease',
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '6px',
|
||||
marginBottom: '12px',
|
||||
minHeight: '24px',
|
||||
animation: 'fadeInUp 0.3s ease',
|
||||
}}>
|
||||
{foundWords.map(w => (
|
||||
<span key={w} style={{
|
||||
padding: '4px 10px',
|
||||
borderRadius: radius.sm,
|
||||
background: `${brandColor}20`,
|
||||
color: '#1a7a3a',
|
||||
fontSize: '12px',
|
||||
fontWeight: 700,
|
||||
border: `1px solid ${brandColor}40`,
|
||||
}}>
|
||||
✅ {w}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '10px',
|
||||
justifyContent: 'center',
|
||||
alignContent: 'flex-start',
|
||||
padding: '10px 0',
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{bubbles.map((bubble, index) => {
|
||||
const selected = isBubbleSelected(bubble);
|
||||
const found = isBubbleFound(bubble);
|
||||
const hinted = isHintHighlighted(bubble);
|
||||
|
||||
if (found) return null;
|
||||
|
||||
const totalBubbles = bubbles.length;
|
||||
const isSmallLevel = totalBubbles <= 12;
|
||||
const bubbleSize = isSmallLevel
|
||||
? (bubble.text.length <= 2 ? 70 : bubble.text.length <= 3 ? 80 : 90)
|
||||
: (bubble.text.length <= 2 ? 55 : bubble.text.length <= 3 ? 65 : 75);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${bubble.text}-${bubble.word_pl}-${index}`}
|
||||
onClick={() => handleBubbleClick(bubble)}
|
||||
style={{
|
||||
width: bubbleSize,
|
||||
height: bubbleSize,
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: bubbleSize > 70 ? '17px' : bubbleSize > 60 ? '15px' : '14px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
transition: 'all 0.2s ease',
|
||||
background: hinted
|
||||
? 'radial-gradient(circle at 35% 35%, #FFD700, #FFA500)'
|
||||
: selected
|
||||
? `radial-gradient(circle at 35% 35%, ${palette.bubble}CC, ${palette.bubble})`
|
||||
: 'rgba(255,255,255,0.55)',
|
||||
border: hinted
|
||||
? '3px solid #FFD700'
|
||||
: selected
|
||||
? `2px solid ${palette.bubble}`
|
||||
: '1px solid rgba(0,0,0,0.06)',
|
||||
color: hinted ? '#FFFFFF' : selected ? '#FFFFFF' : textColor,
|
||||
boxShadow: hinted
|
||||
? '0 0 20px rgba(255, 215, 0, 0.8)'
|
||||
: selected
|
||||
? `0 0 15px ${palette.bubble}60`
|
||||
: '0 4px 12px rgba(31,38,73,0.06)',
|
||||
animation: hinted
|
||||
? `bubbleDrop 0.7s cubic-bezier(0.34, 1.56, 0.64, 1) ${index * 0.1}s both, hintPulse 0.8s ease-in-out infinite`
|
||||
: animationsReady
|
||||
? `bubbleDrop 0.7s cubic-bezier(0.34, 1.56, 0.64, 1) ${index * 0.1}s both, bubbleWobble ${2.5 + index * 0.4}s ease-in-out infinite ${0.7 + index * 0.1}s`
|
||||
: 'none',
|
||||
opacity: animationsReady ? 1 : 0,
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.9)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
{bubble.text}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectedBubbles.length > 0 && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '6px',
|
||||
justifyContent: 'center',
|
||||
padding: '8px',
|
||||
marginBottom: '8px',
|
||||
background: 'rgba(0,0,0,0.02)',
|
||||
borderRadius: radius.md,
|
||||
border: '1px dashed rgba(0,0,0,0.1)',
|
||||
animation: 'fadeInUp 0.2s ease',
|
||||
}}>
|
||||
{selectedBubbles.map((b, i) => (
|
||||
<span key={i} style={{
|
||||
padding: '6px 10px',
|
||||
borderRadius: radius.sm,
|
||||
background: `${palette.bubble}20`,
|
||||
color: palette.bubble,
|
||||
fontSize: '14px',
|
||||
fontWeight: 700,
|
||||
border: `1px solid ${palette.bubble}40`,
|
||||
}}>
|
||||
{b.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
justifyContent: 'center',
|
||||
padding: '8px 0',
|
||||
animation: 'fadeInUp 0.3s ease 0.2s both',
|
||||
}}>
|
||||
<button
|
||||
onClick={handleFirstLetterHint}
|
||||
disabled={firstLetterHintsUsed >= maxFirstLetterHints}
|
||||
style={{
|
||||
...glass({
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
borderRadius: radius.md,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}),
|
||||
opacity: firstLetterHintsUsed >= maxFirstLetterHints ? 0.5 : 1,
|
||||
cursor: firstLetterHintsUsed >= maxFirstLetterHints ? 'default' : 'pointer',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
color: textColor,
|
||||
}}
|
||||
onTouchStart={(e) => { if (firstLetterHintsUsed < maxFirstLetterHints) e.currentTarget.style.transform = 'scale(0.95)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
<span>💡 {t('firstLetter')}</span>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
{Array.from({ length: maxFirstLetterHints }, (_, i) => (
|
||||
<div key={i} style={{
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
borderRadius: '50%',
|
||||
background: i < maxFirstLetterHints - firstLetterHintsUsed ? brandColor : 'rgba(0,0,0,0.08)',
|
||||
border: i < maxFirstLetterHints - firstLetterHintsUsed ? `1px solid ${brandColor}` : '1px solid rgba(0,0,0,0.06)',
|
||||
boxShadow: i < maxFirstLetterHints - firstLetterHintsUsed ? `0 0 6px ${brandColor}80` : 'inset 0 1px 2px rgba(0,0,0,0.06)',
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleShowWords}
|
||||
style={{
|
||||
...glass({
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
borderRadius: radius.md,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}),
|
||||
border: showWordModal ? `2px solid ${brandColor}` : '1px solid rgba(255,255,255,0.7)',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
color: textColor,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.95)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
<span>📖 {t('showWords')} {showWordModal ? '▲' : '▼'}</span>
|
||||
<span style={{ fontSize: '18px', color: brandColor, lineHeight: '1' }}>∞</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showWordModal && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(20,22,35,0.25)',
|
||||
zIndex: 500,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '20px',
|
||||
backdropFilter: 'blur(4px)',
|
||||
WebkitBackdropFilter: 'blur(4px)',
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setShowWordModal(false); }}
|
||||
>
|
||||
<div style={{
|
||||
...glass({
|
||||
borderRadius: radius.lg,
|
||||
padding: '20px',
|
||||
width: '100%',
|
||||
maxWidth: '400px',
|
||||
maxHeight: '70vh',
|
||||
overflow: 'auto',
|
||||
}),
|
||||
boxShadow: '0 20px 60px rgba(31,38,73,0.20), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px',
|
||||
paddingBottom: '12px',
|
||||
borderBottom: '1px solid rgba(0,0,0,0.06)',
|
||||
}}>
|
||||
<h3 style={{ margin: 0, fontSize: '18px', fontWeight: 700, color: textColor }}>📖 {t('showWords')}</h3>
|
||||
<button
|
||||
onClick={() => setShowWordModal(false)}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '8px 16px',
|
||||
borderRadius: radius.md,
|
||||
}),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', justifyContent: 'center' }}>
|
||||
{words.map(w => (
|
||||
<div key={w.word_pl} style={{
|
||||
padding: '10px 14px',
|
||||
borderRadius: radius.md,
|
||||
background: foundWords.includes(w.word_pl) ? `${brandColor}20` : 'rgba(255,255,255,0.55)',
|
||||
border: `2px solid ${foundWords.includes(w.word_pl) ? brandColor : 'rgba(0,0,0,0.06)'}`,
|
||||
color: foundWords.includes(w.word_pl) ? '#1a7a3a' : textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: foundWords.includes(w.word_pl) ? 700 : 400,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
minWidth: '80px',
|
||||
}}>
|
||||
<span style={{ fontSize: '16px' }}>{w.word_pl}</span>
|
||||
{w.translation && (
|
||||
<span style={{ fontSize: '11px', color: foundWords.includes(w.word_pl) ? brandColor : subColor }}>
|
||||
{w.translation}
|
||||
</span>
|
||||
)}
|
||||
{foundWords.includes(w.word_pl) && <span style={{ fontSize: '12px' }}>✅</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{
|
||||
marginTop: '16px',
|
||||
paddingTop: '12px',
|
||||
borderTop: '1px solid rgba(0,0,0,0.06)',
|
||||
textAlign: 'center',
|
||||
fontSize: '14px',
|
||||
color: subColor,
|
||||
}}>
|
||||
{foundWords.length}/{words.length} {t('foundWords').toLowerCase()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{completed && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '16px',
|
||||
marginTop: '8px',
|
||||
background: `${brandColor}15`,
|
||||
borderRadius: radius.lg,
|
||||
border: `2px solid ${brandColor}40`,
|
||||
}}>
|
||||
<div style={{ fontSize: '28px', marginBottom: '4px' }}>🎉</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: 700, color: brandColor }}>{t('levelCompleted')}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: '8px', color: subColor, fontSize: '12px' }}>
|
||||
{t('levelsCompleted')}: {levelsCompleted}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BubbleWordsPage;
|
||||
@@ -0,0 +1,711 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLanguage } from '../i18n/LanguageContext';
|
||||
import { fonts, radius } from '../styles/theme';
|
||||
import { polishAlphabet } from '../data/alphabet';
|
||||
import { polishDiacritics } from '../data/diacritics';
|
||||
import { grammarRules } from '../data/grammarRules';
|
||||
import { cheatsheets } from '../data/cheatsheets';
|
||||
// 🔧 ИСПРАВЛЕНО: Убран trackFlashcardsOpened
|
||||
import { trackCheatsheetsOpened, trackAlphabetOpened, trackRulesOpened, getTTS } from '../api';
|
||||
import { useAchievements } from '../hooks/useAchievements';
|
||||
|
||||
type TabType = 'alphabet' | 'grammar' | 'cheatsheets';
|
||||
|
||||
const tabColors: Record<TabType, string> = {
|
||||
alphabet: '#2ECC71',
|
||||
grammar: '#E74C3C',
|
||||
cheatsheets: '#A970D6',
|
||||
};
|
||||
|
||||
const FlashcardsPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { t, language } = useLanguage();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('alphabet');
|
||||
const [selectedRule, setSelectedRule] = useState<number | null>(null);
|
||||
const [selectedCheatSheet, setSelectedCheatSheet] = useState<string | null>(null);
|
||||
const [selectedLetter, setSelectedLetter] = useState<number | null>(null);
|
||||
const [isPlayingLetter, setIsPlayingLetter] = useState(false);
|
||||
|
||||
const { activeAchievement, showAchievement } = useAchievements();
|
||||
const isMountedRef = useRef(true);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
const brandColor = '#2ECC71';
|
||||
const textColor = '#1A1A2E';
|
||||
const subColor = '#6B7280';
|
||||
const bgColor = '#F4F5F7';
|
||||
const cardBg = 'rgba(255,255,255,0.55)';
|
||||
|
||||
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
||||
background: cardBg,
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 8px 32px rgba(31,38,73,0.10), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
const tabs: { key: TabType; icon: string; label_pl: string; label_ru: string; label_uk: string; label_en: string }[] = [
|
||||
{ key: 'alphabet', icon: '🔤', label_pl: 'Alfabet', label_ru: 'Алфавит', label_uk: 'Алфавіт', label_en: 'Alphabet' },
|
||||
{ key: 'grammar', icon: '📐', label_pl: 'Zasady', label_ru: 'Правила', label_uk: 'Правила', label_en: 'Grammar' },
|
||||
{ key: 'cheatsheets', icon: '📝', label_pl: 'Ściągi', label_ru: 'Шпаргалки', label_uk: 'Шпаргалки', label_en: 'Cheatsheets' },
|
||||
];
|
||||
|
||||
const getTabLabel = (tab: typeof tabs[0]): string => {
|
||||
const map: Record<string, string> = { pl: tab.label_pl, ru: tab.label_ru, uk: tab.label_uk, en: tab.label_en };
|
||||
return map[language] || tab.label_en;
|
||||
};
|
||||
|
||||
const getLocalized = (item: any, field: string): string => {
|
||||
const key = `${field}_${language}`;
|
||||
return item[key] || item[`${field}_en`] || '';
|
||||
};
|
||||
|
||||
const getLocalizedExample = (item: any): string => {
|
||||
const key = `example_${language}`;
|
||||
return item[key] || item.example_en || '';
|
||||
};
|
||||
|
||||
const getDiacriticInfo = (letter: string): { pronunciation: string; description: string } | null => {
|
||||
const found = polishDiacritics.find(d => d.letter.includes(letter));
|
||||
if (!found) return null;
|
||||
return {
|
||||
pronunciation: found.pronunciation,
|
||||
description: getLocalized(found, 'description'),
|
||||
};
|
||||
};
|
||||
|
||||
const playLetterSound = async (word: string) => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
}
|
||||
|
||||
if ('speechSynthesis' in window) {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
|
||||
setIsPlayingLetter(true);
|
||||
|
||||
try {
|
||||
const audioBlob = await getTTS(word, 'male');
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
|
||||
const audio = new Audio(audioUrl);
|
||||
audioRef.current = audio;
|
||||
|
||||
audio.onended = () => {
|
||||
if (isMountedRef.current) setIsPlayingLetter(false);
|
||||
};
|
||||
|
||||
audio.onerror = () => {
|
||||
playWebSpeech(word);
|
||||
};
|
||||
|
||||
await audio.play();
|
||||
|
||||
} catch (e) {
|
||||
console.error('Edge TTS error:', e);
|
||||
playWebSpeech(word);
|
||||
}
|
||||
};
|
||||
|
||||
const playWebSpeech = (word: string) => {
|
||||
if ('speechSynthesis' in window) {
|
||||
const utterance = new SpeechSynthesisUtterance(word);
|
||||
utterance.lang = 'pl-PL';
|
||||
utterance.onend = () => {
|
||||
if (isMountedRef.current) setIsPlayingLetter(false);
|
||||
};
|
||||
utterance.onerror = () => {
|
||||
if (isMountedRef.current) setIsPlayingLetter(false);
|
||||
};
|
||||
window.speechSynthesis.speak(utterance);
|
||||
} else {
|
||||
if (isMountedRef.current) setIsPlayingLetter(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
|
||||
isMountedRef.current = true;
|
||||
|
||||
// 🔧 ИСПРАВЛЕНО: Трекинг только для Алфавита (активная вкладка по умолчанию)
|
||||
trackAlphabetOpened()
|
||||
.then((data: any) => {
|
||||
if (data?.new_achievements && data.new_achievements.length > 0) {
|
||||
data.new_achievements.forEach((ach: any) => {
|
||||
showAchievement({
|
||||
id: ach.code,
|
||||
icon: ach.icon,
|
||||
name: ach.name,
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
}
|
||||
if ('speechSynthesis' in window) {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
};
|
||||
}, [showAchievement]);
|
||||
|
||||
// 🔧 ИСПРАВЛЕНО: switchTab теперь сбрасывает window scroll
|
||||
const switchTab = (tab: TabType) => {
|
||||
setActiveTab(tab);
|
||||
setSelectedRule(null);
|
||||
setSelectedCheatSheet(null);
|
||||
setSelectedLetter(null);
|
||||
window.scrollTo(0, 0);
|
||||
|
||||
if (tab === 'cheatsheets') {
|
||||
trackCheatsheetsOpened()
|
||||
.then((data: any) => {
|
||||
if (data?.new_achievements && data.new_achievements.length > 0) {
|
||||
data.new_achievements.forEach((ach: any) => {
|
||||
showAchievement({
|
||||
id: ach.code,
|
||||
icon: ach.icon,
|
||||
name: ach.name,
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
if (tab === 'grammar') {
|
||||
trackRulesOpened()
|
||||
.then((data: any) => {
|
||||
if (data?.new_achievements && data.new_achievements.length > 0) {
|
||||
data.new_achievements.forEach((ach: any) => {
|
||||
showAchievement({
|
||||
id: ach.code,
|
||||
icon: ach.icon,
|
||||
name: ach.name,
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
const getCloseLabel = (): string => {
|
||||
const labels: Record<string, string> = {
|
||||
pl: 'Zamknij',
|
||||
ru: 'Закрыть',
|
||||
uk: 'Закрити',
|
||||
en: 'Close',
|
||||
};
|
||||
return labels[language] || labels.en;
|
||||
};
|
||||
|
||||
const renderAlphabet = () => (
|
||||
<div style={{ animation: 'fadeInUp 0.3s ease' }}>
|
||||
<p style={{ textAlign: 'center', color: subColor, fontSize: '14px', marginBottom: '16px' }}>
|
||||
{language === 'pl' ? 'Kliknij na literę, aby zobaczyć szczegóły i usłyszeć wymowę' :
|
||||
language === 'ru' ? 'Нажми на букву, чтобы увидеть детали и услышать произношение' :
|
||||
language === 'uk' ? 'Натисни на літеру, щоб побачити деталі та почути вимову' :
|
||||
'Tap a letter to see details and hear pronunciation'}
|
||||
</p>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(60px, 1fr))',
|
||||
gap: '8px',
|
||||
}}>
|
||||
{polishAlphabet.map((letter, i) => {
|
||||
const isDiacritic = polishDiacritics.some(d => d.letter.includes(letter.upper));
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setSelectedLetter(i)}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '14px 8px',
|
||||
borderRadius: radius.md,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
}),
|
||||
color: textColor,
|
||||
fontSize: '20px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
border: isDiacritic ? `2px solid ${tabColors.alphabet}50` : '1px solid rgba(255,255,255,0.7)',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.95)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
<span style={{ fontSize: '22px' }}>{letter.upper}</span>
|
||||
<span style={{ fontSize: '10px', color: subColor }}>{letter.lower}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectedLetter !== null && (() => {
|
||||
const letter = polishAlphabet[selectedLetter];
|
||||
const diacriticInfo = getDiacriticInfo(letter.upper);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(20,22,35,0.25)', zIndex: 500,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
animation: 'modalFadeIn 0.2s ease', padding: '20px',
|
||||
backdropFilter: 'blur(4px)', WebkitBackdropFilter: 'blur(4px)',
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setSelectedLetter(null); }}
|
||||
>
|
||||
<div style={{
|
||||
...glass({
|
||||
borderRadius: radius.lg,
|
||||
padding: '24px',
|
||||
width: '100%',
|
||||
maxWidth: '360px',
|
||||
textAlign: 'center',
|
||||
}),
|
||||
boxShadow: '0 20px 60px rgba(31,38,73,0.20), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
animation: 'modalSlideUp 0.3s ease',
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '72px', fontWeight: 900, color: tabColors.alphabet,
|
||||
lineHeight: '1', marginBottom: '4px',
|
||||
}}>
|
||||
{letter.upper} <span style={{ fontSize: '56px' }}>{letter.lower}</span>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
fontSize: '16px', color: textColor, fontWeight: 700,
|
||||
marginBottom: '12px', padding: '8px 16px',
|
||||
background: `${tabColors.alphabet}15`, borderRadius: radius.md,
|
||||
display: 'inline-block',
|
||||
}}>
|
||||
{language === 'pl' ? 'Wymowa' : language === 'ru' ? 'Произношение' : language === 'uk' ? 'Вимова' : 'Pronunciation'}: {diacriticInfo ? diacriticInfo.pronunciation : getLocalized(letter, 'pronunciation')}
|
||||
</div>
|
||||
|
||||
{diacriticInfo && (
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
background: `${tabColors.alphabet}10`,
|
||||
borderRadius: radius.md,
|
||||
marginBottom: '12px',
|
||||
fontSize: '13px',
|
||||
color: subColor,
|
||||
lineHeight: '1.5',
|
||||
textAlign: 'left',
|
||||
border: `1px solid ${tabColors.alphabet}30`,
|
||||
}}>
|
||||
{diacriticInfo.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
padding: '16px', background: 'rgba(0,0,0,0.02)',
|
||||
borderRadius: radius.md, marginBottom: '16px', textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
border: isPlayingLetter ? `2px solid ${tabColors.alphabet}50` : '2px solid transparent',
|
||||
}}
|
||||
onClick={() => playLetterSound(letter.example_pl)}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.98)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
<div style={{ fontSize: '18px', fontWeight: 700, color: textColor, marginBottom: '6px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
🇵🇱 {letter.example_pl}
|
||||
<span style={{
|
||||
fontSize: '16px',
|
||||
color: tabColors.alphabet,
|
||||
animation: isPlayingLetter ? 'pulse 0.5s ease infinite' : 'none',
|
||||
display: 'inline-block',
|
||||
}}>
|
||||
🔊
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '13px', color: subColor }}>
|
||||
{getLocalizedExample(letter)}
|
||||
</div>
|
||||
<div style={{ fontSize: '10px', color: tabColors.alphabet, marginTop: '6px', fontWeight: 600 }}>
|
||||
{isPlayingLetter
|
||||
? (language === 'pl' ? 'Odtwarzanie...' : language === 'ru' ? 'Воспроизведение...' : language === 'uk' ? 'Відтворення...' : 'Playing...')
|
||||
: (language === 'pl' ? 'Kliknij aby usłyszeć' : language === 'ru' ? 'Нажми чтобы услышать' : language === 'uk' ? 'Натисни щоб почути' : 'Tap to hear')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedLetter(null);
|
||||
setIsPlayingLetter(false);
|
||||
}}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '12px 24px',
|
||||
borderRadius: radius.md,
|
||||
}),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
width: '100%',
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.96)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
{getCloseLabel()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderGrammar = () => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', animation: 'fadeInUp 0.3s ease' }}>
|
||||
{grammarRules.map((rule: any) => (
|
||||
<div key={rule.id} style={{
|
||||
...glass({
|
||||
padding: '16px',
|
||||
borderRadius: radius.lg,
|
||||
}),
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
}} onClick={() => setSelectedRule(rule.id)}>
|
||||
<div style={{ fontSize: '16px', fontWeight: 700, color: tabColors.grammar }}>
|
||||
{getLocalized(rule, 'title')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{selectedRule !== null && (() => {
|
||||
const rule = grammarRules.find((r: any) => r.id === selectedRule);
|
||||
if (!rule) return null;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(20,22,35,0.25)', zIndex: 500,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
animation: 'modalFadeIn 0.2s ease', padding: '20px',
|
||||
backdropFilter: 'blur(4px)', WebkitBackdropFilter: 'blur(4px)',
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setSelectedRule(null); }}
|
||||
>
|
||||
<div style={{
|
||||
...glass({
|
||||
borderRadius: radius.lg,
|
||||
padding: '24px',
|
||||
width: '100%',
|
||||
maxWidth: '420px',
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto',
|
||||
}),
|
||||
boxShadow: '0 24px 60px rgba(31,38,73,0.20), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
animation: 'modalSlideUp 0.3s ease',
|
||||
}}>
|
||||
<h3 style={{
|
||||
margin: '0 0 12px 0',
|
||||
fontSize: '18px',
|
||||
fontWeight: 700,
|
||||
color: tabColors.grammar,
|
||||
}}>
|
||||
📐 {getLocalized(rule, 'title')}
|
||||
</h3>
|
||||
<div style={{ width: 'calc(100% - 32px)', height: '2px', background: `${tabColors.grammar}25`, borderRadius: '1px', margin: '0 16px 16px 16px' }} />
|
||||
<p style={{
|
||||
fontSize: '14px',
|
||||
color: textColor,
|
||||
lineHeight: '1.7',
|
||||
marginBottom: '16px',
|
||||
whiteSpace: 'pre-line',
|
||||
}}>
|
||||
{getLocalized(rule, 'description').replace(/\\n/g, '\n')}
|
||||
</p>
|
||||
<div style={{
|
||||
padding: '14px',
|
||||
background: `${tabColors.grammar}10`,
|
||||
borderRadius: radius.md,
|
||||
border: `1px solid ${tabColors.grammar}30`,
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
marginBottom: '20px',
|
||||
}}>
|
||||
{language === 'pl' ? (
|
||||
<div style={{ fontWeight: 700, color: textColor, whiteSpace: 'pre-line' }}>
|
||||
{rule.example_pl.replace(/\\n/g, '\n')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: textColor, fontSize: '14px', lineHeight: '1.6', whiteSpace: 'pre-line' }}>
|
||||
{getLocalized(rule, 'example').replace(/\\n/g, '\n')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedRule(null)}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '12px 24px',
|
||||
borderRadius: radius.md,
|
||||
}),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
width: '100%',
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.96)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
{getCloseLabel()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderCheatsheets = () => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', animation: 'fadeInUp 0.3s ease' }}>
|
||||
{cheatsheets.map((section) => (
|
||||
<div key={section.id} style={{
|
||||
...glass({
|
||||
padding: '16px',
|
||||
borderRadius: radius.lg,
|
||||
}),
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
}} onClick={() => setSelectedCheatSheet(section.id)}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '20px' }}>{section.icon}</span>
|
||||
<span style={{ fontSize: '16px', fontWeight: 700, color: tabColors.cheatsheets }}>
|
||||
{getLocalized(section, 'title')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{selectedCheatSheet !== null && (() => {
|
||||
const section = cheatsheets.find(s => s.id === selectedCheatSheet);
|
||||
if (!section) return null;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(20,22,35,0.25)', zIndex: 500,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
animation: 'modalFadeIn 0.2s ease', padding: '20px',
|
||||
backdropFilter: 'blur(4px)', WebkitBackdropFilter: 'blur(4px)',
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setSelectedCheatSheet(null); }}
|
||||
>
|
||||
<div style={{
|
||||
...glass({
|
||||
borderRadius: radius.lg,
|
||||
padding: '24px',
|
||||
width: '100%',
|
||||
maxWidth: '420px',
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto',
|
||||
}),
|
||||
boxShadow: '0 24px 60px rgba(31,38,73,0.20), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
animation: 'modalSlideUp 0.3s ease',
|
||||
}}>
|
||||
<h3 style={{
|
||||
margin: '0 0 16px 0',
|
||||
fontSize: '18px',
|
||||
fontWeight: 700,
|
||||
color: tabColors.cheatsheets,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}>
|
||||
<span>{section.icon}</span>
|
||||
<span>{getLocalized(section, 'title')}</span>
|
||||
</h3>
|
||||
<div style={{ width: 'calc(100% - 32px)', height: '2px', background: `${tabColors.cheatsheets}25`, borderRadius: '1px', margin: '0 16px 16px 16px' }} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', marginBottom: '20px' }}>
|
||||
{section.items.map((item, i) => {
|
||||
const content = language === 'pl' ? item.pl : (item[language as keyof typeof item] || item.en || item.pl);
|
||||
|
||||
return (
|
||||
<div key={i} style={{
|
||||
padding: '14px',
|
||||
background: i % 2 === 0 ? 'rgba(0,0,0,0.02)' : 'transparent',
|
||||
borderRadius: radius.md,
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.8',
|
||||
whiteSpace: 'pre-line',
|
||||
color: textColor,
|
||||
border: '1px solid rgba(0,0,0,0.04)',
|
||||
}}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedCheatSheet(null)}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '12px 24px',
|
||||
borderRadius: radius.md,
|
||||
}),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
width: '100%',
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.96)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
{getCloseLabel()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: bgColor,
|
||||
color: textColor,
|
||||
fontFamily: fonts.main,
|
||||
padding: '24px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ position: 'fixed', top: '-80px', right: '-60px', width: '260px', height: '260px', borderRadius: '50%', background: tabColors[activeTab], opacity: 0.18, filter: 'blur(90px)', pointerEvents: 'none', zIndex: 0 }} />
|
||||
<div style={{ position: 'fixed', bottom: '-50px', left: '-40px', width: '240px', height: '240px', borderRadius: '50%', background: brandColor, opacity: 0.14, filter: 'blur(80px)', pointerEvents: 'none', zIndex: 0 }} />
|
||||
|
||||
<style>{`
|
||||
@keyframes fadeInUp {
|
||||
0% { transform: translateY(10px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes achievementSlideDown {
|
||||
0% { transform: translateX(-50%) translateY(-80px); opacity: 0; }
|
||||
100% { transform: translateX(-50%) translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes modalFadeIn {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
@keyframes modalSlideUp {
|
||||
0% { transform: translateY(30px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.1); }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div style={{ position: 'relative', zIndex: 1, display: 'flex', flexDirection: 'column', flex: 1 }}>
|
||||
|
||||
{activeAchievement && (
|
||||
<div style={{
|
||||
position: 'fixed', top: '16px', left: '50%', transform: 'translateX(-50%)', zIndex: 900,
|
||||
background: 'linear-gradient(135deg, #FFD700, #FFA500)',
|
||||
color: '#FFFFFF', padding: '14px 20px', borderRadius: radius.lg,
|
||||
boxShadow: '0 8px 30px rgba(255, 215, 0, 0.5)',
|
||||
display: 'flex', alignItems: 'center', gap: '12px',
|
||||
minWidth: '250px', maxWidth: '90vw', animation: 'achievementSlideDown 0.4s ease',
|
||||
}}>
|
||||
<span style={{ fontSize: '28px', flexShrink: 0 }}>{activeAchievement.icon}</span>
|
||||
<div>
|
||||
<div style={{ fontSize: '11px', fontWeight: 600, opacity: 0.9, letterSpacing: '1px', textTransform: 'uppercase' }}>
|
||||
🏆 {t('achievement') || 'Achievement'}
|
||||
</div>
|
||||
<div style={{ fontSize: '15px', fontWeight: 800, marginTop: '2px' }}>
|
||||
{activeAchievement.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: '12px', animation: 'fadeInUp 0.3s ease' }}>
|
||||
<button
|
||||
onClick={() => navigate('/menu')}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '10px 18px',
|
||||
borderRadius: radius.md,
|
||||
}),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.95)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
← {t('back')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex', gap: '6px', marginBottom: '20px', overflow: 'auto',
|
||||
WebkitOverflowScrolling: 'touch', paddingBottom: '4px',
|
||||
animation: 'fadeInUp 0.3s ease 0.05s both',
|
||||
}}>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => switchTab(tab.key)}
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
borderRadius: radius.md,
|
||||
border: activeTab === tab.key ? `2px solid ${tabColors[tab.key]}` : '1px solid rgba(255,255,255,0.7)',
|
||||
background: activeTab === tab.key ? `${tabColors[tab.key]}18` : cardBg,
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
color: activeTab === tab.key ? tabColors[tab.key] : textColor,
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
boxShadow: activeTab === tab.key ? `0 0 10px ${tabColors[tab.key]}30` : '0 2px 8px rgba(31,38,73,0.06)',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{tab.icon} {getTabLabel(tab)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
{activeTab === 'alphabet' && renderAlphabet()}
|
||||
{activeTab === 'grammar' && renderGrammar()}
|
||||
{activeTab === 'cheatsheets' && renderCheatsheets()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FlashcardsPage;
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLanguage } from '../i18n/LanguageContext';
|
||||
import { fonts, radius } from '../styles/theme';
|
||||
|
||||
const languages = [
|
||||
{ code: 'pl', name: 'Polski', flag: '🇵🇱' },
|
||||
{ code: 'ru', name: 'Русский', flag: '🇷🇺' },
|
||||
{ code: 'uk', name: 'Українська', flag: '🇺🇦' },
|
||||
{ code: 'en', name: 'English', flag: '🇬🇧' },
|
||||
];
|
||||
|
||||
const LanguageSelectPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { setLang } = useLanguage();
|
||||
|
||||
const handleSelect = (code: string) => {
|
||||
setLang(code);
|
||||
navigate('/menu');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: '#F4F5F7',
|
||||
color: '#1A1A2E',
|
||||
fontFamily: fonts.main,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '24px',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ position: 'fixed', top: '-120px', left: '-90px', width: '320px', height: '320px', borderRadius: '50%', background: '#2ECC71', opacity: 0.25, filter: 'blur(90px)', pointerEvents: 'none', zIndex: 0 }} />
|
||||
<div style={{ position: 'fixed', bottom: '-80px', right: '-60px', width: '280px', height: '280px', borderRadius: '50%', background: '#4FA9E8', opacity: 0.20, filter: 'blur(90px)', pointerEvents: 'none', zIndex: 0 }} />
|
||||
|
||||
<style>{`
|
||||
@keyframes fadeInUp {
|
||||
0% { transform: translateY(15px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div style={{ position: 'relative', zIndex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', width: '100%', maxWidth: '340px' }}>
|
||||
<div style={{ fontSize: '56px', marginBottom: '16px', animation: 'fadeInUp 0.3s ease' }}>🌍</div>
|
||||
<h1 style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 800,
|
||||
marginBottom: '8px',
|
||||
textAlign: 'center',
|
||||
color: '#1A1A2E',
|
||||
animation: 'fadeInUp 0.3s ease 0.05s both',
|
||||
}}>
|
||||
Wybierz język
|
||||
</h1>
|
||||
<p style={{
|
||||
fontSize: '14px',
|
||||
color: '#6B7280',
|
||||
marginBottom: '28px',
|
||||
textAlign: 'center',
|
||||
animation: 'fadeInUp 0.3s ease 0.1s both',
|
||||
}}>
|
||||
Choose your language / Выберите язык / Оберіть мову
|
||||
</p>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
width: '100%',
|
||||
animation: 'fadeInUp 0.3s ease 0.15s both',
|
||||
}}>
|
||||
{languages.map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => handleSelect(lang.code)}
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
borderRadius: radius.lg,
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
background: 'rgba(255,255,255,0.55)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
color: '#1A1A2E',
|
||||
fontSize: '17px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '14px',
|
||||
boxShadow: '0 6px 20px rgba(31,38,73,0.08), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.96)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
<span style={{ fontSize: '28px' }}>{lang.flag}</span>
|
||||
<span>{lang.name}</span>
|
||||
<span style={{ marginLeft: 'auto', fontSize: '18px', color: '#2ECC71', fontWeight: 800 }}>→</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSelectPage;
|
||||
@@ -0,0 +1,817 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLanguage } from '../i18n/LanguageContext';
|
||||
import { fonts, radius } from '../styles/theme';
|
||||
import { getLeaderboard, getUserAchievements } from '../api';
|
||||
import { getUserId } from '../telegram';
|
||||
|
||||
interface LeaderboardEntry {
|
||||
rank: number;
|
||||
telegram_id: number;
|
||||
username: string;
|
||||
total_score: number;
|
||||
wordsearch_score: number;
|
||||
sentence_score: number;
|
||||
wordquiz_score: number;
|
||||
words_guessed: number;
|
||||
bubblewords_score: number;
|
||||
bubble_levels_completed: number;
|
||||
}
|
||||
|
||||
interface MyRank {
|
||||
rank: number;
|
||||
telegram_id: number;
|
||||
total_score: number;
|
||||
}
|
||||
|
||||
interface UserAchievement {
|
||||
id: number;
|
||||
code: string;
|
||||
category: string;
|
||||
icon: string;
|
||||
name_pl: string;
|
||||
name_ru: string;
|
||||
name_uk: string;
|
||||
name_en: string;
|
||||
description_pl: string;
|
||||
description_ru: string;
|
||||
description_uk: string;
|
||||
description_en: string;
|
||||
unlocked: boolean;
|
||||
unlocked_at: string | null;
|
||||
}
|
||||
|
||||
const MAX_NAME_LENGTH = 14;
|
||||
|
||||
const truncateName = (name: string, maxLen: number = MAX_NAME_LENGTH): string => {
|
||||
if (!name) return 'Player';
|
||||
if (name.length <= maxLen) return name;
|
||||
return name.substring(0, maxLen - 2) + '…';
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string | null, language: string): string => {
|
||||
if (!dateStr) return '';
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
const locales: Record<string, string> = {
|
||||
pl: 'pl-PL',
|
||||
ru: 'ru-RU',
|
||||
uk: 'uk-UA',
|
||||
en: 'en-US',
|
||||
};
|
||||
return date.toLocaleDateString(locales[language] || 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
interface CategoryConfig {
|
||||
id: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
const ACHIEVEMENT_CATEGORIES: Record<string, CategoryConfig> = {
|
||||
wordsearch: {
|
||||
id: 'wordsearch',
|
||||
icon: '🔍',
|
||||
color: '#4FC3F7',
|
||||
bgColor: 'rgba(79, 195, 247, 0.08)',
|
||||
order: 1,
|
||||
},
|
||||
sentence: {
|
||||
id: 'sentence',
|
||||
icon: '📝',
|
||||
color: '#FF8A65',
|
||||
bgColor: 'rgba(255, 138, 101, 0.08)',
|
||||
order: 2,
|
||||
},
|
||||
wordquiz: {
|
||||
id: 'wordquiz',
|
||||
icon: '🎯',
|
||||
color: '#BA68C8',
|
||||
bgColor: 'rgba(186, 104, 200, 0.08)',
|
||||
order: 3,
|
||||
},
|
||||
bubblewords: {
|
||||
id: 'bubblewords',
|
||||
icon: '🫧',
|
||||
color: '#4DD0E1',
|
||||
bgColor: 'rgba(77, 208, 225, 0.08)',
|
||||
order: 4,
|
||||
},
|
||||
general: {
|
||||
id: 'general',
|
||||
icon: '🏆',
|
||||
color: '#FFD700',
|
||||
bgColor: 'rgba(255, 215, 0, 0.08)',
|
||||
order: 5,
|
||||
},
|
||||
};
|
||||
|
||||
const LeaderboardPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { t, language } = useLanguage();
|
||||
const [entries, setEntries] = useState<LeaderboardEntry[]>([]);
|
||||
const [myRank, setMyRank] = useState<MyRank | null>(null);
|
||||
const [achievements, setAchievements] = useState<UserAchievement[]>([]);
|
||||
const [unlockedCount, setUnlockedCount] = useState(0);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedAchievement, setSelectedAchievement] = useState<UserAchievement | null>(null);
|
||||
const [showAchievements, setShowAchievements] = useState(false);
|
||||
const currentUserId = getUserId();
|
||||
|
||||
const brandColor = '#2ECC71';
|
||||
const palette = {
|
||||
leaderboard: '#E8B93F',
|
||||
};
|
||||
const textColor = '#1A1A2E';
|
||||
const subColor = '#6B7280';
|
||||
const bgColor = '#F4F5F7';
|
||||
const cardBg = 'rgba(255,255,255,0.55)';
|
||||
|
||||
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
||||
background: cardBg,
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 8px 32px rgba(31,38,73,0.10), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
const fetchData = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const promises: Promise<any>[] = [
|
||||
getLeaderboard(50),
|
||||
getUserAchievements(),
|
||||
];
|
||||
|
||||
Promise.all(promises)
|
||||
.then((results: any[]) => {
|
||||
const leaderboardData = results[0];
|
||||
const achievementsData = results[1];
|
||||
|
||||
setEntries(leaderboardData.leaderboard || []);
|
||||
if (leaderboardData.my_rank) {
|
||||
setMyRank({
|
||||
rank: leaderboardData.my_rank.rank,
|
||||
telegram_id: leaderboardData.my_rank.telegram_id,
|
||||
total_score: leaderboardData.my_rank.total_score,
|
||||
});
|
||||
}
|
||||
setAchievements(achievementsData.achievements || []);
|
||||
setUnlockedCount(achievementsData.unlocked || 0);
|
||||
setTotalCount(achievementsData.total || 0);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
console.error('Error fetching data:', err);
|
||||
setError('Failed to load data');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const getAchievementName = (ach: UserAchievement): string => {
|
||||
const nameMap: Record<string, string> = {
|
||||
pl: ach.name_pl,
|
||||
ru: ach.name_ru,
|
||||
uk: ach.name_uk,
|
||||
en: ach.name_en,
|
||||
};
|
||||
return nameMap[language] || ach.name_en;
|
||||
};
|
||||
|
||||
const getAchievementDesc = (ach: UserAchievement): string => {
|
||||
const descMap: Record<string, string> = {
|
||||
pl: ach.description_pl,
|
||||
ru: ach.description_ru,
|
||||
uk: ach.description_uk,
|
||||
en: ach.description_en,
|
||||
};
|
||||
return descMap[language] || ach.description_en;
|
||||
};
|
||||
|
||||
const groupedAchievements = achievements.reduce((acc, ach) => {
|
||||
const cat = ach.category || 'general';
|
||||
if (!acc[cat]) {
|
||||
acc[cat] = [];
|
||||
}
|
||||
acc[cat].push(ach);
|
||||
return acc;
|
||||
}, {} as Record<string, UserAchievement[]>);
|
||||
|
||||
const sortedCategories = Object.keys(groupedAchievements).sort((a, b) => {
|
||||
const orderA = ACHIEVEMENT_CATEGORIES[a]?.order ?? 99;
|
||||
const orderB = ACHIEVEMENT_CATEGORIES[b]?.order ?? 99;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
achievements: language === 'pl' ? 'Osiągnięcia' : language === 'ru' ? 'Достижения' : language === 'uk' ? 'Досягнення' : 'Achievements',
|
||||
unlocked: language === 'pl' ? 'odblokowano' : language === 'ru' ? 'получено' : language === 'uk' ? 'отримано' : 'unlocked',
|
||||
unlockedLabel: language === 'pl' ? 'Odblokowano' : language === 'ru' ? 'Получено' : language === 'uk' ? 'Отримано' : 'Unlocked',
|
||||
lockedLabel: language === 'pl' ? 'Zablokowane' : language === 'ru' ? 'Заблокировано' : language === 'uk' ? 'Заблоковано' : 'Locked',
|
||||
close: language === 'pl' ? 'Zamknij' : language === 'ru' ? 'Закрыть' : language === 'uk' ? 'Закрити' : 'Close',
|
||||
showAchievements: language === 'pl' ? 'Pokaż osiągnięcia' : language === 'ru' ? 'Показать достижения' : language === 'uk' ? 'Показати досягнення' : 'Show Achievements',
|
||||
hideAchievements: language === 'pl' ? 'Ukryj osiągnięcia' : language === 'ru' ? 'Скрыть достижения' : language === 'uk' ? 'Сховати досягнення' : 'Hide Achievements',
|
||||
categoryWordsearch: language === 'pl' ? '🎮 Filwords' : language === 'ru' ? '🎮 Филворды' : language === 'uk' ? '🎮 Філворди' : '🎮 Word Search',
|
||||
categorySentence: language === 'pl' ? '📝 Ułóż zdanie' : language === 'ru' ? '📝 Составь предложение' : language === 'uk' ? '📝 Склади речення' : '📝 Sentence Builder',
|
||||
categoryWordquiz: language === 'pl' ? '🎯 Wybierz słowo' : language === 'ru' ? '🎯 Выбери слово' : language === 'uk' ? '🎯 Вибери слово' : '🎯 Word Match',
|
||||
categoryBubblewords: language === 'pl' ? '🫧 Bubbles' : language === 'ru' ? '🫧 Пузыри' : language === 'uk' ? '🫧 Бульбашки' : '🫧 Bubbles',
|
||||
categoryGeneral: language === 'pl' ? '🏆 Ogólne' : language === 'ru' ? '🏆 Общие' : language === 'uk' ? '🏆 Загальні' : '🏆 General',
|
||||
};
|
||||
|
||||
const getCategoryLabel = (categoryId: string): string => {
|
||||
const keyMap: Record<string, string> = {
|
||||
wordsearch: 'categoryWordsearch',
|
||||
sentence: 'categorySentence',
|
||||
wordquiz: 'categoryWordquiz',
|
||||
bubblewords: 'categoryBubblewords',
|
||||
general: 'categoryGeneral',
|
||||
};
|
||||
const key = keyMap[categoryId] || 'categoryGeneral';
|
||||
return labels[key] || categoryId;
|
||||
};
|
||||
|
||||
const getTop3RowStyle = (rank: number): React.CSSProperties => {
|
||||
if (rank === 1) return {
|
||||
background: 'linear-gradient(135deg, rgba(255,215,0,0.15), rgba(255,215,0,0.05))',
|
||||
border: '1px solid rgba(255,215,0,0.3)',
|
||||
boxShadow: '0 0 15px rgba(255,215,0,0.15)',
|
||||
};
|
||||
if (rank === 2) return {
|
||||
background: 'linear-gradient(135deg, rgba(192,192,192,0.12), rgba(192,192,192,0.04))',
|
||||
border: '1px solid rgba(192,192,192,0.3)',
|
||||
boxShadow: '0 0 12px rgba(192,192,192,0.12)',
|
||||
};
|
||||
if (rank === 3) return {
|
||||
background: 'linear-gradient(135deg, rgba(205,127,50,0.12), rgba(205,127,50,0.04))',
|
||||
border: '1px solid rgba(205,127,50,0.3)',
|
||||
boxShadow: '0 0 12px rgba(205,127,50,0.12)',
|
||||
};
|
||||
return {};
|
||||
};
|
||||
|
||||
const getMedalEmoji = (rank: number): string => {
|
||||
if (rank === 1) return '🥇';
|
||||
if (rank === 2) return '🥈';
|
||||
if (rank === 3) return '🥉';
|
||||
return '';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: bgColor,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: subColor,
|
||||
fontFamily: fonts.main,
|
||||
fontSize: '18px',
|
||||
}}>
|
||||
{t('loading')}...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: bgColor,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: subColor,
|
||||
fontFamily: fonts.main,
|
||||
padding: '24px',
|
||||
}}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>⚠️</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 600, marginBottom: '8px' }}>
|
||||
{error}
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchData}
|
||||
style={{
|
||||
marginTop: '16px',
|
||||
padding: '12px 24px',
|
||||
borderRadius: radius.md,
|
||||
border: 'none',
|
||||
background: `linear-gradient(180deg, ${brandColor}E6, ${brandColor}CC)`,
|
||||
color: '#12271C',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
boxShadow: `0 8px 24px ${brandColor}50`,
|
||||
}}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/menu')}
|
||||
style={{
|
||||
marginTop: '12px',
|
||||
...glass({
|
||||
padding: '12px 24px',
|
||||
borderRadius: radius.md,
|
||||
}),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
← {t('back')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: bgColor,
|
||||
color: textColor,
|
||||
fontFamily: fonts.main,
|
||||
padding: '24px',
|
||||
paddingBottom: '40px',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ position: 'fixed', top: '-80px', right: '-60px', width: '260px', height: '260px', borderRadius: '50%', background: palette.leaderboard, opacity: 0.22, filter: 'blur(90px)', pointerEvents: 'none', zIndex: 0 }} />
|
||||
<div style={{ position: 'fixed', bottom: '-50px', left: '-40px', width: '240px', height: '240px', borderRadius: '50%', background: brandColor, opacity: 0.16, filter: 'blur(80px)', pointerEvents: 'none', zIndex: 0 }} />
|
||||
|
||||
<style>{`
|
||||
@keyframes fadeInUp {
|
||||
0% { transform: translateY(10px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes modalFadeIn {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
@keyframes modalSlideUp {
|
||||
0% { transform: translateY(30px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes achievementsSlideDown {
|
||||
0% { max-height: 0; opacity: 0; margin-top: 0; }
|
||||
100% { max-height: 4000px; opacity: 1; margin-top: 16px; }
|
||||
}
|
||||
@keyframes categoryFadeIn {
|
||||
0% { transform: translateY(8px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div style={{ position: 'relative', zIndex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
|
||||
{/* Модальное окно достижения */}
|
||||
{selectedAchievement && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(20,22,35,0.25)', zIndex: 500,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
animation: 'modalFadeIn 0.2s ease', padding: '20px',
|
||||
backdropFilter: 'blur(4px)', WebkitBackdropFilter: 'blur(4px)',
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setSelectedAchievement(null); }}
|
||||
>
|
||||
<div style={{
|
||||
...glass({
|
||||
borderRadius: radius.lg,
|
||||
padding: '24px',
|
||||
width: '100%',
|
||||
maxWidth: '360px',
|
||||
textAlign: 'center',
|
||||
}),
|
||||
boxShadow: '0 20px 60px rgba(31,38,73,0.20), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
animation: 'modalSlideUp 0.3s ease',
|
||||
}}>
|
||||
<div style={{ fontSize: '56px', marginBottom: '12px', opacity: selectedAchievement.unlocked ? 1 : 0.4 }}>
|
||||
{selectedAchievement.unlocked ? selectedAchievement.icon : '🔒'}
|
||||
</div>
|
||||
<h3 style={{ fontSize: '18px', fontWeight: 800, margin: '0 0 4px 0', color: selectedAchievement.unlocked ? textColor : subColor }}>
|
||||
{getAchievementName(selectedAchievement)}
|
||||
</h3>
|
||||
<div style={{ width: '40px', height: '3px', background: selectedAchievement.unlocked ? brandColor : 'rgba(0,0,0,0.1)', borderRadius: '2px', margin: '12px auto' }} />
|
||||
<p style={{ fontSize: '14px', color: subColor, margin: '0 0 16px 0', lineHeight: '1.5' }}>
|
||||
{getAchievementDesc(selectedAchievement)}
|
||||
</p>
|
||||
<div style={{
|
||||
padding: '10px 16px', borderRadius: radius.md,
|
||||
background: selectedAchievement.unlocked ? `${brandColor}15` : 'rgba(0,0,0,0.03)',
|
||||
border: `1px solid ${selectedAchievement.unlocked ? brandColor + '40' : 'rgba(0,0,0,0.06)'}`,
|
||||
marginBottom: '16px',
|
||||
}}>
|
||||
{selectedAchievement.unlocked ? (
|
||||
<>
|
||||
<div style={{ fontSize: '13px', fontWeight: 600, color: brandColor }}>✅ {labels.unlockedLabel}</div>
|
||||
{selectedAchievement.unlocked_at && (
|
||||
<div style={{ fontSize: '12px', color: subColor, marginTop: '4px' }}>{formatDate(selectedAchievement.unlocked_at, language)}</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div style={{ fontSize: '13px', fontWeight: 600, color: subColor }}>🔒 {labels.lockedLabel}</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => setSelectedAchievement(null)}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '12px 24px',
|
||||
borderRadius: radius.md,
|
||||
}),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s ease',
|
||||
width: '100%',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.95)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}>
|
||||
{labels.close}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Заголовок */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '24px', animation: 'fadeInUp 0.3s ease' }}>
|
||||
<button
|
||||
onClick={() => navigate('/menu')}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '10px 18px',
|
||||
borderRadius: radius.md,
|
||||
}),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.95)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
← {t('back')}
|
||||
</button>
|
||||
<h1 style={{ fontSize: '26px', fontWeight: 900, margin: 0, color: textColor }}>
|
||||
🏆 {t('leaderboard')}
|
||||
</h1>
|
||||
<button
|
||||
onClick={fetchData}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
...glass({
|
||||
padding: '8px 12px',
|
||||
borderRadius: radius.md,
|
||||
}),
|
||||
color: subColor,
|
||||
fontSize: '18px',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.9) rotate(90deg)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1) rotate(0deg)'; }}
|
||||
>
|
||||
🔄
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Пустой рейтинг */}
|
||||
{entries.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 20px', color: subColor, animation: 'fadeInUp 0.3s ease 0.2s both' }}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🏆</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 600, marginBottom: '8px' }}>{t('noPlayers')}</div>
|
||||
<div style={{ fontSize: '14px' }}>{t('beFirst')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* ТОП-3 БИЛБОРД */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr 1fr',
|
||||
gap: '10px',
|
||||
marginBottom: '20px',
|
||||
animation: 'fadeInUp 0.3s ease 0.2s both',
|
||||
}}>
|
||||
{entries.filter(e => e.rank <= 3).map((entry) => {
|
||||
const isCurrentUser = entry.telegram_id === currentUserId;
|
||||
const medal = getMedalEmoji(entry.rank);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.telegram_id}
|
||||
style={{
|
||||
...glass({
|
||||
borderRadius: radius.lg,
|
||||
padding: '16px 10px',
|
||||
textAlign: 'center',
|
||||
}),
|
||||
...getTop3RowStyle(entry.rank),
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '32px', marginBottom: '4px' }}>{medal}</div>
|
||||
<div style={{ fontSize: '13px', fontWeight: 700, color: textColor, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{isCurrentUser ? 'You' : truncateName(entry.username, 10)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: 800, color: entry.rank === 1 ? '#FFD700' : entry.rank === 2 ? '#C0C0C0' : '#CD7F32', marginTop: '4px' }}>
|
||||
{entry.total_score.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Таблица остальных */}
|
||||
<div style={{
|
||||
...glass({
|
||||
borderRadius: radius.lg,
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
animation: 'fadeInUp 0.3s ease 0.3s both',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '40px 1fr 80px',
|
||||
gap: '6px',
|
||||
padding: '12px 14px',
|
||||
fontSize: '12px',
|
||||
color: '#FFFFFF',
|
||||
fontWeight: 700,
|
||||
background: `linear-gradient(135deg, ${brandColor}, #27AE60)`,
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<span>#</span>
|
||||
<span>{t('player')}</span>
|
||||
<span style={{ textAlign: 'right' }}>{t('score')}</span>
|
||||
</div>
|
||||
|
||||
{entries.filter(e => e.rank > 3).map((entry, index) => {
|
||||
const isCurrentUser = entry.telegram_id === currentUserId;
|
||||
const displayName = isCurrentUser ? 'You' : truncateName(entry.username, 15);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${entry.telegram_id}-${entry.rank}`}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '40px 1fr 80px',
|
||||
gap: '6px',
|
||||
padding: '12px 14px',
|
||||
borderBottom: index < entries.filter(e => e.rank > 3).length - 1 ? '1px solid rgba(0,0,0,0.05)' : 'none',
|
||||
alignItems: 'center',
|
||||
fontSize: '13px',
|
||||
animation: `fadeInUp 0.3s ease ${0.3 + index * 0.02}s both`,
|
||||
transition: 'background 0.2s ease',
|
||||
...(isCurrentUser ? {
|
||||
background: `${brandColor}10`,
|
||||
borderLeft: `3px solid ${brandColor}`,
|
||||
borderRight: `3px solid ${brandColor}`,
|
||||
} : {}),
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
fontWeight: 700,
|
||||
fontSize: '15px',
|
||||
color: subColor,
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
{entry.rank}
|
||||
</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0 }}>
|
||||
<span style={{
|
||||
fontWeight: isCurrentUser ? 700 : 500,
|
||||
color: isCurrentUser ? brandColor : textColor,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '100%',
|
||||
}}>
|
||||
{displayName}
|
||||
</span>
|
||||
{isCurrentUser && (
|
||||
<span style={{
|
||||
fontSize: '9px',
|
||||
padding: '2px 6px',
|
||||
background: brandColor,
|
||||
color: '#FFFFFF',
|
||||
borderRadius: '10px',
|
||||
fontWeight: 700,
|
||||
flexShrink: 0,
|
||||
lineHeight: '1.2',
|
||||
}}>
|
||||
YOU
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span style={{
|
||||
textAlign: 'right',
|
||||
fontWeight: 700,
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
{entry.total_score.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Моё место (если не в топ-50) */}
|
||||
{myRank && myRank.rank > 50 && (
|
||||
<div style={{
|
||||
marginTop: '12px',
|
||||
...glass({
|
||||
padding: '12px 14px',
|
||||
borderRadius: radius.md,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '40px 1fr 80px',
|
||||
gap: '6px',
|
||||
alignItems: 'center',
|
||||
fontSize: '13px',
|
||||
}),
|
||||
animation: 'fadeInUp 0.3s ease',
|
||||
}}>
|
||||
<span style={{ fontWeight: 700, fontSize: '15px', color: brandColor, textAlign: 'center' }}>{myRank.rank}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0 }}>
|
||||
<span style={{ color: brandColor, fontWeight: 700 }}>You</span>
|
||||
<span style={{ fontSize: '9px', padding: '2px 6px', background: brandColor, color: '#FFFFFF', borderRadius: '10px', fontWeight: 700, flexShrink: 0 }}>YOU</span>
|
||||
</div>
|
||||
<span style={{ textAlign: 'right', fontWeight: 700, color: brandColor, fontSize: '14px' }}>{myRank.total_score.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Сворачиваемые достижения */}
|
||||
{totalCount > 0 && (
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<button
|
||||
onClick={() => setShowAchievements(!showAchievements)}
|
||||
style={{
|
||||
width: '100%',
|
||||
...glass({
|
||||
padding: '16px',
|
||||
borderRadius: radius.lg,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}),
|
||||
color: textColor,
|
||||
fontSize: '15px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.98)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
🏅 {labels.achievements}
|
||||
<span style={{ fontSize: '12px', color: subColor, fontWeight: 400 }}>
|
||||
{unlockedCount}/{totalCount} {labels.unlocked}
|
||||
</span>
|
||||
</span>
|
||||
<span style={{ fontSize: '14px', color: subColor, transition: 'transform 0.3s ease', transform: showAchievements ? 'rotate(180deg)' : 'rotate(0deg)' }}>
|
||||
▼
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div style={{ width: '100%', height: '4px', background: 'rgba(0,0,0,0.04)', borderRadius: '2px', marginTop: '8px', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${totalCount > 0 ? (unlockedCount / totalCount * 100) : 0}%`, height: '100%', background: `linear-gradient(90deg, ${brandColor}, #27AE60)`, borderRadius: '2px', transition: 'width 0.5s ease' }} />
|
||||
</div>
|
||||
|
||||
{showAchievements && (
|
||||
<div style={{
|
||||
marginTop: '16px',
|
||||
...glass({
|
||||
padding: '16px',
|
||||
borderRadius: radius.lg,
|
||||
}),
|
||||
animation: 'achievementsSlideDown 0.4s ease',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{sortedCategories.map((categoryId, catIndex) => {
|
||||
const catAchievements = groupedAchievements[categoryId];
|
||||
const catConfig = ACHIEVEMENT_CATEGORIES[categoryId];
|
||||
const catUnlocked = catAchievements.filter(a => a.unlocked).length;
|
||||
const catTotal = catAchievements.length;
|
||||
const catProgressPercent = catTotal > 0 ? (catUnlocked / catTotal * 100) : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={categoryId}
|
||||
style={{
|
||||
marginBottom: catIndex < sortedCategories.length - 1 ? '20px' : '0',
|
||||
animation: `categoryFadeIn 0.3s ease ${0.1 + catIndex * 0.08}s both`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{
|
||||
fontSize: '18px',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '8px',
|
||||
background: catConfig?.bgColor || 'rgba(0,0,0,0.03)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
{catConfig?.icon || '🏆'}
|
||||
</span>
|
||||
<span style={{ fontSize: '14px', fontWeight: 700, color: textColor }}>
|
||||
{getCategoryLabel(categoryId)}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontSize: '12px', fontWeight: 600, color: catProgressPercent === 100 ? brandColor : subColor }}>
|
||||
{catUnlocked}/{catTotal}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ width: '100%', height: '3px', background: catConfig?.bgColor || 'rgba(0,0,0,0.04)', borderRadius: '2px', marginBottom: '10px', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${catProgressPercent}%`, height: '100%', background: catConfig?.color || brandColor, borderRadius: '2px', transition: 'width 0.5s ease' }} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: '8px' }}>
|
||||
{catAchievements.map((ach) => (
|
||||
<div
|
||||
key={ach.id}
|
||||
onClick={() => setSelectedAchievement(ach)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '10px 12px',
|
||||
borderRadius: radius.md,
|
||||
background: ach.unlocked
|
||||
? `${catConfig?.color || brandColor}15`
|
||||
: 'rgba(0,0,0,0.02)',
|
||||
border: `1px solid ${ach.unlocked ? (catConfig?.color || brandColor) + '40' : 'rgba(0,0,0,0.06)'}`,
|
||||
opacity: ach.unlocked ? 1 : 0.45,
|
||||
transition: 'all 0.2s ease',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.96)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
<span style={{ fontSize: '22px', flexShrink: 0 }}>
|
||||
{ach.unlocked ? ach.icon : '🔒'}
|
||||
</span>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: ach.unlocked ? textColor : subColor,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{getAchievementName(ach)}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '10px',
|
||||
color: subColor,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
marginTop: '2px',
|
||||
}}>
|
||||
{getAchievementDesc(ach)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LeaderboardPage;
|
||||
@@ -0,0 +1,549 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useLanguage } from '../i18n/LanguageContext';
|
||||
import { useLocalized } from '../hooks/useLocalized';
|
||||
import { fonts, radius } from '../styles/theme';
|
||||
import { getLearningLesson, startLearningSession, saveLearningAnswer, completeLearningSession } from '../api';
|
||||
|
||||
interface QuestionOption {
|
||||
option_key: string;
|
||||
text_pl: string;
|
||||
text_ru: string;
|
||||
text_uk: string;
|
||||
text_en: string;
|
||||
is_correct: boolean;
|
||||
wrong_because: string | null;
|
||||
}
|
||||
|
||||
interface Question {
|
||||
question: {
|
||||
question_id: string;
|
||||
prompt_pl: string;
|
||||
prompt_ru: string;
|
||||
prompt_uk: string;
|
||||
prompt_en: string;
|
||||
question_type: string;
|
||||
skill_tag: string;
|
||||
difficulty: number;
|
||||
};
|
||||
options: QuestionOption[];
|
||||
}
|
||||
|
||||
interface Example {
|
||||
text_pl: string;
|
||||
text_ru: string;
|
||||
text_uk: string;
|
||||
text_en: string;
|
||||
}
|
||||
|
||||
interface LessonData {
|
||||
lesson: {
|
||||
lesson_id: string;
|
||||
title_pl: string;
|
||||
title_ru: string;
|
||||
title_uk: string;
|
||||
title_en: string;
|
||||
explanation_pl: string;
|
||||
explanation_ru: string;
|
||||
explanation_uk: string;
|
||||
explanation_en: string;
|
||||
};
|
||||
examples: Example[];
|
||||
questions: Question[];
|
||||
}
|
||||
|
||||
const LessonViewPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { lessonId, topicId } = useParams<{ lessonId: string; topicId: string }>();
|
||||
const { t } = useLanguage();
|
||||
const getLocalized = useLocalized();
|
||||
const [lesson, setLesson] = useState<LessonData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||
const [selectedAnswers, setSelectedAnswers] = useState<Record<string, string>>({});
|
||||
const [textInputValue, setTextInputValue] = useState('');
|
||||
const [sessionId, setSessionId] = useState<number | null>(null);
|
||||
const [isCompleting, setIsCompleting] = useState(false);
|
||||
const [hintUsed, setHintUsed] = useState<Record<string, boolean>>({});
|
||||
const [showHint, setShowHint] = useState(false);
|
||||
const startTimeRef = useRef<number>(Date.now());
|
||||
|
||||
const brandColor = '#2ECC71';
|
||||
const textColor = '#1A1A2E';
|
||||
const subColor = '#6B7280';
|
||||
const bgColor = '#F4F5F7';
|
||||
const cardBg = 'rgba(255,255,255,0.55)';
|
||||
|
||||
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
||||
background: cardBg,
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 8px 32px rgba(31,38,73,0.10), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (lessonId && topicId) {
|
||||
setLoading(true);
|
||||
getLearningLesson(lessonId)
|
||||
.then((data: any) => {
|
||||
setLesson(data);
|
||||
return startLearningSession('A1', topicId, 'lesson');
|
||||
})
|
||||
.then((sessionData: any) => {
|
||||
setSessionId(sessionData.session_id);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [lessonId, topicId]);
|
||||
|
||||
// 🔧 ИСПРАВЛЕНО: saveAnswer теперь принимает isFinalAnswer и hintUsed
|
||||
const saveAnswer = async (questionId: string, answer: string, isFinalAnswer: boolean = true, usedHint: boolean = false) => {
|
||||
const timeTaken = Date.now() - startTimeRef.current;
|
||||
startTimeRef.current = Date.now();
|
||||
|
||||
if (sessionId) {
|
||||
try {
|
||||
await saveLearningAnswer(sessionId, questionId, answer, timeTaken, isFinalAnswer, usedHint);
|
||||
} catch (e) {
|
||||
console.error('Error saving answer:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnswerSelect = async (questionId: string, optionKey: string) => {
|
||||
setSelectedAnswers(prev => ({ ...prev, [questionId]: optionKey }));
|
||||
// 🔧 ИСПРАВЛЕНО: обычный ответ — isFinalAnswer=true, hintUsed=false
|
||||
await saveAnswer(questionId, optionKey, true, false);
|
||||
};
|
||||
|
||||
const handleTextInputChange = async (questionId: string, value: string) => {
|
||||
setTextInputValue(value);
|
||||
setSelectedAnswers(prev => ({ ...prev, [questionId]: value }));
|
||||
};
|
||||
|
||||
const handleTextInputBlur = async (questionId: string) => {
|
||||
if (textInputValue.trim()) {
|
||||
// 🔧 ИСПРАВЛЕНО: обычный ответ — isFinalAnswer=true, hintUsed=false
|
||||
await saveAnswer(questionId, textInputValue.trim(), true, false);
|
||||
}
|
||||
};
|
||||
|
||||
// 🔧 ИСПРАВЛЕНО: handleHintClick теперь сохраняет ответ с hintUsed=true
|
||||
const handleHintClick = async () => {
|
||||
if (!currentQuestion || hintUsed[currentQuestion.question.question_id]) return;
|
||||
|
||||
setHintUsed(prev => ({ ...prev, [currentQuestion.question.question_id]: true }));
|
||||
setShowHint(true);
|
||||
|
||||
// Сохраняем ответ с подсказкой (правильный ответ, hintUsed=true)
|
||||
const correctOption = currentQuestion.options.find(o => o.is_correct);
|
||||
const correctAnswer = correctOption ? correctOption.option_key : '';
|
||||
// 🔧 ИСПРАВЛЕНО: isFinalAnswer=true, hintUsed=true — система будет знать, что была подсказка
|
||||
await saveAnswer(currentQuestion.question.question_id, correctAnswer, true, true);
|
||||
|
||||
// 🔧 НОВОЕ: также отмечаем ответ как выбранный, чтобы кнопка "Далее" стала активной
|
||||
setSelectedAnswers(prev => ({ ...prev, [currentQuestion.question.question_id]: correctAnswer }));
|
||||
};
|
||||
|
||||
const handleNext = async () => {
|
||||
setShowHint(false);
|
||||
if (currentQuestionIndex < (lesson?.questions.length || 0) - 1) {
|
||||
setCurrentQuestionIndex(prev => prev + 1);
|
||||
setTextInputValue('');
|
||||
startTimeRef.current = Date.now();
|
||||
} else {
|
||||
setIsCompleting(true);
|
||||
if (sessionId) {
|
||||
try {
|
||||
await completeLearningSession(sessionId);
|
||||
} catch (e) {
|
||||
console.error('Error completing session:', e);
|
||||
}
|
||||
}
|
||||
navigate(`/lessons/topic/${topicId}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setShowHint(false);
|
||||
if (currentQuestionIndex > 0) {
|
||||
setCurrentQuestionIndex(prev => prev - 1);
|
||||
setTextInputValue('');
|
||||
startTimeRef.current = Date.now();
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: bgColor,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: subColor,
|
||||
fontFamily: fonts.main,
|
||||
fontSize: '18px',
|
||||
}}>
|
||||
{t('loading')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!lesson) return null;
|
||||
|
||||
const currentQuestion = lesson.questions[currentQuestionIndex];
|
||||
const isLastQuestion = currentQuestionIndex === lesson.questions.length - 1;
|
||||
const isProduction = currentQuestion?.question.question_type === 'production';
|
||||
const isCurrentAnswered = currentQuestion ? !!selectedAnswers[currentQuestion.question.question_id] : false;
|
||||
const isTranslationQuestion = currentQuestion?.question.skill_tag === 'how_are_you';
|
||||
const isHintUsed = currentQuestion ? !!hintUsed[currentQuestion.question.question_id] : false;
|
||||
const correctOption = currentQuestion?.options.find(o => o.is_correct);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: bgColor,
|
||||
color: textColor,
|
||||
fontFamily: fonts.main,
|
||||
padding: '24px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ position: 'fixed', top: '-80px', right: '-60px', width: '260px', height: '260px', borderRadius: '50%', background: brandColor, opacity: 0.18, filter: 'blur(90px)', pointerEvents: 'none', zIndex: 0 }} />
|
||||
|
||||
<style>{`
|
||||
@keyframes fadeInUp {
|
||||
0% { transform: translateY(10px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes optionSelect {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
@keyframes modalFadeIn {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
@keyframes modalSlideUp {
|
||||
0% { transform: translateY(20px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div style={{ position: 'relative', zIndex: 1, display: 'flex', flexDirection: 'column', flex: 1 }}>
|
||||
|
||||
{/* Модалка подсказки */}
|
||||
{showHint && correctOption && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(20,22,35,0.25)', zIndex: 500,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
animation: 'modalFadeIn 0.2s ease', padding: '20px',
|
||||
backdropFilter: 'blur(4px)', WebkitBackdropFilter: 'blur(4px)',
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setShowHint(false); }}
|
||||
>
|
||||
<div style={{
|
||||
...glass({
|
||||
borderRadius: radius.lg,
|
||||
padding: '20px',
|
||||
width: '100%',
|
||||
maxWidth: '360px',
|
||||
boxShadow: '0 24px 60px rgba(31,38,73,0.20), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
}),
|
||||
animation: 'modalSlideUp 0.3s ease',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: 700, color: textColor }}>
|
||||
💡 {t('hintButton')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowHint(false)}
|
||||
style={{
|
||||
...glass({ padding: '6px 12px', borderRadius: radius.sm }),
|
||||
color: textColor, fontSize: '14px', fontWeight: 600, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ padding: '12px', background: `${brandColor}10`, borderRadius: radius.md, marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '13px', color: subColor, marginBottom: '4px' }}>
|
||||
{t('correctOption')}:
|
||||
</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: 700, color: brandColor }}>
|
||||
{correctOption.text_pl}
|
||||
</div>
|
||||
</div>
|
||||
{correctOption.wrong_because && (
|
||||
<div style={{ padding: '12px', background: 'rgba(255,215,0,0.1)', borderRadius: radius.md }}>
|
||||
<div style={{ fontSize: '13px', color: subColor, marginBottom: '4px' }}>
|
||||
{t('whyLabel')}:
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: textColor }}>
|
||||
{correctOption.wrong_because}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowHint(false)}
|
||||
style={{
|
||||
width: '100%', marginTop: '16px', padding: '12px',
|
||||
borderRadius: radius.md, border: 'none',
|
||||
background: `linear-gradient(135deg, ${brandColor}, #27AE60)`,
|
||||
color: '#FFFFFF', fontSize: '14px', fontWeight: 700, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{t('teacherClose')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Заголовок */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
marginBottom: '20px',
|
||||
animation: 'fadeInUp 0.3s ease',
|
||||
}}>
|
||||
<button
|
||||
onClick={() => navigate(`/lessons/topic/${topicId}`)}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '10px 18px',
|
||||
borderRadius: radius.md,
|
||||
}),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.95)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
← {t('back')}
|
||||
</button>
|
||||
<h1 style={{ fontSize: '18px', fontWeight: 700, margin: 0, color: textColor, flex: 1, textAlign: 'center' }}>
|
||||
{getLocalized(lesson.lesson, 'title')}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Счётчик */}
|
||||
<div style={{ textAlign: 'center', marginBottom: '16px', color: subColor, fontSize: '13px' }}>
|
||||
{t('testQuestion')} {currentQuestionIndex + 1} / {lesson.questions.length}
|
||||
</div>
|
||||
|
||||
{/* Объяснение (только на первом вопросе) */}
|
||||
{currentQuestionIndex === 0 && (
|
||||
<div style={{
|
||||
...glass({
|
||||
padding: '16px',
|
||||
borderRadius: radius.lg,
|
||||
marginBottom: '16px',
|
||||
}),
|
||||
animation: 'fadeInUp 0.3s ease 0.1s both',
|
||||
}}>
|
||||
<p style={{ fontSize: '14px', lineHeight: '1.6', margin: 0, color: textColor, whiteSpace: 'pre-line' }}>
|
||||
{getLocalized(lesson.lesson, 'explanation')}
|
||||
</p>
|
||||
{lesson.examples.length > 0 && (
|
||||
<div style={{ marginTop: '12px', padding: '12px', background: `${brandColor}10`, borderRadius: radius.md }}>
|
||||
{lesson.examples.map((example, i) => (
|
||||
<div key={i} style={{ marginBottom: '6px' }}>
|
||||
<div style={{ fontSize: '15px', fontWeight: 700, color: brandColor }}>🇵🇱 {example.text_pl}</div>
|
||||
<div style={{ fontSize: '13px', color: subColor }}>{getLocalized(example, 'text')}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Вопрос */}
|
||||
{currentQuestion && (
|
||||
<div style={{ animation: 'fadeInUp 0.3s ease 0.15s both', flex: 1 }}>
|
||||
<p style={{ fontSize: '16px', fontWeight: 600, color: textColor, marginBottom: '16px' }}>
|
||||
{getLocalized(currentQuestion.question, 'prompt')}
|
||||
</p>
|
||||
|
||||
{isProduction ? (
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={textInputValue}
|
||||
onChange={(e) => handleTextInputChange(currentQuestion.question.question_id, e.target.value)}
|
||||
onBlur={() => handleTextInputBlur(currentQuestion.question.question_id)}
|
||||
placeholder={t('teacherPlaceholder')}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
borderRadius: radius.lg,
|
||||
border: textInputValue.trim() ? `2px solid ${brandColor}` : '1px solid rgba(255,255,255,0.7)',
|
||||
background: textInputValue.trim() ? `${brandColor}10` : cardBg,
|
||||
color: textColor,
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
fontFamily: fonts.main,
|
||||
outline: 'none',
|
||||
textAlign: 'center',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: textInputValue.trim() ? `0 0 15px ${brandColor}30` : '0 2px 8px rgba(31,38,73,0.06)',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = brandColor;
|
||||
e.currentTarget.style.boxShadow = `0 0 15px ${brandColor}30`;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{currentQuestion.options.map((option) => {
|
||||
const isSelected = selectedAnswers[currentQuestion.question.question_id] === option.option_key;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.option_key}
|
||||
onClick={() => handleAnswerSelect(currentQuestion.question.question_id, option.option_key)}
|
||||
style={{
|
||||
padding: '14px 16px',
|
||||
borderRadius: radius.md,
|
||||
border: isSelected ? `2px solid ${brandColor}` : '1px solid rgba(255,255,255,0.7)',
|
||||
background: isSelected ? `${brandColor}15` : cardBg,
|
||||
color: textColor,
|
||||
fontSize: '15px',
|
||||
fontWeight: isSelected ? 700 : 500,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
boxShadow: isSelected ? `0 0 10px ${brandColor}30` : '0 2px 8px rgba(31,38,73,0.06)',
|
||||
transition: 'all 0.2s ease',
|
||||
animation: isSelected ? 'optionSelect 0.3s ease' : 'none',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.97)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
<span style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
border: isSelected ? `2px solid ${brandColor}` : '2px solid rgba(0,0,0,0.2)',
|
||||
background: isSelected ? brandColor : 'transparent',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
{isSelected && <span style={{ width: '8px', height: '8px', borderRadius: '50%', background: '#FFFFFF' }} />}
|
||||
</span>
|
||||
<span>
|
||||
{isTranslationQuestion
|
||||
? getLocalized(option, 'text')
|
||||
: option.text_pl}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Кнопка подсказки */}
|
||||
<div style={{ marginTop: '12px', animation: 'fadeInUp 0.3s ease 0.2s both' }}>
|
||||
<button
|
||||
onClick={handleHintClick}
|
||||
disabled={isHintUsed}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
borderRadius: radius.md,
|
||||
border: isHintUsed ? '1px solid rgba(0,0,0,0.06)' : `1px solid ${brandColor}30`,
|
||||
background: isHintUsed ? 'rgba(0,0,0,0.03)' : `${brandColor}10`,
|
||||
color: isHintUsed ? subColor : brandColor,
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
cursor: isHintUsed ? 'default' : 'pointer',
|
||||
opacity: isHintUsed ? 0.5 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{isHintUsed ? `🔒 ${t('hintUsed')}` : `💡 ${t('hintButton')}`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Кнопки навигации */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
marginTop: '20px',
|
||||
animation: 'fadeInUp 0.3s ease 0.3s both',
|
||||
}}>
|
||||
<button
|
||||
onClick={handleBack}
|
||||
disabled={currentQuestionIndex === 0 || isCompleting}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '14px 20px',
|
||||
borderRadius: radius.md,
|
||||
}),
|
||||
color: currentQuestionIndex === 0 ? subColor : textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: currentQuestionIndex === 0 ? 'default' : 'pointer',
|
||||
opacity: currentQuestionIndex === 0 ? 0.5 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
← {t('back')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={!isCurrentAnswered || isCompleting}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '14px 20px',
|
||||
borderRadius: radius.md,
|
||||
border: 'none',
|
||||
background: isCurrentAnswered
|
||||
? `linear-gradient(135deg, ${brandColor}, #27AE60)`
|
||||
: 'rgba(0,0,0,0.1)',
|
||||
color: '#FFFFFF',
|
||||
fontSize: '14px',
|
||||
fontWeight: 700,
|
||||
cursor: isCurrentAnswered ? 'pointer' : 'default',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: isCurrentAnswered ? `0 4px 15px ${brandColor}40` : 'none',
|
||||
opacity: isCompleting ? 0.7 : 1,
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
if (isCurrentAnswered && !isCompleting) {
|
||||
e.currentTarget.style.transform = 'scale(0.96)';
|
||||
}
|
||||
}}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
{isCompleting
|
||||
? t('testChecking')
|
||||
: isLastQuestion
|
||||
? t('testFinish')
|
||||
: `${t('next')} →`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LessonViewPage;
|
||||
@@ -0,0 +1,293 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLanguage } from '../i18n/LanguageContext';
|
||||
import { fonts, radius } from '../styles/theme';
|
||||
import { getLearningLevels, getLearningTopics, getLearningProgress } from '../api';
|
||||
|
||||
interface LearningLevel {
|
||||
code: string;
|
||||
name_pl: string;
|
||||
name_ru: string;
|
||||
name_uk: string;
|
||||
name_en: string;
|
||||
}
|
||||
|
||||
interface LearningTopic {
|
||||
topic_id: string;
|
||||
level_code: string;
|
||||
order_number: number;
|
||||
name_pl: string;
|
||||
name_ru: string;
|
||||
name_uk: string;
|
||||
name_en: string;
|
||||
lessons_completed?: number;
|
||||
test_completed?: boolean;
|
||||
test_score?: number;
|
||||
topic_mastery?: number;
|
||||
}
|
||||
|
||||
const LessonsPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { language } = useLanguage();
|
||||
const [levels, setLevels] = useState<LearningLevel[]>([]);
|
||||
const [selectedLevel, setSelectedLevel] = useState<string | null>(null);
|
||||
const [topics, setTopics] = useState<LearningTopic[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const brandColor = '#2ECC71';
|
||||
const textColor = '#1A1A2E';
|
||||
const subColor = '#6B7280';
|
||||
const bgColor = '#F4F5F7';
|
||||
const cardBg = 'rgba(255,255,255,0.55)';
|
||||
|
||||
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
||||
background: cardBg,
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 8px 32px rgba(31,38,73,0.10), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
const getLevelName = (level: LearningLevel): string => {
|
||||
const map: Record<string, string> = { pl: level.name_pl, ru: level.name_ru, uk: level.name_uk, en: level.name_en };
|
||||
return map[language] || level.name_en;
|
||||
};
|
||||
|
||||
const getTopicName = (topic: LearningTopic): string => {
|
||||
const map: Record<string, string> = { pl: topic.name_pl, ru: topic.name_ru, uk: topic.name_uk, en: topic.name_en };
|
||||
return map[language] || topic.name_en;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
getLearningLevels()
|
||||
.then((data: any) => {
|
||||
setLevels(data.levels || []);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleLevelSelect = useCallback(async (levelCode: string) => {
|
||||
setSelectedLevel(levelCode);
|
||||
setLoading(true);
|
||||
try {
|
||||
const progressData = await getLearningProgress();
|
||||
const topicsData = await getLearningTopics(levelCode);
|
||||
|
||||
// Объединяем прогресс с темами
|
||||
const progressMap: Record<string, any> = {};
|
||||
if (progressData.progress) {
|
||||
progressData.progress.forEach((p: any) => {
|
||||
progressMap[p.topic_id] = p;
|
||||
});
|
||||
}
|
||||
|
||||
const mergedTopics = (topicsData.topics || []).map((topic: any) => ({
|
||||
...topic,
|
||||
lessons_completed: progressMap[topic.topic_id]?.lessons_completed || 0,
|
||||
test_completed: progressMap[topic.topic_id]?.test_completed || false,
|
||||
test_score: progressMap[topic.topic_id]?.test_score,
|
||||
topic_mastery: progressMap[topic.topic_id]?.topic_mastery,
|
||||
}));
|
||||
|
||||
setTopics(mergedTopics);
|
||||
} catch (e) {
|
||||
console.error('Error loading topics:', e);
|
||||
setTopics([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: bgColor,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: subColor,
|
||||
fontFamily: fonts.main,
|
||||
fontSize: '18px',
|
||||
}}>
|
||||
{language === 'pl' ? 'Ładowanie...' : language === 'ru' ? 'Загрузка...' : language === 'uk' ? 'Завантаження...' : 'Loading...'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: bgColor,
|
||||
color: textColor,
|
||||
fontFamily: fonts.main,
|
||||
padding: '24px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ position: 'fixed', top: '-80px', right: '-60px', width: '260px', height: '260px', borderRadius: '50%', background: brandColor, opacity: 0.18, filter: 'blur(90px)', pointerEvents: 'none', zIndex: 0 }} />
|
||||
<div style={{ position: 'fixed', bottom: '-50px', left: '-40px', width: '240px', height: '240px', borderRadius: '50%', background: '#4FA9E8', opacity: 0.14, filter: 'blur(80px)', pointerEvents: 'none', zIndex: 0 }} />
|
||||
|
||||
<style>{`
|
||||
@keyframes fadeInUp {
|
||||
0% { transform: translateY(10px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div style={{ position: 'relative', zIndex: 1, display: 'flex', flexDirection: 'column', flex: 1 }}>
|
||||
|
||||
{/* Заголовок */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
marginBottom: '24px',
|
||||
animation: 'fadeInUp 0.3s ease',
|
||||
}}>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (selectedLevel) {
|
||||
setSelectedLevel(null);
|
||||
} else {
|
||||
navigate('/menu');
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '10px 18px',
|
||||
borderRadius: radius.md,
|
||||
}),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.95)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
← {language === 'pl' ? 'Powrót' : language === 'ru' ? 'Назад' : language === 'uk' ? 'Назад' : 'Back'}
|
||||
</button>
|
||||
<h1 style={{ fontSize: '26px', fontWeight: 900, margin: 0, color: textColor }}>
|
||||
📚 {language === 'pl' ? 'Lekcje' : language === 'ru' ? 'Уроки' : language === 'uk' ? 'Уроки' : 'Lessons'}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{!selectedLevel ? (
|
||||
/* Выбор уровня */
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
animation: 'fadeInUp 0.3s ease 0.1s both',
|
||||
}}>
|
||||
<p style={{ textAlign: 'center', fontSize: '15px', color: subColor, marginBottom: '8px' }}>
|
||||
{language === 'pl' ? 'Wybierz poziom' : language === 'ru' ? 'Выберите уровень' : language === 'uk' ? 'Виберіть рівень' : 'Select level'}
|
||||
</p>
|
||||
{levels.map((level, index) => (
|
||||
<button
|
||||
key={level.code}
|
||||
onClick={() => handleLevelSelect(level.code)}
|
||||
style={{
|
||||
padding: '18px',
|
||||
borderRadius: radius.lg,
|
||||
border: 'none',
|
||||
background: `linear-gradient(135deg, ${brandColor}, #27AE60)`,
|
||||
color: '#FFFFFF',
|
||||
fontSize: '18px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
boxShadow: `0 4px 15px ${brandColor}40`,
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
animation: `fadeInUp 0.3s ease ${0.1 + index * 0.05}s both`,
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.96)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
<span style={{ fontSize: '22px', fontWeight: 800 }}>{level.code}</span>
|
||||
<span style={{ fontSize: '14px', opacity: 0.9 }}>{getLevelName(level)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* Список тем */
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
animation: 'fadeInUp 0.3s ease 0.1s both',
|
||||
}}>
|
||||
<p style={{ textAlign: 'center', fontSize: '15px', color: brandColor, fontWeight: 700, marginBottom: '8px' }}>
|
||||
{selectedLevel}
|
||||
</p>
|
||||
{topics.length === 0 ? (
|
||||
<p style={{ textAlign: 'center', color: subColor }}>
|
||||
{language === 'pl' ? 'Brak tematów' : language === 'ru' ? 'Нет тем' : language === 'uk' ? 'Немає тем' : 'No topics'}
|
||||
</p>
|
||||
) : (
|
||||
topics.map((topic, index) => {
|
||||
const isCompleted = topic.test_completed;
|
||||
const isUnlocked = index === 0 || topics[index - 1]?.test_completed;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={topic.topic_id}
|
||||
onClick={() => {
|
||||
if (isUnlocked) {
|
||||
navigate(`/lessons/topic/${topic.topic_id}`);
|
||||
}
|
||||
}}
|
||||
disabled={!isUnlocked}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '16px',
|
||||
borderRadius: radius.lg,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
}),
|
||||
opacity: isUnlocked ? 1 : 0.5,
|
||||
cursor: isUnlocked ? 'pointer' : 'default',
|
||||
animation: `fadeInUp 0.3s ease ${0.1 + index * 0.05}s both`,
|
||||
border: isCompleted ? `2px solid ${brandColor}` : '1px solid rgba(255,255,255,0.7)',
|
||||
}}
|
||||
onTouchStart={(e) => { if (isUnlocked) e.currentTarget.style.transform = 'scale(0.97)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
<span style={{ fontSize: '24px', flexShrink: 0 }}>
|
||||
{isCompleted ? '✅' : isUnlocked ? '📖' : '🔒'}
|
||||
</span>
|
||||
<div style={{ flex: 1, textAlign: 'left' }}>
|
||||
<div style={{ fontSize: '15px', fontWeight: 700, color: textColor }}>
|
||||
{index + 1}. {getTopicName(topic)}
|
||||
</div>
|
||||
{isCompleted && topic.topic_mastery && (
|
||||
<div style={{ fontSize: '12px', color: brandColor, marginTop: '2px' }}>
|
||||
{language === 'pl' ? 'Wynik' : language === 'ru' ? 'Результат' : language === 'uk' ? 'Результат' : 'Score'}: {topic.topic_mastery}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isUnlocked && !isCompleted && (
|
||||
<span style={{ fontSize: '18px', color: brandColor }}>→</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LessonsPage;
|
||||
@@ -0,0 +1,863 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLanguage } from '../i18n/LanguageContext';
|
||||
import { fonts, radius } from '../styles/theme';
|
||||
import { fetchProgress, getLeaderboard, setUserLevel, getUnreadCount, getUserUnreadCount, getIsAdmin, registerUser } from '../api';
|
||||
import { getUserId } from '../telegram';
|
||||
import SupportChatModal from '../components/SupportChatModal';
|
||||
import OnboardingModal from '../components/OnboardingModal';
|
||||
import HighlightsCarousel from '../components/HighlightsCarousel';
|
||||
import FeedModal, { type FeedSection } from '../components/FeedModal';
|
||||
|
||||
// 🔧 ОБНОВЛЕНО (18.09.2026): единый FeedModal заменил 4 отдельные модалки.
|
||||
// Тип SlideId теперь — алиас на FeedSection, чтобы не было конфликта типов.
|
||||
type SlideId = FeedSection;
|
||||
|
||||
const MenuPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { t, language, setLang } = useLanguage();
|
||||
const [nextLevel, setNextLevel] = useState(1);
|
||||
const [totalScore, setTotalScore] = useState(0);
|
||||
const [rank, setRank] = useState<number | null>(null);
|
||||
const [pressing, setPressing] = useState<string | null>(null);
|
||||
const [showLangModal, setShowLangModal] = useState(false);
|
||||
const [showLevelModal, setShowLevelModal] = useState(false);
|
||||
const [showGamesModal, setShowGamesModal] = useState(false);
|
||||
const [showSupportModal, setShowSupportModal] = useState(false);
|
||||
const [showOnboarding, setShowOnboarding] = useState(false);
|
||||
const [currentLevel, setCurrentLevel] = useState('A1');
|
||||
const [isAdmin, setIsAdmin] = useState(() => {
|
||||
// Начальное значение из localStorage, чтобы не мигало при загрузке
|
||||
return localStorage.getItem('filwords_is_admin') === '1';
|
||||
});
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
// 🔧 Активная модалка карусели (единый FeedModal)
|
||||
const [activeSlideModal, setActiveSlideModal] = useState<SlideId | null>(null);
|
||||
|
||||
const touchStartY = useRef(0);
|
||||
const touchStartX = useRef(0);
|
||||
const touchMoved = useRef(false);
|
||||
const SCROLL_THRESHOLD = 8;
|
||||
|
||||
const brandColor = '#2ECC71';
|
||||
const helpColor = '#E74C3C';
|
||||
|
||||
// 🔧 ОБНОВЛЕНО: убраны неиспользуемые цвета pronunciation / typing,
|
||||
// потому что кнопки перенесены в меню учителя.
|
||||
const palette: Record<string, string> = {
|
||||
play: brandColor,
|
||||
bubble: '#4FA9E8',
|
||||
sentence: '#A970D6',
|
||||
wordquiz: '#E8933F',
|
||||
leaderboard: '#E8B93F',
|
||||
teacher: '#8B5CF6',
|
||||
support: '#FF6B6B',
|
||||
dashboard: '#E8B93F',
|
||||
};
|
||||
|
||||
const languages = [
|
||||
{ code: 'pl', name: 'Polski', flag: '🇵🇱', short: 'PL' },
|
||||
{ code: 'ru', name: 'Русский', flag: '🇷🇺', short: 'RU' },
|
||||
{ code: 'uk', name: 'Українська', flag: '🇺🇦', short: 'UA' },
|
||||
{ code: 'en', name: 'English', flag: '🇬🇧', short: 'EN' },
|
||||
];
|
||||
|
||||
const currentLang = languages.find(l => l.code === language) || languages[0];
|
||||
|
||||
const getSupportLabel = (): string => {
|
||||
const labels: Record<string, string> = {
|
||||
pl: 'Wsparcie',
|
||||
ru: 'Поддержка',
|
||||
uk: 'Підтримка',
|
||||
en: 'Support',
|
||||
};
|
||||
return labels[language] || labels.en;
|
||||
};
|
||||
|
||||
const getDashboardLabel = (): string => {
|
||||
const labels: Record<string, string> = {
|
||||
pl: 'Panel',
|
||||
ru: 'Дашборд',
|
||||
uk: 'Дашборд',
|
||||
en: 'Dashboard',
|
||||
};
|
||||
return labels[language] || labels.en;
|
||||
};
|
||||
|
||||
const makeButtonHandlers = useCallback((buttonName: string, action: () => void) => ({
|
||||
onTouchStart: (e: React.TouchEvent) => {
|
||||
touchStartY.current = e.touches[0].clientY;
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
touchMoved.current = false;
|
||||
setPressing(buttonName);
|
||||
},
|
||||
onTouchMove: (e: React.TouchEvent) => {
|
||||
const deltaY = Math.abs(e.touches[0].clientY - touchStartY.current);
|
||||
const deltaX = Math.abs(e.touches[0].clientX - touchStartX.current);
|
||||
if (deltaY > SCROLL_THRESHOLD || deltaX > SCROLL_THRESHOLD) {
|
||||
touchMoved.current = true;
|
||||
setPressing(null);
|
||||
}
|
||||
},
|
||||
onTouchEnd: () => {
|
||||
setPressing(null);
|
||||
if (!touchMoved.current) {
|
||||
action();
|
||||
}
|
||||
},
|
||||
onMouseDown: () => setPressing(buttonName),
|
||||
onMouseUp: () => { setPressing(null); action(); },
|
||||
}), []);
|
||||
|
||||
useEffect(() => {
|
||||
const myId = getUserId();
|
||||
|
||||
registerUser()
|
||||
.then((data) => {
|
||||
if (typeof data.is_admin === 'boolean') {
|
||||
setIsAdmin(data.is_admin);
|
||||
localStorage.setItem('filwords_is_admin', data.is_admin ? '1' : '0');
|
||||
} else {
|
||||
getIsAdmin()
|
||||
.then((adminData) => {
|
||||
setIsAdmin(adminData.is_admin);
|
||||
localStorage.setItem('filwords_is_admin', adminData.is_admin ? '1' : '0');
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
fetchProgress()
|
||||
.then((data) => {
|
||||
setNextLevel(data.last_completed_level + 1);
|
||||
setTotalScore(data.total_score || 0);
|
||||
if (data.current_level) {
|
||||
setCurrentLevel(data.current_level);
|
||||
localStorage.setItem('filwords_level', data.current_level);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setNextLevel(1);
|
||||
setTotalScore(0);
|
||||
});
|
||||
|
||||
getLeaderboard(100)
|
||||
.then((data) => {
|
||||
const leaderboard = data.leaderboard || [];
|
||||
const myEntry = leaderboard.find((entry: any) =>
|
||||
entry.telegram_id === myId
|
||||
);
|
||||
if (myEntry) {
|
||||
setRank(myEntry.rank);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
const savedLevel = localStorage.getItem('filwords_level');
|
||||
if (savedLevel) {
|
||||
setCurrentLevel(savedLevel);
|
||||
}
|
||||
|
||||
const langSaved = localStorage.getItem('filwords_lang');
|
||||
const onboardingDone = localStorage.getItem('filwords_onboarding_done');
|
||||
if (langSaved && !onboardingDone) {
|
||||
setTimeout(() => setShowOnboarding(true), 500);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshUnreadCount = useCallback(() => {
|
||||
if (isAdmin) {
|
||||
getUnreadCount()
|
||||
.then((data) => setUnreadCount(data.unread_count || 0))
|
||||
.catch(() => {});
|
||||
} else {
|
||||
getUserUnreadCount()
|
||||
.then((data) => setUnreadCount(data.unread_count || 0))
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [isAdmin]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshUnreadCount();
|
||||
|
||||
if (isAdmin) return;
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
if (!showSupportModal) {
|
||||
refreshUnreadCount();
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [isAdmin, showSupportModal, refreshUnreadCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdmin) return;
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible' && !showSupportModal) {
|
||||
refreshUnreadCount();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [isAdmin, showSupportModal, refreshUnreadCount]);
|
||||
|
||||
const handleLevelSelect = (level: string) => {
|
||||
setCurrentLevel(level);
|
||||
localStorage.setItem('filwords_level', level);
|
||||
setUserLevel(level).catch(() => {});
|
||||
setShowLevelModal(false);
|
||||
};
|
||||
|
||||
const handleOpenDashboard = () => {
|
||||
window.dispatchEvent(new Event('open-dashboard'));
|
||||
};
|
||||
|
||||
const handleOnboardingComplete = () => {
|
||||
localStorage.setItem('filwords_onboarding_done', '1');
|
||||
};
|
||||
|
||||
// 🔧 Обработчик клика по слайду карусели — открывает FeedModal с нужной секцией
|
||||
const handleSlideClick = (slideId: SlideId) => {
|
||||
if (window.Telegram?.WebApp?.HapticFeedback) {
|
||||
window.Telegram.WebApp.HapticFeedback.impactOccurred('light');
|
||||
}
|
||||
setActiveSlideModal(slideId);
|
||||
};
|
||||
|
||||
// 🔧 Обработчик клика по кнопке "очки + рейтинг"
|
||||
const handleScoreClick = () => {
|
||||
if (window.Telegram?.WebApp?.HapticFeedback) {
|
||||
window.Telegram.WebApp.HapticFeedback.impactOccurred('light');
|
||||
}
|
||||
navigate('/leaderboard');
|
||||
};
|
||||
|
||||
// 🔧 ОБНОВЛЕНО: убраны typingHandlers и pronunciationHandlers —
|
||||
// эти разделы теперь живут в меню учителя (☰ → Произношение / Печать).
|
||||
const teacherHandlers = makeButtonHandlers('teacher', () => navigate('/teacher'));
|
||||
const gamesHandlers = makeButtonHandlers('games', () => setShowGamesModal(true));
|
||||
const leaderboardHandlers = makeButtonHandlers('leaderboard', () => navigate('/leaderboard'));
|
||||
const supportHandlers = makeButtonHandlers('support', () => setShowSupportModal(true));
|
||||
const dashboardHandlers = makeButtonHandlers('dashboard', handleOpenDashboard);
|
||||
const onboardingHandlers = makeButtonHandlers('onboarding', () => setShowOnboarding(true));
|
||||
|
||||
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
||||
background: 'rgba(255,255,255,0.55)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 8px 32px rgba(31,38,73,0.10), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
const getButtonStyle = (name: string, isLarge = false): React.CSSProperties => {
|
||||
const accent = palette[name] || brandColor;
|
||||
const isPressed = pressing === name;
|
||||
return {
|
||||
padding: isLarge ? '22px' : '18px',
|
||||
borderRadius: radius.lg,
|
||||
border: isPressed ? `1px solid ${accent}80` : '1px solid rgba(255,255,255,0.7)',
|
||||
background: isPressed
|
||||
? `linear-gradient(180deg, ${accent}E6, ${accent}CC)`
|
||||
: 'rgba(255,255,255,0.55)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
color: isPressed ? '#12271C' : '#1A1A2E',
|
||||
fontSize: isLarge ? '18px' : '16px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
boxShadow: isPressed
|
||||
? `0 8px 24px ${accent}50, inset 0 1px 0 rgba(255,255,255,0.6)`
|
||||
: '0 6px 20px rgba(31,38,73,0.08), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
transform: isPressed ? 'scale(0.97)' : 'scale(1)',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '10px',
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: '#F4F5F7',
|
||||
color: '#1A1A2E',
|
||||
fontFamily: fonts.main,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '24px',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ position: 'fixed', top: '-120px', left: '-90px', width: '320px', height: '320px', borderRadius: '50%', background: palette.teacher, opacity: 0.25, filter: 'blur(90px)', pointerEvents: 'none', zIndex: 0 }} />
|
||||
<div style={{ position: 'fixed', top: '100px', right: '-110px', width: '300px', height: '300px', borderRadius: '50%', background: palette.bubble, opacity: 0.26, filter: 'blur(100px)', pointerEvents: 'none', zIndex: 0 }} />
|
||||
<div style={{ position: 'fixed', bottom: '140px', left: '-80px', width: '280px', height: '280px', borderRadius: '50%', background: palette.sentence, opacity: 0.22, filter: 'blur(95px)', pointerEvents: 'none', zIndex: 0 }} />
|
||||
<div style={{ position: 'fixed', bottom: '-90px', right: '-40px', width: '260px', height: '260px', borderRadius: '50%', background: palette.wordquiz, opacity: 0.22, filter: 'blur(90px)', pointerEvents: 'none', zIndex: 0 }} />
|
||||
|
||||
<style>{`
|
||||
@keyframes fadeInUp {
|
||||
0% { transform: translateY(10px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes modalFadeIn {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
@keyframes modalSlideUp {
|
||||
0% { transform: translateY(20px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes badgePulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.2); }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div style={{ position: 'relative', zIndex: 1, display: 'flex', flexDirection: 'column', flex: 1 }}>
|
||||
|
||||
{/* ============ МОДАЛКИ ============ */}
|
||||
|
||||
{/* Онбординг */}
|
||||
<OnboardingModal
|
||||
isOpen={showOnboarding}
|
||||
onClose={() => setShowOnboarding(false)}
|
||||
onComplete={handleOnboardingComplete}
|
||||
/>
|
||||
|
||||
{/* 🔧 ЕДИНЫЙ FeedModal — заменяет 4 старые модалки карусели */}
|
||||
<FeedModal
|
||||
isOpen={activeSlideModal !== null}
|
||||
onClose={() => setActiveSlideModal(null)}
|
||||
section={activeSlideModal || 'news'}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
|
||||
{/* Модальное окно выбора языка */}
|
||||
{showLangModal && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(20,22,35,0.25)', zIndex: 500,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
animation: 'modalFadeIn 0.2s ease', padding: '20px',
|
||||
backdropFilter: 'blur(4px)', WebkitBackdropFilter: 'blur(4px)',
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setShowLangModal(false); }}
|
||||
>
|
||||
<div style={{
|
||||
...glass({
|
||||
borderRadius: radius.lg,
|
||||
padding: '20px',
|
||||
width: '100%',
|
||||
maxWidth: '340px',
|
||||
boxShadow: '0 24px 60px rgba(31,38,73,0.20), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
}),
|
||||
animation: 'modalSlideUp 0.3s ease',
|
||||
}}>
|
||||
<h3 style={{
|
||||
margin: '0 0 16px 0',
|
||||
fontSize: '18px',
|
||||
fontWeight: 700,
|
||||
textAlign: 'center',
|
||||
color: '#1A1A2E',
|
||||
}}>
|
||||
{t('selectLanguage')}
|
||||
</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{languages.map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => { setLang(lang.code); setShowLangModal(false); }}
|
||||
style={{
|
||||
padding: '14px 18px',
|
||||
borderRadius: radius.md,
|
||||
border: `2px solid ${lang.code === language ? brandColor : 'rgba(0,0,0,0.06)'}`,
|
||||
background: lang.code === language ? `${brandColor}18` : 'rgba(255,255,255,0.6)',
|
||||
color: '#1A1A2E',
|
||||
fontSize: '16px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
boxShadow: lang.code === language ? `0 0 0 3px ${brandColor}15` : 'none',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '24px' }}>{lang.flag}</span>
|
||||
<span>{lang.name}</span>
|
||||
{lang.code === language && (
|
||||
<span style={{ marginLeft: 'auto', color: brandColor, fontWeight: 800 }}>✓</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Модальное окно выбора уровня */}
|
||||
{showLevelModal && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(20,22,35,0.25)', zIndex: 500,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
animation: 'modalFadeIn 0.2s ease', padding: '20px',
|
||||
backdropFilter: 'blur(4px)', WebkitBackdropFilter: 'blur(4px)',
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setShowLevelModal(false); }}
|
||||
>
|
||||
<div style={{
|
||||
...glass({
|
||||
borderRadius: radius.lg,
|
||||
padding: '20px',
|
||||
width: '100%',
|
||||
maxWidth: '340px',
|
||||
boxShadow: '0 24px 60px rgba(31,38,73,0.20), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
}),
|
||||
animation: 'modalSlideUp 0.3s ease',
|
||||
}}>
|
||||
<h3 style={{
|
||||
margin: '0 0 16px 0',
|
||||
fontSize: '18px',
|
||||
fontWeight: 700,
|
||||
textAlign: 'center',
|
||||
color: '#1A1A2E',
|
||||
}}>
|
||||
{t('selectLevel')}
|
||||
</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{[
|
||||
{ code: 'A1', descKey: 'levelA1Name', available: true },
|
||||
{ code: 'A2', descKey: 'levelA2Name', available: true },
|
||||
{ code: 'B1', descKey: 'levelB1Name', available: true },
|
||||
{ code: 'B2', descKey: 'levelB2Name', available: false },
|
||||
{ code: 'C1', descKey: 'levelC1Name', available: false },
|
||||
{ code: 'C2', descKey: 'levelC2Name', available: false },
|
||||
].map((level) => (
|
||||
<button
|
||||
key={level.code}
|
||||
onClick={() => level.available && handleLevelSelect(level.code)}
|
||||
disabled={!level.available}
|
||||
style={{
|
||||
padding: '14px 18px',
|
||||
borderRadius: radius.md,
|
||||
border: `2px solid ${level.code === currentLevel ? brandColor : level.available ? 'rgba(0,0,0,0.06)' : 'rgba(0,0,0,0.03)'}`,
|
||||
background: level.code === currentLevel ? `${brandColor}18` : level.available ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.03)',
|
||||
color: level.available ? '#1A1A2E' : 'rgba(26,26,46,0.35)',
|
||||
fontSize: '16px',
|
||||
fontWeight: 600,
|
||||
cursor: level.available ? 'pointer' : 'default',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
boxShadow: level.code === currentLevel ? `0 0 0 3px ${brandColor}15` : 'none',
|
||||
transition: 'all 0.2s ease',
|
||||
opacity: level.available ? 1 : 0.7,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '20px', fontWeight: 800, width: '36px' }}>{level.code}</span>
|
||||
<span style={{ fontSize: '13px', opacity: 0.7 }}>
|
||||
{level.available ? t(level.descKey) : `${t(level.descKey)} (${t('comingSoon')})`}
|
||||
</span>
|
||||
{level.available ? (
|
||||
level.code === currentLevel && (
|
||||
<span style={{ marginLeft: 'auto', color: brandColor, fontWeight: 800 }}>✓</span>
|
||||
)
|
||||
) : (
|
||||
<span style={{ marginLeft: 'auto', fontSize: '16px' }}>🔒</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Модальное окно выбора игры */}
|
||||
{showGamesModal && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(20,22,35,0.25)', zIndex: 500,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
animation: 'modalFadeIn 0.2s ease', padding: '20px',
|
||||
backdropFilter: 'blur(4px)', WebkitBackdropFilter: 'blur(4px)',
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setShowGamesModal(false); }}
|
||||
>
|
||||
<div style={{
|
||||
...glass({
|
||||
borderRadius: radius.lg,
|
||||
padding: '20px',
|
||||
width: '100%',
|
||||
maxWidth: '340px',
|
||||
boxShadow: '0 24px 60px rgba(31,38,73,0.20), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
}),
|
||||
animation: 'modalSlideUp 0.3s ease',
|
||||
}}>
|
||||
<h3 style={{
|
||||
margin: '0 0 16px 0',
|
||||
fontSize: '18px',
|
||||
fontWeight: 700,
|
||||
textAlign: 'center',
|
||||
color: '#1A1A2E',
|
||||
}}>
|
||||
{t('games') || 'Games'}
|
||||
</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
<button
|
||||
onClick={() => { setShowGamesModal(false); navigate(`/play/${nextLevel}`); }}
|
||||
style={{
|
||||
padding: '16px 18px',
|
||||
borderRadius: radius.md,
|
||||
border: '2px solid rgba(0,0,0,0.06)',
|
||||
background: 'rgba(255,255,255,0.6)',
|
||||
color: '#1A1A2E',
|
||||
fontSize: '16px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '24px' }}>🎮</span>
|
||||
<span>{t('play')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowGamesModal(false); navigate('/bubble'); }}
|
||||
style={{
|
||||
padding: '16px 18px',
|
||||
borderRadius: radius.md,
|
||||
border: '2px solid rgba(0,0,0,0.06)',
|
||||
background: 'rgba(255,255,255,0.6)',
|
||||
color: '#1A1A2E',
|
||||
fontSize: '16px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '24px' }}>🫧</span>
|
||||
<span>{t('bubbleWords') || 'Bubbles'}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowGamesModal(false); navigate('/sentence'); }}
|
||||
style={{
|
||||
padding: '16px 18px',
|
||||
borderRadius: radius.md,
|
||||
border: '2px solid rgba(0,0,0,0.06)',
|
||||
background: 'rgba(255,255,255,0.6)',
|
||||
color: '#1A1A2E',
|
||||
fontSize: '16px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '24px' }}>📝</span>
|
||||
<span>{t('sentenceBuilder')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowGamesModal(false); navigate('/wordquiz'); }}
|
||||
style={{
|
||||
padding: '16px 18px',
|
||||
borderRadius: radius.md,
|
||||
border: '2px solid rgba(0,0,0,0.06)',
|
||||
background: 'rgba(255,255,255,0.6)',
|
||||
color: '#1A1A2E',
|
||||
fontSize: '16px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '24px' }}>🎯</span>
|
||||
<span>{t('wordQuiz') || 'Выбери слово'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Модальное окно поддержки */}
|
||||
{showSupportModal && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(20,22,35,0.25)', zIndex: 500,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
animation: 'modalFadeIn 0.2s ease', padding: '20px',
|
||||
backdropFilter: 'blur(4px)', WebkitBackdropFilter: 'blur(4px)',
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setShowSupportModal(false); }}
|
||||
>
|
||||
<SupportChatModal
|
||||
isOpen={showSupportModal}
|
||||
onClose={() => {
|
||||
setShowSupportModal(false);
|
||||
refreshUnreadCount();
|
||||
}}
|
||||
isAdmin={isAdmin}
|
||||
onUnreadChange={refreshUnreadCount}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ============ ВЕРХНИЙ УГОЛ ============ */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: '32px',
|
||||
animation: 'fadeInUp 0.3s ease',
|
||||
}}>
|
||||
|
||||
{/* ? — Онбординг */}
|
||||
<button
|
||||
{...onboardingHandlers}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '10px 14px',
|
||||
borderRadius: radius.md,
|
||||
}),
|
||||
color: helpColor,
|
||||
fontSize: '16px',
|
||||
fontWeight: 800,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.2s ease',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title={
|
||||
language === 'ru' ? 'Как пользоваться приложением' :
|
||||
language === 'uk' ? 'Як користуватися додатком' :
|
||||
language === 'pl' ? 'Jak korzystać z aplikacji' :
|
||||
'How to use the app'
|
||||
}
|
||||
>
|
||||
?
|
||||
</button>
|
||||
|
||||
{/* 💯 Очки + Рейтинг */}
|
||||
<button
|
||||
onClick={handleScoreClick}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '10px 14px',
|
||||
borderRadius: radius.md,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}),
|
||||
color: '#1A1A2E',
|
||||
fontSize: '14px',
|
||||
fontWeight: 800,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(0.95)';
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '14px' }}>💯</span>
|
||||
<span style={{ color: '#1A1A2E' }}>
|
||||
{totalScore.toLocaleString()}
|
||||
</span>
|
||||
{rank !== null && rank <= 50 && (
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
color: '#B8860B',
|
||||
background: '#E8B93F15',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '6px',
|
||||
marginLeft: '2px',
|
||||
}}>
|
||||
#{rank}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* A1 ▼ — Уровень */}
|
||||
<button
|
||||
onClick={() => setShowLevelModal(true)}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '10px 14px',
|
||||
borderRadius: radius.md,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}),
|
||||
color: '#4B5563',
|
||||
fontSize: '14px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(0.95)';
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '14px', color: brandColor, fontWeight: 800 }}>
|
||||
{currentLevel}
|
||||
</span>
|
||||
<span style={{ fontSize: '10px', opacity: 0.5 }}>▼</span>
|
||||
</button>
|
||||
|
||||
{/* 🇵🇱 PL ▼ — Язык */}
|
||||
<button
|
||||
onClick={() => setShowLangModal(true)}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '10px 14px',
|
||||
borderRadius: radius.md,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}),
|
||||
color: '#4B5563',
|
||||
fontSize: '14px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(0.95)';
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '16px' }}>{currentLang.flag}</span>
|
||||
<span>{currentLang.short}</span>
|
||||
<span style={{ fontSize: '10px', opacity: 0.5 }}>▼</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ============ ЛОГОТИП ============ */}
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
marginBottom: '24px',
|
||||
animation: 'fadeInUp 0.3s ease 0.05s both',
|
||||
}}>
|
||||
<h1 style={{
|
||||
fontSize: '46px',
|
||||
fontWeight: 900,
|
||||
margin: 0,
|
||||
letterSpacing: '-1px',
|
||||
color: '#1A1A2E',
|
||||
}}>
|
||||
Filwords{' '}
|
||||
<span style={{
|
||||
color: brandColor,
|
||||
textShadow: `0 0 20px ${brandColor}40`,
|
||||
}}>PL</span>
|
||||
</h1>
|
||||
<p style={{ color: '#6B7280', fontSize: '16px', marginTop: '8px' }}>
|
||||
Ucz się polskiego przez grę.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ============ КАРУСЕЛЬ ============ */}
|
||||
<HighlightsCarousel onSlideClick={handleSlideClick} />
|
||||
|
||||
{/* ============ КНОПКИ ============ */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}>
|
||||
<button {...teacherHandlers} style={getButtonStyle('teacher', true)}>
|
||||
<span>🎓</span> {t('teacher')}
|
||||
</button>
|
||||
<button {...gamesHandlers} style={getButtonStyle('play')}>
|
||||
<span>🎮</span> {t('games') || 'Games'}
|
||||
</button>
|
||||
<button {...leaderboardHandlers} style={getButtonStyle('leaderboard')}>
|
||||
🏆 {t('leaderboard')}
|
||||
</button>
|
||||
|
||||
{/* Кнопка Поддержка */}
|
||||
<button {...supportHandlers} style={{
|
||||
...getButtonStyle('support'),
|
||||
position: 'relative',
|
||||
}}>
|
||||
<span>📮</span> {getSupportLabel()}
|
||||
{unreadCount > 0 && (
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
top: '8px',
|
||||
right: '10px',
|
||||
minWidth: '22px',
|
||||
height: '22px',
|
||||
borderRadius: '11px',
|
||||
background: '#E74C3C',
|
||||
color: '#FFFFFF',
|
||||
fontSize: '12px',
|
||||
fontWeight: 800,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0 6px',
|
||||
animation: 'badgePulse 2s ease infinite',
|
||||
boxShadow: '0 2px 8px rgba(231,76,60,0.4)',
|
||||
}}>
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Кнопка Дашборд — только для админов */}
|
||||
{isAdmin && (
|
||||
<button {...dashboardHandlers} style={getButtonStyle('dashboard')}>
|
||||
<span>📊</span> {getDashboardLabel()}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: '24px', color: '#9CA3AF', fontSize: '12px' }}>
|
||||
Filwords PL © 2026
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MenuPage;
|
||||
@@ -0,0 +1,626 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useLanguage } from '../i18n/LanguageContext';
|
||||
import { useLocalized } from '../hooks/useLocalized';
|
||||
import { fonts, radius } from '../styles/theme';
|
||||
import { saveLearningAnswer, completeLearningSession } from '../api';
|
||||
|
||||
interface PracticeQuestion {
|
||||
question: {
|
||||
question_id: string;
|
||||
prompt_pl: string;
|
||||
prompt_ru: string;
|
||||
prompt_uk: string;
|
||||
prompt_en: string;
|
||||
question_type: string;
|
||||
skill_tag: string;
|
||||
difficulty: number;
|
||||
};
|
||||
options: Array<{
|
||||
option_key: string;
|
||||
text_pl: string;
|
||||
text_ru: string;
|
||||
text_uk: string;
|
||||
text_en: string;
|
||||
is_correct: boolean;
|
||||
wrong_because: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface PracticeExplanation {
|
||||
skill_tag: string;
|
||||
name: string;
|
||||
explanation_pl?: string;
|
||||
explanation_ru?: string;
|
||||
explanation_uk?: string;
|
||||
explanation_en?: string;
|
||||
}
|
||||
|
||||
const PracticeViewPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { t } = useLanguage();
|
||||
const getLocalized = useLocalized();
|
||||
const [questions, setQuestions] = useState<PracticeQuestion[]>([]);
|
||||
const [explanations, setExplanations] = useState<PracticeExplanation[]>([]);
|
||||
const [sessionId, setSessionId] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [selectedAnswers, setSelectedAnswers] = useState<Record<string, string>>({});
|
||||
const [textInputValue, setTextInputValue] = useState('');
|
||||
const [isCompleting, setIsCompleting] = useState(false);
|
||||
const [hintUsed, setHintUsed] = useState<Record<string, boolean>>({});
|
||||
const [showHint, setShowHint] = useState(false);
|
||||
const [practiceResult, setPracticeResult] = useState<{ total: number; correct_count: number } | null>(null);
|
||||
const startTimeRef = useRef<number>(Date.now());
|
||||
|
||||
const brandColor = '#2ECC71';
|
||||
const textColor = '#1A1A2E';
|
||||
const subColor = '#6B7280';
|
||||
const bgColor = '#F4F5F7';
|
||||
const cardBg = 'rgba(255,255,255,0.55)';
|
||||
|
||||
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
||||
background: cardBg,
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 8px 32px rgba(31,38,73,0.10), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const state = location.state as any;
|
||||
if (state && state.sessionId && state.questions) {
|
||||
setSessionId(state.sessionId);
|
||||
setQuestions(state.questions);
|
||||
setExplanations(state.explanations || []);
|
||||
setLoading(false);
|
||||
} else {
|
||||
navigate('/menu');
|
||||
}
|
||||
}, [location.state, navigate]);
|
||||
|
||||
// 🔧 ИСПРАВЛЕНО: saveAnswer теперь принимает isFinalAnswer и usedHint
|
||||
const saveAnswer = async (questionId: string, answer: string, isFinalAnswer: boolean = true, usedHint: boolean = false) => {
|
||||
const timeTaken = Date.now() - startTimeRef.current;
|
||||
startTimeRef.current = Date.now();
|
||||
|
||||
if (sessionId) {
|
||||
try {
|
||||
await saveLearningAnswer(sessionId, questionId, answer, timeTaken, isFinalAnswer, usedHint);
|
||||
} catch (e) {
|
||||
console.error('Error saving answer:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnswerSelect = async (questionId: string, optionKey: string) => {
|
||||
setSelectedAnswers(prev => ({ ...prev, [questionId]: optionKey }));
|
||||
// 🔧 ИСПРАВЛЕНО: обычный ответ — isFinalAnswer=true, hintUsed=false
|
||||
await saveAnswer(questionId, optionKey, true, false);
|
||||
};
|
||||
|
||||
const handleTextInputChange = async (questionId: string, value: string) => {
|
||||
setTextInputValue(value);
|
||||
setSelectedAnswers(prev => ({ ...prev, [questionId]: value }));
|
||||
};
|
||||
|
||||
const handleTextInputBlur = async (questionId: string) => {
|
||||
if (textInputValue.trim()) {
|
||||
// 🔧 ИСПРАВЛЕНО: обычный ответ — isFinalAnswer=true, hintUsed=false
|
||||
await saveAnswer(questionId, textInputValue.trim(), true, false);
|
||||
}
|
||||
};
|
||||
|
||||
// 🔧 ИСПРАВЛЕНО: handleHintClick теперь сохраняет ответ с hintUsed=true
|
||||
const handleHintClick = async () => {
|
||||
if (!currentQuestion || hintUsed[currentQuestion.question.question_id]) return;
|
||||
|
||||
setHintUsed(prev => ({ ...prev, [currentQuestion.question.question_id]: true }));
|
||||
setShowHint(true);
|
||||
|
||||
const correctOption = currentQuestion.options.find(o => o.is_correct);
|
||||
const correctAnswer = correctOption ? correctOption.option_key : '';
|
||||
// 🔧 ИСПРАВЛЕНО: isFinalAnswer=true, hintUsed=true — система будет знать, что была подсказка
|
||||
await saveAnswer(currentQuestion.question.question_id, correctAnswer, true, true);
|
||||
|
||||
// 🔧 НОВОЕ: также отмечаем ответ как выбранный, чтобы кнопка "Далее" стала активной
|
||||
setSelectedAnswers(prev => ({ ...prev, [currentQuestion.question.question_id]: correctAnswer }));
|
||||
};
|
||||
|
||||
const handleNext = async () => {
|
||||
setShowHint(false);
|
||||
if (currentIndex < questions.length - 1) {
|
||||
setCurrentIndex(prev => prev + 1);
|
||||
setTextInputValue('');
|
||||
startTimeRef.current = Date.now();
|
||||
} else {
|
||||
setIsCompleting(true);
|
||||
|
||||
const currentQ = questions[currentIndex];
|
||||
const answer = selectedAnswers[currentQ.question.question_id];
|
||||
|
||||
if (answer && sessionId) {
|
||||
const timeTaken = Date.now() - startTimeRef.current;
|
||||
// 🔧 ИСПРАВЛЕНО: проверяем, была ли подсказка для этого вопроса
|
||||
const wasHintUsed = !!hintUsed[currentQ.question.question_id];
|
||||
try {
|
||||
await saveLearningAnswer(sessionId, currentQ.question.question_id, answer, timeTaken, true, wasHintUsed);
|
||||
} catch (e) {
|
||||
console.error('Error saving final answer:', e);
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
if (sessionId) {
|
||||
try {
|
||||
const result = await completeLearningSession(sessionId);
|
||||
setPracticeResult(result);
|
||||
} catch (e) {
|
||||
console.error('Error completing practice:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setShowHint(false);
|
||||
if (currentIndex > 0) {
|
||||
setCurrentIndex(prev => prev - 1);
|
||||
setTextInputValue('');
|
||||
startTimeRef.current = Date.now();
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: bgColor,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: subColor,
|
||||
fontFamily: fonts.main,
|
||||
fontSize: '18px',
|
||||
}}>
|
||||
{t('loading')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Экран результатов
|
||||
if (practiceResult) {
|
||||
const total = practiceResult.total || 0;
|
||||
const correct = practiceResult.correct_count || 0;
|
||||
const percentage = total > 0 ? Math.round((correct / total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: bgColor,
|
||||
color: textColor,
|
||||
fontFamily: fonts.main,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '24px',
|
||||
}}>
|
||||
<div style={{ fontSize: '64px', marginBottom: '16px' }}>
|
||||
{percentage >= 70 ? '🎉' : percentage >= 50 ? '👍' : '💪'}
|
||||
</div>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 900, margin: '0 0 8px 0', textAlign: 'center' }}>
|
||||
{t('practiceCompleted')}
|
||||
</h1>
|
||||
<div style={{
|
||||
...glass({
|
||||
padding: '20px',
|
||||
borderRadius: radius.lg,
|
||||
textAlign: 'center',
|
||||
marginTop: '16px',
|
||||
width: '100%',
|
||||
maxWidth: '300px',
|
||||
}),
|
||||
}}>
|
||||
<div style={{ fontSize: '48px', fontWeight: 900, color: percentage >= 70 ? '#2ECC71' : percentage >= 50 ? '#F39C12' : '#E74C3C' }}>
|
||||
{percentage}%
|
||||
</div>
|
||||
<div style={{ fontSize: '18px', color: subColor, marginTop: '8px', fontWeight: 600 }}>
|
||||
{correct} / {total}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: subColor, marginTop: '4px' }}>
|
||||
{t('practiceCorrectAnswers')}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/menu')}
|
||||
style={{
|
||||
marginTop: '20px',
|
||||
padding: '16px 32px',
|
||||
borderRadius: radius.lg,
|
||||
border: 'none',
|
||||
background: `linear-gradient(135deg, ${brandColor}, #27AE60)`,
|
||||
color: '#FFFFFF',
|
||||
fontSize: '16px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
boxShadow: `0 4px 15px ${brandColor}40`,
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.96)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
{t('backToMenu')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (questions.length === 0) return null;
|
||||
|
||||
const currentQuestion = questions[currentIndex];
|
||||
const isLastQuestion = currentIndex === questions.length - 1;
|
||||
const isProduction = currentQuestion?.question.question_type === 'production';
|
||||
const isCurrentAnswered = currentQuestion ? !!selectedAnswers[currentQuestion.question.question_id] : false;
|
||||
const isHintUsed = currentQuestion ? !!hintUsed[currentQuestion.question.question_id] : false;
|
||||
const correctOption = currentQuestion?.options.find(o => o.is_correct);
|
||||
|
||||
const currentSkillTag = currentQuestion?.question.skill_tag;
|
||||
const currentExplanation = explanations.find(e => e.skill_tag === currentSkillTag);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: bgColor,
|
||||
color: textColor,
|
||||
fontFamily: fonts.main,
|
||||
padding: '24px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ position: 'fixed', top: '-80px', right: '-60px', width: '260px', height: '260px', borderRadius: '50%', background: brandColor, opacity: 0.18, filter: 'blur(90px)', pointerEvents: 'none', zIndex: 0 }} />
|
||||
|
||||
<style>{`
|
||||
@keyframes fadeInUp {
|
||||
0% { transform: translateY(10px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes optionSelect {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
@keyframes modalFadeIn {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
@keyframes modalSlideUp {
|
||||
0% { transform: translateY(20px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div style={{ position: 'relative', zIndex: 1, display: 'flex', flexDirection: 'column', flex: 1 }}>
|
||||
|
||||
{/* Модалка подсказки */}
|
||||
{showHint && correctOption && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(20,22,35,0.25)', zIndex: 500,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
animation: 'modalFadeIn 0.2s ease', padding: '20px',
|
||||
backdropFilter: 'blur(4px)', WebkitBackdropFilter: 'blur(4px)',
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setShowHint(false); }}
|
||||
>
|
||||
<div style={{
|
||||
...glass({
|
||||
borderRadius: radius.lg,
|
||||
padding: '20px',
|
||||
width: '100%',
|
||||
maxWidth: '360px',
|
||||
boxShadow: '0 24px 60px rgba(31,38,73,0.20), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
}),
|
||||
animation: 'modalSlideUp 0.3s ease',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: 700, color: textColor }}>
|
||||
💡 {t('hintButton')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowHint(false)}
|
||||
style={{
|
||||
...glass({ padding: '6px 12px', borderRadius: radius.sm }),
|
||||
color: textColor, fontSize: '14px', fontWeight: 600, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ padding: '12px', background: `${brandColor}10`, borderRadius: radius.md, marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '13px', color: subColor, marginBottom: '4px' }}>
|
||||
{t('correctOption')}:
|
||||
</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: 700, color: brandColor }}>
|
||||
{correctOption.text_pl}
|
||||
</div>
|
||||
</div>
|
||||
{correctOption.wrong_because && (
|
||||
<div style={{ padding: '12px', background: 'rgba(255,215,0,0.1)', borderRadius: radius.md }}>
|
||||
<div style={{ fontSize: '13px', color: subColor, marginBottom: '4px' }}>
|
||||
{t('whyLabel')}:
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: textColor }}>
|
||||
{correctOption.wrong_because}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowHint(false)}
|
||||
style={{
|
||||
width: '100%', marginTop: '16px', padding: '12px',
|
||||
borderRadius: radius.md, border: 'none',
|
||||
background: `linear-gradient(135deg, ${brandColor}, #27AE60)`,
|
||||
color: '#FFFFFF', fontSize: '14px', fontWeight: 700, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{t('teacherClose')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Заголовок */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
marginBottom: '20px',
|
||||
animation: 'fadeInUp 0.3s ease',
|
||||
}}>
|
||||
<button
|
||||
onClick={() => navigate('/menu')}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '10px 18px',
|
||||
borderRadius: radius.md,
|
||||
}),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.95)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
← {t('back')}
|
||||
</button>
|
||||
<h1 style={{ fontSize: '18px', fontWeight: 700, margin: 0, color: textColor, flex: 1, textAlign: 'center' }}>
|
||||
💪 {t('practiceTitle')}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Счётчик */}
|
||||
<div style={{ textAlign: 'center', marginBottom: '12px', color: subColor, fontSize: '13px' }}>
|
||||
{t('testQuestion')} {currentIndex + 1} / {questions.length}
|
||||
</div>
|
||||
|
||||
{/* Прогресс-бар */}
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '4px',
|
||||
background: 'rgba(0,0,0,0.06)',
|
||||
borderRadius: '2px',
|
||||
marginBottom: '16px',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${((currentIndex + 1) / questions.length) * 100}%`,
|
||||
height: '100%',
|
||||
background: `linear-gradient(90deg, ${brandColor}, #27AE60)`,
|
||||
borderRadius: '2px',
|
||||
transition: 'width 0.3s ease',
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* Объяснение навыка (если есть) */}
|
||||
{currentExplanation && (
|
||||
<div style={{
|
||||
...glass({
|
||||
padding: '14px',
|
||||
borderRadius: radius.lg,
|
||||
marginBottom: '12px',
|
||||
}),
|
||||
border: `1px solid ${brandColor}30`,
|
||||
animation: 'fadeInUp 0.3s ease 0.1s both',
|
||||
}}>
|
||||
<div style={{ fontSize: '13px', fontWeight: 700, color: brandColor, marginBottom: '6px' }}>
|
||||
📖 {currentExplanation.name}
|
||||
</div>
|
||||
{currentExplanation.explanation_ru && (
|
||||
<div style={{ fontSize: '12px', color: subColor, lineHeight: '1.5' }}>
|
||||
{getLocalized(currentExplanation, 'explanation')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Вопрос */}
|
||||
{currentQuestion && (
|
||||
<div style={{ animation: 'fadeInUp 0.3s ease 0.15s both', flex: 1 }}>
|
||||
<p style={{ fontSize: '16px', fontWeight: 600, color: textColor, marginBottom: '16px' }}>
|
||||
{getLocalized(currentQuestion.question, 'prompt')}
|
||||
</p>
|
||||
|
||||
{isProduction ? (
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={textInputValue}
|
||||
onChange={(e) => handleTextInputChange(currentQuestion.question.question_id, e.target.value)}
|
||||
onBlur={() => handleTextInputBlur(currentQuestion.question.question_id)}
|
||||
placeholder={t('teacherPlaceholder')}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
borderRadius: radius.lg,
|
||||
border: textInputValue.trim() ? `2px solid ${brandColor}` : '1px solid rgba(255,255,255,0.7)',
|
||||
background: textInputValue.trim() ? `${brandColor}10` : cardBg,
|
||||
color: textColor,
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
fontFamily: fonts.main,
|
||||
outline: 'none',
|
||||
textAlign: 'center',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: textInputValue.trim() ? `0 0 15px ${brandColor}30` : '0 2px 8px rgba(31,38,73,0.06)',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = brandColor;
|
||||
e.currentTarget.style.boxShadow = `0 0 15px ${brandColor}30`;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{currentQuestion.options.map((option) => {
|
||||
const isSelected = selectedAnswers[currentQuestion.question.question_id] === option.option_key;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.option_key}
|
||||
onClick={() => handleAnswerSelect(currentQuestion.question.question_id, option.option_key)}
|
||||
style={{
|
||||
padding: '14px 16px',
|
||||
borderRadius: radius.md,
|
||||
border: isSelected ? `2px solid ${brandColor}` : '1px solid rgba(255,255,255,0.7)',
|
||||
background: isSelected ? `${brandColor}15` : cardBg,
|
||||
color: textColor,
|
||||
fontSize: '15px',
|
||||
fontWeight: isSelected ? 700 : 500,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
boxShadow: isSelected ? `0 0 10px ${brandColor}30` : '0 2px 8px rgba(31,38,73,0.06)',
|
||||
transition: 'all 0.2s ease',
|
||||
animation: isSelected ? 'optionSelect 0.3s ease' : 'none',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.97)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
<span style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
border: isSelected ? `2px solid ${brandColor}` : '2px solid rgba(0,0,0,0.2)',
|
||||
background: isSelected ? brandColor : 'transparent',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
{isSelected && <span style={{ width: '8px', height: '8px', borderRadius: '50%', background: '#FFFFFF' }} />}
|
||||
</span>
|
||||
<span>{option.text_pl}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Кнопка подсказки */}
|
||||
<div style={{ marginTop: '12px', animation: 'fadeInUp 0.3s ease 0.2s both' }}>
|
||||
<button
|
||||
onClick={handleHintClick}
|
||||
disabled={isHintUsed}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
borderRadius: radius.md,
|
||||
border: isHintUsed ? '1px solid rgba(0,0,0,0.06)' : `1px solid ${brandColor}30`,
|
||||
background: isHintUsed ? 'rgba(0,0,0,0.03)' : `${brandColor}10`,
|
||||
color: isHintUsed ? subColor : brandColor,
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
cursor: isHintUsed ? 'default' : 'pointer',
|
||||
opacity: isHintUsed ? 0.5 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{isHintUsed ? `🔒 ${t('hintUsed')}` : `💡 ${t('hintButton')}`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Кнопки навигации */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
marginTop: '20px',
|
||||
animation: 'fadeInUp 0.3s ease 0.3s both',
|
||||
}}>
|
||||
<button
|
||||
onClick={handleBack}
|
||||
disabled={currentIndex === 0 || isCompleting}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '14px 20px',
|
||||
borderRadius: radius.md,
|
||||
}),
|
||||
color: currentIndex === 0 ? subColor : textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: currentIndex === 0 ? 'default' : 'pointer',
|
||||
opacity: currentIndex === 0 ? 0.5 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
← {t('back')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={!isCurrentAnswered || isCompleting}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '14px 20px',
|
||||
borderRadius: radius.md,
|
||||
border: 'none',
|
||||
background: isCurrentAnswered
|
||||
? `linear-gradient(135deg, ${brandColor}, #27AE60)`
|
||||
: 'rgba(0,0,0,0.1)',
|
||||
color: '#FFFFFF',
|
||||
fontSize: '14px',
|
||||
fontWeight: 700,
|
||||
cursor: isCurrentAnswered ? 'pointer' : 'default',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: isCurrentAnswered ? `0 4px 15px ${brandColor}40` : 'none',
|
||||
opacity: isCompleting ? 0.7 : 1,
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
if (isCurrentAnswered && !isCompleting) {
|
||||
e.currentTarget.style.transform = 'scale(0.96)';
|
||||
}
|
||||
}}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
{isCompleting
|
||||
? t('loading')
|
||||
: isLastQuestion
|
||||
? t('testFinish')
|
||||
: `${t('next')} →`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PracticeViewPage;
|
||||
@@ -0,0 +1,463 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLanguage } from '../i18n/LanguageContext';
|
||||
import { fonts, radius } from '../styles/theme';
|
||||
import { getNextPronunciationWord, trackPronunciationOpened, getTTS } from '../api';
|
||||
import RulesModal from '../components/RulesModal';
|
||||
import { useAchievements } from '../hooks/useAchievements';
|
||||
|
||||
interface PronunciationWord {
|
||||
word_pl: string;
|
||||
translation: string;
|
||||
level: string;
|
||||
}
|
||||
|
||||
const levelColors: Record<string, string> = {
|
||||
A1: '#2ECC71',
|
||||
A2: '#27AE60',
|
||||
B1: '#F39C12',
|
||||
B2: '#E67E22',
|
||||
C1: '#E74C3C',
|
||||
C2: '#C0392B',
|
||||
};
|
||||
|
||||
const PronunciationPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useLanguage();
|
||||
const [currentWord, setCurrentWord] = useState<PronunciationWord | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [showRules, setShowRules] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
const rulesShownRef = useRef(false);
|
||||
const trackedRef = useRef(false);
|
||||
|
||||
const { activeAchievement, showAchievement } = useAchievements();
|
||||
|
||||
const brandColor = '#2ECC71';
|
||||
const textColor = '#1A1A2E';
|
||||
const subColor = '#6B7280';
|
||||
const bgColor = '#F4F5F7';
|
||||
const cardBg = 'rgba(255,255,255,0.55)';
|
||||
|
||||
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
||||
background: cardBg,
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 8px 32px rgba(31,38,73,0.10), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
const playWord = async (word: string) => {
|
||||
// Останавливаем предыдущее аудио
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
}
|
||||
|
||||
// Останавливаем Web Speech API, если он что-то говорит
|
||||
if ('speechSynthesis' in window) {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
|
||||
setIsPlaying(true);
|
||||
|
||||
try {
|
||||
// Пробуем Edge TTS через бэкенд
|
||||
const audioBlob = await getTTS(word, 'male');
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
|
||||
const audio = new Audio(audioUrl);
|
||||
audioRef.current = audio;
|
||||
|
||||
audio.onended = () => {
|
||||
if (isMountedRef.current) setIsPlaying(false);
|
||||
};
|
||||
|
||||
audio.onerror = () => {
|
||||
// Edge TTS не сработал — пробуем Web Speech API
|
||||
playWebSpeech(word);
|
||||
};
|
||||
|
||||
await audio.play();
|
||||
|
||||
} catch (e) {
|
||||
console.error('Edge TTS error:', e);
|
||||
// Edge TTS не сработал — пробуем Web Speech API
|
||||
playWebSpeech(word);
|
||||
}
|
||||
};
|
||||
|
||||
const playWebSpeech = (word: string) => {
|
||||
if ('speechSynthesis' in window) {
|
||||
const utterance = new SpeechSynthesisUtterance(word);
|
||||
utterance.lang = 'pl-PL';
|
||||
utterance.onend = () => {
|
||||
if (isMountedRef.current) setIsPlaying(false);
|
||||
};
|
||||
utterance.onerror = () => {
|
||||
if (isMountedRef.current) setIsPlaying(false);
|
||||
};
|
||||
window.speechSynthesis.speak(utterance);
|
||||
} else {
|
||||
if (isMountedRef.current) setIsPlaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchWord = useCallback(() => {
|
||||
setLoading(true);
|
||||
getNextPronunciationWord()
|
||||
.then((data: any) => {
|
||||
if (isMountedRef.current) {
|
||||
setCurrentWord(data);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (isMountedRef.current) {
|
||||
navigate('/menu');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (isMountedRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
// Автопоказ правил при первом входе
|
||||
if (!rulesShownRef.current) {
|
||||
rulesShownRef.current = true;
|
||||
const rulesSeen = localStorage.getItem('filwords_rules_pronunciation');
|
||||
if (!rulesSeen) {
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
setShowRules(true);
|
||||
localStorage.setItem('filwords_rules_pronunciation', '1');
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Трекинг открытия Произношения
|
||||
if (!trackedRef.current) {
|
||||
trackedRef.current = true;
|
||||
trackPronunciationOpened()
|
||||
.then((data: any) => {
|
||||
if (data?.new_achievements && data.new_achievements.length > 0) {
|
||||
data.new_achievements.forEach((ach: any) => {
|
||||
showAchievement({
|
||||
id: ach.code,
|
||||
icon: ach.icon,
|
||||
name: ach.name,
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
fetchWord();
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
}
|
||||
// Останавливаем Web Speech API при уходе со страницы
|
||||
if ('speechSynthesis' in window) {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
};
|
||||
}, [fetchWord, showAchievement]);
|
||||
|
||||
const levelColor = currentWord?.level ? levelColors[currentWord.level] || brandColor : brandColor;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: bgColor,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: subColor,
|
||||
fontFamily: fonts.main,
|
||||
fontSize: '18px',
|
||||
}}>
|
||||
{t('loading')}...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: bgColor,
|
||||
color: textColor,
|
||||
fontFamily: fonts.main,
|
||||
padding: '24px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ position: 'fixed', top: '-80px', right: '-60px', width: '260px', height: '260px', borderRadius: '50%', background: levelColor, opacity: 0.18, filter: 'blur(90px)', pointerEvents: 'none', zIndex: 0 }} />
|
||||
<div style={{ position: 'fixed', bottom: '-50px', left: '-40px', width: '240px', height: '240px', borderRadius: '50%', background: brandColor, opacity: 0.14, filter: 'blur(80px)', pointerEvents: 'none', zIndex: 0 }} />
|
||||
|
||||
<style>{`
|
||||
@keyframes fadeInUp {
|
||||
0% { transform: translateY(10px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
}
|
||||
@keyframes soundWave {
|
||||
0%, 100% { height: 8px; }
|
||||
50% { height: 24px; }
|
||||
}
|
||||
@keyframes achievementSlideDown {
|
||||
0% { transform: translateX(-50%) translateY(-80px); opacity: 0; }
|
||||
100% { transform: translateX(-50%) translateY(0); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div style={{ position: 'relative', zIndex: 1, display: 'flex', flexDirection: 'column', flex: 1 }}>
|
||||
|
||||
<RulesModal
|
||||
isOpen={showRules}
|
||||
onClose={() => setShowRules(false)}
|
||||
title={t('pronunciationRulesTitle') || '🔊 Pronunciation Rules'}
|
||||
content={t('pronunciationRulesContent') || 'Rules content loading...'}
|
||||
/>
|
||||
|
||||
{activeAchievement && (
|
||||
<div style={{
|
||||
position: 'fixed', top: '16px', left: '50%', transform: 'translateX(-50%)', zIndex: 900,
|
||||
background: 'linear-gradient(135deg, #FFD700, #FFA500)',
|
||||
color: '#FFFFFF', padding: '14px 20px', borderRadius: radius.lg,
|
||||
boxShadow: '0 8px 30px rgba(255, 215, 0, 0.5)',
|
||||
display: 'flex', alignItems: 'center', gap: '12px',
|
||||
minWidth: '250px', maxWidth: '90vw', animation: 'achievementSlideDown 0.4s ease',
|
||||
}}>
|
||||
<span style={{ fontSize: '28px', flexShrink: 0 }}>{activeAchievement.icon}</span>
|
||||
<div>
|
||||
<div style={{ fontSize: '11px', fontWeight: 600, opacity: 0.9, letterSpacing: '1px', textTransform: 'uppercase' }}>
|
||||
🏆 {t('achievement') || 'Achievement'}
|
||||
</div>
|
||||
<div style={{ fontSize: '15px', fontWeight: 800, marginTop: '2px' }}>
|
||||
{activeAchievement.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Верхняя панель */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '24px',
|
||||
animation: 'fadeInUp 0.3s ease',
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<button
|
||||
onClick={() => navigate('/menu')}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '10px 18px',
|
||||
borderRadius: radius.md,
|
||||
}),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.95)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
← {t('back')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowRules(true)}
|
||||
style={{
|
||||
...glass({
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: radius.md,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}),
|
||||
color: textColor,
|
||||
fontSize: '18px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s ease',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.95)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 800,
|
||||
color: levelColor,
|
||||
background: `${levelColor}15`,
|
||||
padding: '4px 10px',
|
||||
borderRadius: radius.sm,
|
||||
border: `1px solid ${levelColor}40`,
|
||||
}}>
|
||||
{currentWord?.level}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Заголовок */}
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
marginBottom: '32px',
|
||||
animation: 'fadeInUp 0.3s ease 0.05s both',
|
||||
}}>
|
||||
<h1 style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: 900,
|
||||
margin: 0,
|
||||
color: textColor,
|
||||
}}>
|
||||
🔊 {t('pronunciation') || 'Pronunciation'}
|
||||
</h1>
|
||||
<p style={{
|
||||
color: subColor,
|
||||
fontSize: '14px',
|
||||
marginTop: '8px',
|
||||
}}>
|
||||
{t('tapToListen') || 'Tap the word to listen'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Карточка со словом */}
|
||||
{currentWord && (
|
||||
<div style={{
|
||||
flex: 1,
|
||||
...glass({
|
||||
borderRadius: radius.lg,
|
||||
padding: '32px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: '20px',
|
||||
minHeight: '300px',
|
||||
}),
|
||||
border: `2px solid ${levelColor}30`,
|
||||
animation: 'fadeInUp 0.3s ease 0.1s both',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => playWord(currentWord.word_pl)}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.98)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
{/* Иконка динамика */}
|
||||
<div style={{
|
||||
width: '80px',
|
||||
height: '80px',
|
||||
borderRadius: '50%',
|
||||
background: `${levelColor}20`,
|
||||
border: `2px solid ${levelColor}50`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
animation: isPlaying ? 'pulse 1s ease infinite' : 'none',
|
||||
}}>
|
||||
<span style={{ fontSize: '36px' }}>🔊</span>
|
||||
</div>
|
||||
|
||||
{/* Польское слово */}
|
||||
<div style={{
|
||||
fontSize: '36px',
|
||||
fontWeight: 900,
|
||||
color: textColor,
|
||||
textAlign: 'center',
|
||||
letterSpacing: '1px',
|
||||
}}>
|
||||
{currentWord.word_pl}
|
||||
</div>
|
||||
|
||||
{/* Разделитель */}
|
||||
<div style={{
|
||||
width: '60px',
|
||||
height: '2px',
|
||||
background: `${levelColor}40`,
|
||||
borderRadius: '1px',
|
||||
}} />
|
||||
|
||||
{/* Перевод */}
|
||||
<div style={{
|
||||
fontSize: '18px',
|
||||
color: subColor,
|
||||
textAlign: 'center',
|
||||
fontWeight: 400,
|
||||
}}>
|
||||
{currentWord.translation}
|
||||
</div>
|
||||
|
||||
{/* Индикатор воспроизведения */}
|
||||
{isPlaying && (
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'flex-end', height: '24px' }}>
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
width: '4px',
|
||||
height: '8px',
|
||||
background: levelColor,
|
||||
borderRadius: '2px',
|
||||
animation: `soundWave 0.6s ease infinite ${i * 0.1}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Кнопка Далее */}
|
||||
<button
|
||||
onClick={fetchWord}
|
||||
style={{
|
||||
marginTop: '20px',
|
||||
padding: '16px',
|
||||
borderRadius: radius.lg,
|
||||
border: 'none',
|
||||
background: `linear-gradient(180deg, ${levelColor}E6, ${levelColor}CC)`,
|
||||
color: '#FFFFFF',
|
||||
fontSize: '16px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
boxShadow: `0 8px 24px ${levelColor}50`,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.96)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
{t('next') || 'Next'} →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PronunciationPage;
|
||||
@@ -0,0 +1,998 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLanguage } from '../i18n/LanguageContext';
|
||||
import { fonts, radius } from '../styles/theme';
|
||||
import { getNextSentence, checkSentence, getSentenceHint, fetchProgress } from '../api';
|
||||
import RulesModal from '../components/RulesModal';
|
||||
import { useAchievements } from '../hooks/useAchievements';
|
||||
|
||||
interface SentenceData {
|
||||
id: number;
|
||||
sentence_number: number;
|
||||
theme: string;
|
||||
words: string[];
|
||||
correct_words: string[];
|
||||
translation: string;
|
||||
difficulty: number;
|
||||
tier?: string;
|
||||
}
|
||||
|
||||
const tierLabels: Record<string, Record<string, string>> = {
|
||||
easy: {
|
||||
pl: 'łatwy',
|
||||
ru: 'лёгкий',
|
||||
uk: 'легкий',
|
||||
en: 'easy',
|
||||
},
|
||||
medium: {
|
||||
pl: 'średni',
|
||||
ru: 'средний',
|
||||
uk: 'середній',
|
||||
en: 'medium',
|
||||
},
|
||||
hard: {
|
||||
pl: 'trudny',
|
||||
ru: 'сложный',
|
||||
uk: 'складний',
|
||||
en: 'hard',
|
||||
},
|
||||
};
|
||||
|
||||
const tierColors: Record<string, string> = {
|
||||
easy: '#2ECC71',
|
||||
medium: '#F39C12',
|
||||
hard: '#E74C3C',
|
||||
};
|
||||
|
||||
const SentenceBuilderPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { t, language } = useLanguage();
|
||||
const [sentence, setSentence] = useState<SentenceData | null>(null);
|
||||
const [availableWords, setAvailableWords] = useState<string[]>([]);
|
||||
const [selectedWords, setSelectedWords] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [completed, setCompleted] = useState(false);
|
||||
const [isCorrect, setIsCorrect] = useState<boolean | null>(null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [showRules, setShowRules] = useState(false);
|
||||
const [hintsUsed, setHintsUsed] = useState(0);
|
||||
const [showHintModal, setShowHintModal] = useState(false);
|
||||
const [hintSentence, setHintSentence] = useState<string>('');
|
||||
const [currentLevel, setCurrentLevel] = useState('A1');
|
||||
const [totalScore, setTotalScore] = useState(0);
|
||||
|
||||
const { activeAchievement, showAchievement, resetAchievements } = useAchievements();
|
||||
|
||||
const hintTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
const startTimeRef = useRef<number>(0);
|
||||
const rulesShownRef = useRef(false);
|
||||
|
||||
const brandColor = '#2ECC71';
|
||||
const palette = { sentence: '#A970D6' };
|
||||
const textColor = '#1A1A2E';
|
||||
const subColor = '#6B7280';
|
||||
const bgColor = '#F4F5F7';
|
||||
const cardBg = 'rgba(255,255,255,0.55)';
|
||||
const dangerColor = '#E74C3C';
|
||||
|
||||
const MAX_HINTS = 3;
|
||||
const remainingHints = MAX_HINTS - hintsUsed;
|
||||
|
||||
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
||||
background: cardBg,
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 8px 32px rgba(31,38,73,0.10), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
// Загружаем уровень из localStorage
|
||||
const savedLevel = localStorage.getItem('filwords_level');
|
||||
if (savedLevel) {
|
||||
setCurrentLevel(savedLevel);
|
||||
}
|
||||
|
||||
// Загружаем общий счёт
|
||||
fetchProgress()
|
||||
.then((data: any) => {
|
||||
if (isMountedRef.current) {
|
||||
setTotalScore(data.total_score || 0);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Автопоказ правил при первом входе
|
||||
if (!rulesShownRef.current) {
|
||||
rulesShownRef.current = true;
|
||||
const rulesSeen = localStorage.getItem('filwords_rules_sentence');
|
||||
if (!rulesSeen) {
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
setShowRules(true);
|
||||
localStorage.setItem('filwords_rules_sentence', '1');
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
if (hintTimeoutRef.current) clearTimeout(hintTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchSentence = useCallback(() => {
|
||||
setLoading(true);
|
||||
setCompleted(false);
|
||||
setIsCorrect(null);
|
||||
setSelectedWords([]);
|
||||
setErrorMessage(null);
|
||||
setShowRules(false);
|
||||
setHintsUsed(0);
|
||||
setShowHintModal(false);
|
||||
setHintSentence('');
|
||||
resetAchievements();
|
||||
startTimeRef.current = Date.now();
|
||||
|
||||
getNextSentence()
|
||||
.then((data: any) => {
|
||||
if (data.completed) {
|
||||
setCompleted(true);
|
||||
} else {
|
||||
setSentence(data);
|
||||
setAvailableWords(data.words);
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
console.error('Error fetching sentence:', err);
|
||||
setErrorMessage('Failed to load sentence');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [resetAchievements]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSentence();
|
||||
}, [fetchSentence]);
|
||||
|
||||
const handleWordClick = (word: string, index: number) => {
|
||||
if (isCorrect !== null) return;
|
||||
|
||||
setAvailableWords(prev => prev.filter((_, i) => i !== index));
|
||||
setSelectedWords(prev => [...prev, word]);
|
||||
|
||||
if (window.Telegram?.WebApp?.HapticFeedback) {
|
||||
window.Telegram.WebApp.HapticFeedback.impactOccurred('light');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectedWordClick = (index: number) => {
|
||||
if (isCorrect !== null) return;
|
||||
|
||||
const word = selectedWords[index];
|
||||
setSelectedWords(prev => prev.filter((_, i) => i !== index));
|
||||
setAvailableWords(prev => [...prev, word]);
|
||||
|
||||
if (window.Telegram?.WebApp?.HapticFeedback) {
|
||||
window.Telegram.WebApp.HapticFeedback.impactOccurred('light');
|
||||
}
|
||||
};
|
||||
|
||||
const handleHint = async () => {
|
||||
if (!sentence || hintsUsed >= MAX_HINTS) return;
|
||||
|
||||
try {
|
||||
const data: any = await getSentenceHint(sentence.id);
|
||||
setHintSentence(data.sentence_pl);
|
||||
setHintsUsed(prev => prev + 1);
|
||||
setShowHintModal(true);
|
||||
|
||||
if (hintTimeoutRef.current) clearTimeout(hintTimeoutRef.current);
|
||||
hintTimeoutRef.current = setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
setShowHintModal(false);
|
||||
}
|
||||
}, 2500);
|
||||
} catch (err) {
|
||||
console.error('Error getting hint:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheck = async () => {
|
||||
if (!sentence || selectedWords.length === 0) return;
|
||||
|
||||
setChecking(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const completionTime = Math.floor((Date.now() - startTimeRef.current) / 1000);
|
||||
|
||||
const data: any = await checkSentence(sentence.id, selectedWords, completionTime);
|
||||
setIsCorrect(data.correct);
|
||||
|
||||
if (data.correct) {
|
||||
setTotalScore(prev => prev + (data.score || 0));
|
||||
|
||||
if (window.Telegram?.WebApp?.HapticFeedback) {
|
||||
window.Telegram.WebApp.HapticFeedback.impactOccurred('heavy');
|
||||
}
|
||||
|
||||
if (data.new_achievements && data.new_achievements.length > 0) {
|
||||
data.new_achievements.forEach((ach: any) => {
|
||||
showAchievement({
|
||||
id: ach.code,
|
||||
icon: ach.icon,
|
||||
name: ach.name,
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (window.Telegram?.WebApp?.HapticFeedback) {
|
||||
window.Telegram.WebApp.HapticFeedback.impactOccurred('medium');
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
setAvailableWords(sentence.words);
|
||||
setSelectedWords([]);
|
||||
setIsCorrect(null);
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error checking sentence:', err);
|
||||
setErrorMessage(err.message || 'Error checking sentence');
|
||||
setIsCorrect(null);
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (!sentence) return;
|
||||
setAvailableWords(sentence.words);
|
||||
setSelectedWords([]);
|
||||
setIsCorrect(null);
|
||||
setErrorMessage(null);
|
||||
};
|
||||
|
||||
const getTierLabel = (tier: string): string => {
|
||||
return tierLabels[tier]?.[language] || tier;
|
||||
};
|
||||
|
||||
const getHintButtonLabel = (): string => {
|
||||
const labels: Record<string, string> = {
|
||||
pl: 'Pokaż zdanie',
|
||||
ru: 'Показать предложение',
|
||||
uk: 'Показати речення',
|
||||
en: 'Show sentence',
|
||||
};
|
||||
return labels[language] || labels.en;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: bgColor,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: subColor,
|
||||
fontFamily: fonts.main,
|
||||
fontSize: '18px',
|
||||
}}>
|
||||
{t('loading')}...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (completed) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: bgColor,
|
||||
color: textColor,
|
||||
fontFamily: fonts.main,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '24px',
|
||||
}}>
|
||||
<div style={{ fontSize: '64px', marginBottom: '16px' }}>🎉</div>
|
||||
<h1 style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 700,
|
||||
marginBottom: '12px',
|
||||
textAlign: 'center',
|
||||
lineHeight: '1.4',
|
||||
}}>
|
||||
{t('allSentencesCompleted')}
|
||||
</h1>
|
||||
<p style={{
|
||||
fontSize: '14px',
|
||||
color: subColor,
|
||||
marginBottom: '24px',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
Great job! You've completed all available sentences.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/menu')}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '16px 32px',
|
||||
borderRadius: radius.lg,
|
||||
}),
|
||||
color: textColor,
|
||||
fontSize: '16px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(0.95)';
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
← {t('back')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: bgColor,
|
||||
color: textColor,
|
||||
fontFamily: fonts.main,
|
||||
padding: '24px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: '-80px',
|
||||
left: '-60px',
|
||||
width: '260px',
|
||||
height: '260px',
|
||||
borderRadius: '50%',
|
||||
background: palette.sentence,
|
||||
opacity: 0.22,
|
||||
filter: 'blur(90px)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
}} />
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: '-50px',
|
||||
right: '-40px',
|
||||
width: '240px',
|
||||
height: '240px',
|
||||
borderRadius: '50%',
|
||||
background: brandColor,
|
||||
opacity: 0.16,
|
||||
filter: 'blur(80px)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
}} />
|
||||
|
||||
<style>{`
|
||||
@keyframes fadeInUp {
|
||||
0% { transform: translateY(10px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes wordPopIn {
|
||||
0% { transform: scale(0.8); opacity: 0; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-5px); }
|
||||
75% { transform: translateX(5px); }
|
||||
}
|
||||
@keyframes achievementSlideDown {
|
||||
0% { transform: translateX(-50%) translateY(-80px); opacity: 0; }
|
||||
100% { transform: translateX(-50%) translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes hintModalFadeInOut {
|
||||
0% { opacity: 0; transform: translateY(-10px); }
|
||||
10% { opacity: 1; transform: translateY(0); }
|
||||
90% { opacity: 1; transform: translateY(0); }
|
||||
100% { opacity: 0; transform: translateY(-10px); }
|
||||
}
|
||||
@keyframes hintDotActive {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.5); opacity: 0.6; }
|
||||
}
|
||||
@keyframes hintDotUsed {
|
||||
0% { transform: scale(1); opacity: 1; }
|
||||
100% { transform: scale(0); opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div style={{ position: 'relative', zIndex: 1, display: 'flex', flexDirection: 'column', flex: 1 }}>
|
||||
|
||||
<RulesModal
|
||||
isOpen={showRules}
|
||||
onClose={() => setShowRules(false)}
|
||||
title={t('sentenceRulesTitle') || '📝 Sentence Builder Rules'}
|
||||
content={t('sentenceRulesContent') || 'Rules content loading...'}
|
||||
/>
|
||||
|
||||
{showHintModal && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(20,22,35,0.15)',
|
||||
zIndex: 500,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
paddingTop: '25%',
|
||||
animation: 'hintModalFadeInOut 2.5s ease forwards',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
...glass({
|
||||
padding: '20px 28px',
|
||||
borderRadius: radius.lg,
|
||||
}),
|
||||
border: `2px solid ${brandColor}50`,
|
||||
boxShadow: `0 12px 40px rgba(31,38,73,0.15), 0 0 24px ${brandColor}25, inset 0 1px 0 rgba(255,255,255,0.85)`,
|
||||
textAlign: 'center',
|
||||
maxWidth: '90vw',
|
||||
}}>
|
||||
<div style={{ fontSize: '12px', color: brandColor, fontWeight: 700, marginBottom: '10px', letterSpacing: '0.5px', textTransform: 'uppercase' }}>
|
||||
💡 {getHintButtonLabel()}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '17px',
|
||||
fontWeight: 700,
|
||||
color: textColor,
|
||||
letterSpacing: '0.3px',
|
||||
lineHeight: '1.5',
|
||||
}}>
|
||||
{hintSentence}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeAchievement && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: '16px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 900,
|
||||
background: 'linear-gradient(135deg, #FFD700, #FFA500)',
|
||||
color: '#FFFFFF',
|
||||
padding: '14px 20px',
|
||||
borderRadius: radius.lg,
|
||||
boxShadow: '0 8px 30px rgba(255, 215, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
minWidth: '250px',
|
||||
maxWidth: '90vw',
|
||||
animation: 'achievementSlideDown 0.4s ease',
|
||||
}}>
|
||||
<span style={{ fontSize: '28px', flexShrink: 0 }}>{activeAchievement.icon}</span>
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
opacity: 0.9,
|
||||
letterSpacing: '1px',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
🏆 {t('achievement') || 'Achievement'}
|
||||
</div>
|
||||
<div style={{ fontSize: '15px', fontWeight: 800, marginTop: '2px' }}>
|
||||
{activeAchievement.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '24px',
|
||||
animation: 'fadeInUp 0.3s ease',
|
||||
position: 'relative',
|
||||
minHeight: '40px',
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', zIndex: 1 }}>
|
||||
<button
|
||||
onClick={() => navigate('/menu')}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '10px 18px',
|
||||
borderRadius: radius.md,
|
||||
}),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s ease',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.95)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
← {t('back')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowRules(true)}
|
||||
style={{
|
||||
...glass({
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: radius.md,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}),
|
||||
color: textColor,
|
||||
fontSize: '18px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s ease',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.95)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 800,
|
||||
color: brandColor,
|
||||
background: `${brandColor}15`,
|
||||
padding: '4px 8px',
|
||||
borderRadius: radius.sm,
|
||||
border: `1px solid ${brandColor}40`,
|
||||
}}>
|
||||
{currentLevel}
|
||||
</span>
|
||||
{sentence?.tier && (
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
color: '#FFFFFF',
|
||||
padding: '3px 10px',
|
||||
borderRadius: radius.sm,
|
||||
background: tierColors[sentence.tier] || brandColor,
|
||||
}}>
|
||||
{getTierLabel(sentence.tier)}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: '10px', color: subColor, marginBottom: '2px' }}>
|
||||
{t('score')}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: 800, color: brandColor }}>
|
||||
{totalScore.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
...glass({
|
||||
padding: '20px',
|
||||
borderRadius: radius.lg,
|
||||
marginBottom: '20px',
|
||||
}),
|
||||
animation: 'fadeInUp 0.3s ease 0.05s both',
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: subColor,
|
||||
marginBottom: '8px',
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
{t('buildSentence')}
|
||||
</div>
|
||||
{sentence?.translation && (
|
||||
<div style={{
|
||||
fontSize: '15px',
|
||||
color: brandColor,
|
||||
fontWeight: 600,
|
||||
padding: '12px',
|
||||
background: `${brandColor}15`,
|
||||
borderRadius: radius.md,
|
||||
border: `1px solid ${brandColor}30`,
|
||||
lineHeight: '1.5',
|
||||
}}>
|
||||
{sentence.translation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: '20px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
padding: '12px 24px',
|
||||
background: dangerColor,
|
||||
color: '#FFFFFF',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
borderRadius: radius.md,
|
||||
boxShadow: '0 4px 12px rgba(224,78,78,0.4)',
|
||||
zIndex: 100,
|
||||
animation: 'fadeInUp 0.25s ease',
|
||||
}}>
|
||||
❌ {errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCorrect === true && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '20px',
|
||||
marginBottom: '16px',
|
||||
background: `${brandColor}15`,
|
||||
borderRadius: radius.lg,
|
||||
border: `2px solid ${brandColor}40`,
|
||||
animation: 'fadeInUp 0.3s ease',
|
||||
}}>
|
||||
<div style={{ fontSize: '32px', marginBottom: '6px' }}>✅</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 700, color: brandColor, marginBottom: '8px' }}>
|
||||
{t('correctAnswer')}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
color: textColor,
|
||||
fontWeight: 600,
|
||||
padding: '8px',
|
||||
background: cardBg,
|
||||
borderRadius: radius.md,
|
||||
display: 'inline-block',
|
||||
minWidth: '60%',
|
||||
}}>
|
||||
{sentence?.correct_words?.join(' ')}
|
||||
</div>
|
||||
{sentence?.translation && (
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
color: subColor,
|
||||
marginTop: '8px',
|
||||
}}>
|
||||
{sentence.translation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCorrect === false && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '16px',
|
||||
marginBottom: '16px',
|
||||
background: `${dangerColor}10`,
|
||||
borderRadius: radius.lg,
|
||||
border: `2px solid ${dangerColor}40`,
|
||||
animation: 'shake 0.5s ease',
|
||||
}}>
|
||||
<div style={{ fontSize: '18px', fontWeight: 700, color: dangerColor }}>
|
||||
❌ {t('tryAgain')}
|
||||
</div>
|
||||
<div style={{ fontSize: '13px', color: subColor, marginTop: '4px' }}>
|
||||
Words will reset in a moment...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
minHeight: '56px',
|
||||
background: selectedWords.length > 0
|
||||
? `${palette.sentence}10`
|
||||
: 'rgba(0,0,0,0.02)',
|
||||
borderRadius: radius.lg,
|
||||
padding: '14px',
|
||||
marginBottom: '20px',
|
||||
border: `2px dashed ${selectedWords.length > 0 ? palette.sentence + '60' : 'rgba(0,0,0,0.1)'}`,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.3s ease',
|
||||
}}>
|
||||
{selectedWords.length === 0 && (
|
||||
<span style={{ color: subColor, fontSize: '14px', fontStyle: 'italic' }}>
|
||||
{t('tapWords')}
|
||||
</span>
|
||||
)}
|
||||
{selectedWords.map((word, index) => (
|
||||
<button
|
||||
key={`${word}-${index}`}
|
||||
onClick={() => handleSelectedWordClick(index)}
|
||||
disabled={isCorrect !== null}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
borderRadius: radius.md,
|
||||
border: `2px solid ${palette.sentence}`,
|
||||
background: `${palette.sentence}20`,
|
||||
color: palette.sentence,
|
||||
fontSize: '15px',
|
||||
fontWeight: 700,
|
||||
cursor: isCorrect !== null ? 'default' : 'pointer',
|
||||
animation: 'wordPopIn 0.2s ease',
|
||||
transition: 'all 0.15s ease',
|
||||
boxShadow: `0 2px 8px ${palette.sentence}30`,
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
if (isCorrect === null) {
|
||||
e.currentTarget.style.transform = 'scale(0.93)';
|
||||
}
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
if (isCorrect === null) {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{word}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '10px',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '24px',
|
||||
animation: 'fadeInUp 0.3s ease 0.1s both',
|
||||
minHeight: '60px',
|
||||
}}>
|
||||
{availableWords.map((word, index) => (
|
||||
<button
|
||||
key={`${word}-${index}`}
|
||||
onClick={() => handleWordClick(word, index)}
|
||||
disabled={isCorrect !== null}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '12px 18px',
|
||||
borderRadius: radius.md,
|
||||
}),
|
||||
color: textColor,
|
||||
fontSize: '15px',
|
||||
fontWeight: 600,
|
||||
cursor: isCorrect !== null ? 'default' : 'pointer',
|
||||
transition: 'all 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
if (isCorrect === null) {
|
||||
e.currentTarget.style.transform = 'scale(0.93)';
|
||||
e.currentTarget.style.boxShadow = `0 0 12px ${palette.sentence}40`;
|
||||
e.currentTarget.style.borderColor = palette.sentence;
|
||||
}
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
if (isCorrect === null) {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
e.currentTarget.style.boxShadow = '0 6px 20px rgba(31,38,73,0.08)';
|
||||
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.7)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{word}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '20px',
|
||||
animation: 'fadeInUp 0.3s ease 0.15s both',
|
||||
}}>
|
||||
<button
|
||||
onClick={handleHint}
|
||||
disabled={hintsUsed >= MAX_HINTS}
|
||||
style={{
|
||||
flex: 1,
|
||||
maxWidth: '340px',
|
||||
padding: '14px 16px',
|
||||
borderRadius: radius.md,
|
||||
border: hintsUsed >= MAX_HINTS
|
||||
? '1px solid rgba(0,0,0,0.06)'
|
||||
: `1px solid ${brandColor}40`,
|
||||
background: hintsUsed >= MAX_HINTS
|
||||
? 'rgba(0,0,0,0.03)'
|
||||
: `${brandColor}10`,
|
||||
color: hintsUsed >= MAX_HINTS ? subColor : brandColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: hintsUsed >= MAX_HINTS ? 'default' : 'pointer',
|
||||
opacity: hintsUsed >= MAX_HINTS ? 0.6 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
if (hintsUsed < MAX_HINTS) e.currentTarget.style.transform = 'scale(0.96)';
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span>{hintsUsed >= MAX_HINTS ? '🔒' : '💡'}</span>
|
||||
<span>{getHintButtonLabel()}</span>
|
||||
</span>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '6px',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '12px',
|
||||
}}>
|
||||
{Array.from({ length: MAX_HINTS }, (_, i) => {
|
||||
const isActive = i < remainingHints;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
borderRadius: '50%',
|
||||
background: isActive
|
||||
? `radial-gradient(circle at 30% 30%, ${brandColor}, #1a7a3a)`
|
||||
: 'rgba(0,0,0,0.1)',
|
||||
border: isActive
|
||||
? `1px solid ${brandColor}`
|
||||
: '1px solid rgba(0,0,0,0.08)',
|
||||
boxShadow: isActive
|
||||
? `0 0 6px ${brandColor}80, inset 0 1px 0 rgba(255,255,255,0.5)`
|
||||
: 'inset 0 1px 2px rgba(0,0,0,0.1)',
|
||||
animation: isActive
|
||||
? 'hintDotActive 2s ease infinite'
|
||||
: hintsUsed >= MAX_HINTS
|
||||
? 'hintDotUsed 0.4s ease forwards'
|
||||
: 'none',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
fontSize: '10px',
|
||||
color: hintsUsed >= MAX_HINTS ? dangerColor : subColor,
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
{hintsUsed >= MAX_HINTS
|
||||
? (t('sentenceHintUsed') || 'Hint used')
|
||||
: `${remainingHints} ${t('remaining') || 'left'}`}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
justifyContent: 'center',
|
||||
marginTop: 'auto',
|
||||
paddingBottom: '20px',
|
||||
animation: 'fadeInUp 0.3s ease 0.15s both',
|
||||
}}>
|
||||
{isCorrect === true ? (
|
||||
<button
|
||||
onClick={fetchSentence}
|
||||
style={{
|
||||
padding: '16px 32px',
|
||||
borderRadius: radius.lg,
|
||||
border: 'none',
|
||||
background: `linear-gradient(180deg, ${brandColor}E6, ${brandColor}CC)`,
|
||||
color: '#12271C',
|
||||
fontSize: '16px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
boxShadow: `0 8px 24px ${brandColor}50`,
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(0.95)';
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
{t('nextLevel')} →
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={selectedWords.length === 0}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '16px 24px',
|
||||
borderRadius: radius.lg,
|
||||
}),
|
||||
color: selectedWords.length > 0 ? textColor : subColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: selectedWords.length === 0 ? 'default' : 'pointer',
|
||||
opacity: selectedWords.length === 0 ? 0.5 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
if (selectedWords.length > 0) {
|
||||
e.currentTarget.style.transform = 'scale(0.95)';
|
||||
}
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
↩ {t('reset')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCheck}
|
||||
disabled={selectedWords.length === 0 || checking}
|
||||
style={{
|
||||
padding: '16px 32px',
|
||||
borderRadius: radius.lg,
|
||||
border: 'none',
|
||||
background: selectedWords.length > 0
|
||||
? `linear-gradient(180deg, ${palette.sentence}E6, ${palette.sentence}CC)`
|
||||
: 'rgba(0,0,0,0.1)',
|
||||
color: selectedWords.length > 0 ? '#FFFFFF' : subColor,
|
||||
fontSize: '16px',
|
||||
fontWeight: 700,
|
||||
cursor: selectedWords.length === 0 ? 'default' : 'pointer',
|
||||
boxShadow: selectedWords.length > 0 ? `0 8px 24px ${palette.sentence}50` : 'none',
|
||||
opacity: selectedWords.length === 0 || checking ? 0.6 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
if (selectedWords.length > 0 && !checking) {
|
||||
e.currentTarget.style.transform = 'scale(0.95)';
|
||||
}
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
{checking ? '...' : '✓ ' + t('check')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SentenceBuilderPage;
|
||||
@@ -0,0 +1,742 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useLanguage } from '../i18n/LanguageContext';
|
||||
import { useLocalized } from '../hooks/useLocalized';
|
||||
import { fonts, radius } from '../styles/theme';
|
||||
import { getTopicTest, startLearningSession, saveLearningAnswer, completeLearningSession, teacherExamples, getLearningAnalytics, startWeakSkillsPractice, getTTS } from '../api';
|
||||
|
||||
interface QuestionOption {
|
||||
option_key: string;
|
||||
text_pl: string;
|
||||
text_ru: string;
|
||||
text_uk: string;
|
||||
text_en: string;
|
||||
is_correct: boolean;
|
||||
wrong_because: string | null;
|
||||
}
|
||||
|
||||
interface TestQuestion {
|
||||
question: {
|
||||
question_id: string;
|
||||
prompt_pl: string;
|
||||
prompt_ru: string;
|
||||
prompt_uk: string;
|
||||
prompt_en: string;
|
||||
question_type: string;
|
||||
skill_tag: string;
|
||||
difficulty: number;
|
||||
};
|
||||
options: QuestionOption[];
|
||||
}
|
||||
|
||||
const TestViewPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { topicId } = useParams<{ topicId: string }>();
|
||||
const { t } = useLanguage();
|
||||
const getLocalized = useLocalized();
|
||||
const [questions, setQuestions] = useState<TestQuestion[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [selectedAnswers, setSelectedAnswers] = useState<Record<string, string>>({});
|
||||
const [textInputValue, setTextInputValue] = useState('');
|
||||
const [sessionId, setSessionId] = useState<number | null>(null);
|
||||
const [isCompleting, setIsCompleting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ total: number; correct_count: number } | null>(null);
|
||||
const startTimeRef = useRef<number>(Date.now());
|
||||
|
||||
// 🔧 НОВОЕ: состояния для примеров и практики
|
||||
const [showExamplesModal, setShowExamplesModal] = useState(false);
|
||||
const [examplesText, setExamplesText] = useState('');
|
||||
const [isLoadingExamples, setIsLoadingExamples] = useState(false);
|
||||
const [examplesError, setExamplesError] = useState<string | null>(null);
|
||||
const [isPlayingExamples, setIsPlayingExamples] = useState(false);
|
||||
const [isStartingPractice, setIsStartingPractice] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
const brandColor = '#A970D6';
|
||||
const textColor = '#1A1A2E';
|
||||
const subColor = '#6B7280';
|
||||
const bgColor = '#F4F5F7';
|
||||
const cardBg = 'rgba(255,255,255,0.55)';
|
||||
|
||||
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
||||
background: cardBg,
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 8px 32px rgba(31,38,73,0.10), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (topicId) {
|
||||
setLoading(true);
|
||||
getTopicTest(topicId)
|
||||
.then((data: any) => {
|
||||
if (data.test && data.test.length > 0) {
|
||||
setQuestions(data.test);
|
||||
return startLearningSession('A1', topicId, 'topic_test');
|
||||
} else {
|
||||
throw new Error('No test questions');
|
||||
}
|
||||
})
|
||||
.then((sessionData: any) => {
|
||||
setSessionId(sessionData.session_id);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('Error loading test:', e);
|
||||
navigate(`/lessons/topic/${topicId}`);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [topicId]);
|
||||
|
||||
const saveAnswer = async (questionId: string, answer: string) => {
|
||||
const timeTaken = Date.now() - startTimeRef.current;
|
||||
startTimeRef.current = Date.now();
|
||||
|
||||
if (sessionId) {
|
||||
try {
|
||||
await saveLearningAnswer(sessionId, questionId, answer, timeTaken, true);
|
||||
} catch (e) {
|
||||
console.error('Error saving answer:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnswerSelect = async (questionId: string, optionKey: string) => {
|
||||
setSelectedAnswers(prev => ({ ...prev, [questionId]: optionKey }));
|
||||
await saveAnswer(questionId, optionKey);
|
||||
};
|
||||
|
||||
const handleTextInputChange = async (questionId: string, value: string) => {
|
||||
setTextInputValue(value);
|
||||
setSelectedAnswers(prev => ({ ...prev, [questionId]: value }));
|
||||
};
|
||||
|
||||
const handleTextInputBlur = async (questionId: string) => {
|
||||
if (textInputValue.trim()) {
|
||||
await saveAnswer(questionId, textInputValue.trim());
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = async () => {
|
||||
if (currentIndex < questions.length - 1) {
|
||||
setCurrentIndex(prev => prev + 1);
|
||||
setTextInputValue('');
|
||||
startTimeRef.current = Date.now();
|
||||
} else {
|
||||
setIsCompleting(true);
|
||||
|
||||
// Сохраняем ответ последнего вопроса
|
||||
const currentQ = questions[currentIndex];
|
||||
const answer = selectedAnswers[currentQ.question.question_id];
|
||||
|
||||
if (answer && sessionId) {
|
||||
const timeTaken = Date.now() - startTimeRef.current;
|
||||
try {
|
||||
await saveLearningAnswer(sessionId, currentQ.question.question_id, answer, timeTaken, true);
|
||||
} catch (e) {
|
||||
console.error('Error saving final answer:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Небольшая задержка для сохранения
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
if (sessionId) {
|
||||
try {
|
||||
const result = await completeLearningSession(sessionId);
|
||||
setTestResult(result);
|
||||
} catch (e) {
|
||||
console.error('Error completing test:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentIndex > 0) {
|
||||
setCurrentIndex(prev => prev - 1);
|
||||
setTextInputValue('');
|
||||
startTimeRef.current = Date.now();
|
||||
}
|
||||
};
|
||||
|
||||
// 🔧 НОВОЕ: загрузка примеров по теме
|
||||
const handleLoadExamples = async () => {
|
||||
if (isLoadingExamples) return;
|
||||
setIsLoadingExamples(true);
|
||||
setExamplesError(null);
|
||||
setExamplesText('');
|
||||
setShowExamplesModal(true);
|
||||
|
||||
try {
|
||||
const data = await teacherExamples('', '', topicId || '');
|
||||
if (data.limit_exceeded && data.message) {
|
||||
setExamplesError(data.message);
|
||||
} else if (data.response) {
|
||||
setExamplesText(data.response);
|
||||
} else {
|
||||
setExamplesError(t('testNoExamples'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading examples:', e);
|
||||
setExamplesError(t('teacherAIError'));
|
||||
} finally {
|
||||
setIsLoadingExamples(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 🔧 НОВОЕ: озвучка примеров (первый польский пример)
|
||||
const handlePlayExamples = async () => {
|
||||
if (!examplesText || isPlayingExamples) return;
|
||||
|
||||
// Извлекаем первый польский пример
|
||||
const lines = examplesText.split('\n');
|
||||
let polishText = '';
|
||||
for (const line of lines) {
|
||||
if (line.includes('🇵🇱')) {
|
||||
polishText = line.split('🇵🇱')[1]?.split('→')[0]?.trim() || '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!polishText) return;
|
||||
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
}
|
||||
if ('speechSynthesis' in window) {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
|
||||
setIsPlayingExamples(true);
|
||||
|
||||
try {
|
||||
const audioBlob = await getTTS(polishText, 'male');
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
const audio = new Audio(audioUrl);
|
||||
audioRef.current = audio;
|
||||
audio.onended = () => setIsPlayingExamples(false);
|
||||
audio.onerror = () => setIsPlayingExamples(false);
|
||||
await audio.play();
|
||||
} catch (e) {
|
||||
console.error('TTS error:', e);
|
||||
setIsPlayingExamples(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 🔧 НОВОЕ: практика слабых тем
|
||||
const handleStartPractice = async () => {
|
||||
if (isStartingPractice) return;
|
||||
setIsStartingPractice(true);
|
||||
|
||||
try {
|
||||
// Проверяем, есть ли слабые темы
|
||||
const analytics = await getLearningAnalytics();
|
||||
if (!analytics.has_practice_available) {
|
||||
// Нет слабых тем — показываем сообщение
|
||||
alert(t('testPracticeNoWeak'));
|
||||
setIsStartingPractice(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await startWeakSkillsPractice();
|
||||
if (data.has_weak_skills && data.session_id) {
|
||||
navigate('/lessons/practice', {
|
||||
state: {
|
||||
sessionId: data.session_id,
|
||||
questions: data.questions,
|
||||
explanations: data.explanations,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
alert(data.message || t('testPracticeNoWeak'));
|
||||
setIsStartingPractice(false);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error starting practice:', e);
|
||||
alert(t('teacherAIError'));
|
||||
setIsStartingPractice(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: bgColor,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: subColor,
|
||||
fontFamily: fonts.main,
|
||||
fontSize: '18px',
|
||||
}}>
|
||||
{t('loading')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Экран результатов
|
||||
if (testResult) {
|
||||
const total = testResult.total || 0;
|
||||
const correct = testResult.correct_count || 0;
|
||||
const percentage = total > 0 ? Math.round((correct / total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: bgColor,
|
||||
color: textColor,
|
||||
fontFamily: fonts.main,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '24px',
|
||||
}}>
|
||||
<div style={{ fontSize: '64px', marginBottom: '16px' }}>
|
||||
{percentage >= 70 ? '🎉' : percentage >= 50 ? '👍' : '💪'}
|
||||
</div>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 900, margin: '0 0 8px 0', textAlign: 'center' }}>
|
||||
{t('teacherCompleted')}
|
||||
</h1>
|
||||
<div style={{
|
||||
...glass({
|
||||
padding: '20px',
|
||||
borderRadius: radius.lg,
|
||||
textAlign: 'center',
|
||||
marginTop: '16px',
|
||||
width: '100%',
|
||||
maxWidth: '300px',
|
||||
}),
|
||||
}}>
|
||||
<div style={{ fontSize: '48px', fontWeight: 900, color: percentage >= 70 ? '#2ECC71' : percentage >= 50 ? '#F39C12' : '#E74C3C' }}>
|
||||
{percentage}%
|
||||
</div>
|
||||
<div style={{ fontSize: '18px', color: subColor, marginTop: '8px', fontWeight: 600 }}>
|
||||
{correct} / {total}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: subColor, marginTop: '4px' }}>
|
||||
{t('testCorrectAnswers')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 🔧 НОВОЕ: Кнопки "Дай примеры" и "Потренировать слабые темы" */}
|
||||
<div style={{ marginTop: '20px', width: '100%', maxWidth: '300px', display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
<button
|
||||
onClick={handleLoadExamples}
|
||||
disabled={isLoadingExamples}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '14px 20px',
|
||||
borderRadius: radius.lg,
|
||||
border: 'none',
|
||||
background: isLoadingExamples
|
||||
? 'rgba(0,0,0,0.1)'
|
||||
: `linear-gradient(135deg, #4FA9E8, #3B82F6)`,
|
||||
color: '#FFFFFF',
|
||||
fontSize: '15px',
|
||||
fontWeight: 700,
|
||||
cursor: isLoadingExamples ? 'default' : 'pointer',
|
||||
boxShadow: isLoadingExamples ? 'none' : '0 4px 15px rgba(79,169,232,0.4)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
onTouchStart={(e) => { if (!isLoadingExamples) e.currentTarget.style.transform = 'scale(0.96)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
💡 {t('testExamplesButton')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStartPractice}
|
||||
disabled={isStartingPractice}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '14px 20px',
|
||||
borderRadius: radius.lg,
|
||||
border: 'none',
|
||||
background: isStartingPractice
|
||||
? 'rgba(0,0,0,0.1)'
|
||||
: `linear-gradient(135deg, #2ECC71, #27AE60)`,
|
||||
color: '#FFFFFF',
|
||||
fontSize: '15px',
|
||||
fontWeight: 700,
|
||||
cursor: isStartingPractice ? 'default' : 'pointer',
|
||||
boxShadow: isStartingPractice ? 'none' : '0 4px 15px rgba(46,204,113,0.4)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
onTouchStart={(e) => { if (!isStartingPractice) e.currentTarget.style.transform = 'scale(0.96)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
🎯 {t('testPracticeButton')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => navigate(`/lessons/topic/${topicId}`)}
|
||||
style={{
|
||||
marginTop: '12px',
|
||||
padding: '16px 32px',
|
||||
borderRadius: radius.lg,
|
||||
border: 'none',
|
||||
background: `linear-gradient(135deg, ${brandColor}, #8B5CF6)`,
|
||||
color: '#FFFFFF',
|
||||
fontSize: '16px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
boxShadow: `0 4px 15px ${brandColor}40`,
|
||||
width: '100%',
|
||||
maxWidth: '300px',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.96)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
{t('testBackToTopic')}
|
||||
</button>
|
||||
|
||||
{/* 🔧 НОВОЕ: Модалка с примерами */}
|
||||
{showExamplesModal && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(20,22,35,0.25)', zIndex: 500,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '20px',
|
||||
backdropFilter: 'blur(4px)', WebkitBackdropFilter: 'blur(4px)',
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setShowExamplesModal(false); }}
|
||||
>
|
||||
<div style={{
|
||||
...glass({
|
||||
borderRadius: radius.lg,
|
||||
padding: '24px',
|
||||
width: '100%',
|
||||
maxWidth: '400px',
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 24px 60px rgba(31,38,73,0.20), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
}),
|
||||
}}>
|
||||
<h3 style={{
|
||||
margin: '0 0 16px 0',
|
||||
fontSize: '18px',
|
||||
fontWeight: 800,
|
||||
textAlign: 'center',
|
||||
color: textColor,
|
||||
}}>
|
||||
💡 {t('testExamplesTitle')}
|
||||
</h3>
|
||||
|
||||
{isLoadingExamples ? (
|
||||
<div style={{ textAlign: 'center', padding: '30px', color: subColor, fontSize: '14px' }}>
|
||||
{t('loading')}
|
||||
</div>
|
||||
) : examplesError ? (
|
||||
<div style={{ textAlign: 'center', padding: '20px', color: '#E74C3C', fontSize: '14px', fontWeight: 600 }}>
|
||||
⚠️ {examplesError}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
background: 'rgba(79,169,232,0.08)',
|
||||
borderRadius: radius.md,
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.7',
|
||||
color: textColor,
|
||||
whiteSpace: 'pre-line',
|
||||
marginBottom: '16px',
|
||||
}}>
|
||||
{examplesText}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<button
|
||||
onClick={handlePlayExamples}
|
||||
disabled={isPlayingExamples}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
borderRadius: radius.md,
|
||||
border: `1px solid ${brandColor}40`,
|
||||
background: isPlayingExamples ? `${brandColor}30` : 'rgba(255,255,255,0.8)',
|
||||
color: brandColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 700,
|
||||
cursor: isPlayingExamples ? 'default' : 'pointer',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
🔊
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowExamplesModal(false)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
borderRadius: radius.md,
|
||||
border: 'none',
|
||||
background: `linear-gradient(135deg, #4FA9E8, #3B82F6)`,
|
||||
color: '#FFFFFF',
|
||||
fontSize: '14px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{t('teacherClose')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (questions.length === 0) return null;
|
||||
|
||||
const currentQuestion = questions[currentIndex];
|
||||
const isLastQuestion = currentIndex === questions.length - 1;
|
||||
const isProduction = currentQuestion?.question.question_type === 'production';
|
||||
const isCurrentAnswered = currentQuestion ? !!selectedAnswers[currentQuestion.question.question_id] : false;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: bgColor,
|
||||
color: textColor,
|
||||
fontFamily: fonts.main,
|
||||
padding: '24px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ position: 'fixed', top: '-80px', right: '-60px', width: '260px', height: '260px', borderRadius: '50%', background: brandColor, opacity: 0.18, filter: 'blur(90px)', pointerEvents: 'none', zIndex: 0 }} />
|
||||
|
||||
<style>{`
|
||||
@keyframes fadeInUp {
|
||||
0% { transform: translateY(10px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes optionSelect {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div style={{ position: 'relative', zIndex: 1, display: 'flex', flexDirection: 'column', flex: 1 }}>
|
||||
|
||||
{/* Заголовок */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
marginBottom: '20px',
|
||||
animation: 'fadeInUp 0.3s ease',
|
||||
}}>
|
||||
<button
|
||||
onClick={() => navigate(`/lessons/topic/${topicId}`)}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '10px 18px',
|
||||
borderRadius: radius.md,
|
||||
}),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.95)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
← {t('back')}
|
||||
</button>
|
||||
<h1 style={{ fontSize: '18px', fontWeight: 700, margin: 0, color: textColor, flex: 1, textAlign: 'center' }}>
|
||||
🎓 {t('testTitle')}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Счётчик */}
|
||||
<div style={{ textAlign: 'center', marginBottom: '16px', color: subColor, fontSize: '13px' }}>
|
||||
{t('testQuestion')} {currentIndex + 1} / {questions.length}
|
||||
</div>
|
||||
|
||||
{/* Прогресс-бар */}
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '4px',
|
||||
background: 'rgba(0,0,0,0.06)',
|
||||
borderRadius: '2px',
|
||||
marginBottom: '20px',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${((currentIndex + 1) / questions.length) * 100}%`,
|
||||
height: '100%',
|
||||
background: `linear-gradient(90deg, ${brandColor}, #8B5CF6)`,
|
||||
borderRadius: '2px',
|
||||
transition: 'width 0.3s ease',
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* Вопрос */}
|
||||
{currentQuestion && (
|
||||
<div style={{ animation: 'fadeInUp 0.3s ease 0.15s both', flex: 1 }}>
|
||||
<p style={{ fontSize: '16px', fontWeight: 600, color: textColor, marginBottom: '16px' }}>
|
||||
{getLocalized(currentQuestion.question, 'prompt')}
|
||||
</p>
|
||||
|
||||
{isProduction ? (
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={textInputValue}
|
||||
onChange={(e) => handleTextInputChange(currentQuestion.question.question_id, e.target.value)}
|
||||
onBlur={() => handleTextInputBlur(currentQuestion.question.question_id)}
|
||||
placeholder={t('teacherPlaceholder')}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
borderRadius: radius.lg,
|
||||
border: textInputValue.trim() ? `2px solid ${brandColor}` : '1px solid rgba(255,255,255,0.7)',
|
||||
background: textInputValue.trim() ? `${brandColor}10` : cardBg,
|
||||
color: textColor,
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
fontFamily: fonts.main,
|
||||
outline: 'none',
|
||||
textAlign: 'center',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: textInputValue.trim() ? `0 0 15px ${brandColor}30` : '0 2px 8px rgba(31,38,73,0.06)',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = brandColor;
|
||||
e.currentTarget.style.boxShadow = `0 0 15px ${brandColor}30`;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{currentQuestion.options.map((option) => {
|
||||
const isSelected = selectedAnswers[currentQuestion.question.question_id] === option.option_key;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.option_key}
|
||||
onClick={() => handleAnswerSelect(currentQuestion.question.question_id, option.option_key)}
|
||||
style={{
|
||||
padding: '14px 16px',
|
||||
borderRadius: radius.md,
|
||||
border: isSelected ? `2px solid ${brandColor}` : '1px solid rgba(255,255,255,0.7)',
|
||||
background: isSelected ? `${brandColor}15` : cardBg,
|
||||
color: textColor,
|
||||
fontSize: '15px',
|
||||
fontWeight: isSelected ? 700 : 500,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
boxShadow: isSelected ? `0 0 10px ${brandColor}30` : '0 2px 8px rgba(31,38,73,0.06)',
|
||||
transition: 'all 0.2s ease',
|
||||
animation: isSelected ? 'optionSelect 0.3s ease' : 'none',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.97)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
<span style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
border: isSelected ? `2px solid ${brandColor}` : '2px solid rgba(0,0,0,0.2)',
|
||||
background: isSelected ? brandColor : 'transparent',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
{isSelected && <span style={{ width: '8px', height: '8px', borderRadius: '50%', background: '#FFFFFF' }} />}
|
||||
</span>
|
||||
<span>{option.text_pl}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Кнопки навигации */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
marginTop: '20px',
|
||||
animation: 'fadeInUp 0.3s ease 0.3s both',
|
||||
}}>
|
||||
<button
|
||||
onClick={handleBack}
|
||||
disabled={currentIndex === 0 || isCompleting}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '14px 20px',
|
||||
borderRadius: radius.md,
|
||||
}),
|
||||
color: currentIndex === 0 ? subColor : textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: currentIndex === 0 ? 'default' : 'pointer',
|
||||
opacity: currentIndex === 0 ? 0.5 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
← {t('back')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={!isCurrentAnswered || isCompleting}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '14px 20px',
|
||||
borderRadius: radius.md,
|
||||
border: 'none',
|
||||
background: isCurrentAnswered
|
||||
? `linear-gradient(135deg, ${brandColor}, #8B5CF6)`
|
||||
: 'rgba(0,0,0,0.1)',
|
||||
color: '#FFFFFF',
|
||||
fontSize: '14px',
|
||||
fontWeight: 700,
|
||||
cursor: isCurrentAnswered ? 'pointer' : 'default',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: isCurrentAnswered ? `0 4px 15px ${brandColor}40` : 'none',
|
||||
opacity: isCompleting ? 0.7 : 1,
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
if (isCurrentAnswered && !isCompleting) {
|
||||
e.currentTarget.style.transform = 'scale(0.96)';
|
||||
}
|
||||
}}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
{isCompleting
|
||||
? t('testChecking')
|
||||
: isLastQuestion
|
||||
? t('testFinish')
|
||||
: `${t('next')} →`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestViewPage;
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useLanguage } from '../i18n/LanguageContext';
|
||||
import { useLocalized } from '../hooks/useLocalized';
|
||||
import { fonts, radius } from '../styles/theme';
|
||||
import { getLearningTopic } from '../api';
|
||||
|
||||
interface Lesson {
|
||||
lesson_id: string;
|
||||
order_number: number;
|
||||
skill_tag: string;
|
||||
title_pl: string;
|
||||
title_ru: string;
|
||||
title_uk: string;
|
||||
title_en: string;
|
||||
completed?: boolean;
|
||||
}
|
||||
|
||||
interface TopicData {
|
||||
topic_id: string;
|
||||
name_pl: string;
|
||||
name_ru: string;
|
||||
name_uk: string;
|
||||
name_en: string;
|
||||
}
|
||||
|
||||
const TopicLessonsPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { topicId } = useParams<{ topicId: string }>();
|
||||
const { t } = useLanguage();
|
||||
const getLocalized = useLocalized();
|
||||
const [topic, setTopic] = useState<TopicData | null>(null);
|
||||
const [lessons, setLessons] = useState<Lesson[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const brandColor = '#2ECC71';
|
||||
const textColor = '#1A1A2E';
|
||||
const subColor = '#6B7280';
|
||||
const bgColor = '#F4F5F7';
|
||||
const cardBg = 'rgba(255,255,255,0.55)';
|
||||
|
||||
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
||||
background: cardBg,
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 8px 32px rgba(31,38,73,0.10), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (topicId) {
|
||||
setLoading(true);
|
||||
getLearningTopic(topicId)
|
||||
.then((data: any) => {
|
||||
setTopic(data.topic);
|
||||
setLessons(data.lessons || []);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [topicId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: bgColor,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: subColor,
|
||||
fontFamily: fonts.main,
|
||||
fontSize: '18px',
|
||||
}}>
|
||||
{t('loading')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: bgColor,
|
||||
color: textColor,
|
||||
fontFamily: fonts.main,
|
||||
padding: '24px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ position: 'fixed', top: '-80px', right: '-60px', width: '260px', height: '260px', borderRadius: '50%', background: brandColor, opacity: 0.18, filter: 'blur(90px)', pointerEvents: 'none', zIndex: 0 }} />
|
||||
<div style={{ position: 'fixed', bottom: '-50px', left: '-40px', width: '240px', height: '240px', borderRadius: '50%', background: '#A970D6', opacity: 0.14, filter: 'blur(80px)', pointerEvents: 'none', zIndex: 0 }} />
|
||||
|
||||
<style>{`
|
||||
@keyframes fadeInUp {
|
||||
0% { transform: translateY(10px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div style={{ position: 'relative', zIndex: 1, display: 'flex', flexDirection: 'column', flex: 1 }}>
|
||||
|
||||
{/* Заголовок */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
marginBottom: '24px',
|
||||
animation: 'fadeInUp 0.3s ease',
|
||||
}}>
|
||||
<button
|
||||
onClick={() => navigate('/lessons')}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '10px 18px',
|
||||
borderRadius: radius.md,
|
||||
}),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.95)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
← {t('back')}
|
||||
</button>
|
||||
<h1 style={{ fontSize: '22px', fontWeight: 900, margin: 0, color: textColor }}>
|
||||
{topic ? getLocalized(topic, 'name') : ''}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Список уроков */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px',
|
||||
animation: 'fadeInUp 0.3s ease 0.1s both',
|
||||
}}>
|
||||
{lessons.map((lesson, index) => {
|
||||
const isUnlocked = index === 0 || lessons[index - 1]?.completed;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={lesson.lesson_id}
|
||||
onClick={() => {
|
||||
if (isUnlocked) {
|
||||
navigate(`/lessons/topic/${topicId}/lesson/${lesson.lesson_id}`);
|
||||
}
|
||||
}}
|
||||
disabled={!isUnlocked}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '14px 16px',
|
||||
borderRadius: radius.lg,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
}),
|
||||
opacity: isUnlocked ? 1 : 0.5,
|
||||
cursor: isUnlocked ? 'pointer' : 'default',
|
||||
animation: `fadeInUp 0.3s ease ${0.1 + index * 0.04}s both`,
|
||||
}}
|
||||
onTouchStart={(e) => { if (isUnlocked) e.currentTarget.style.transform = 'scale(0.97)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
<span style={{ fontSize: '20px', flexShrink: 0 }}>
|
||||
{lesson.completed ? '✅' : isUnlocked ? '📝' : '🔒'}
|
||||
</span>
|
||||
<div style={{ flex: 1, textAlign: 'left' }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: 700, color: textColor }}>
|
||||
{t('topicLesson')} {index + 1}
|
||||
</div>
|
||||
<div style={{ fontSize: '13px', color: subColor, marginTop: '2px' }}>
|
||||
{getLocalized(lesson, 'title')}
|
||||
</div>
|
||||
</div>
|
||||
{isUnlocked && !lesson.completed && (
|
||||
<span style={{ fontSize: '18px', color: brandColor }}>→</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Кнопка финального теста */}
|
||||
<div style={{ marginTop: '20px', animation: 'fadeInUp 0.3s ease 0.5s both' }}>
|
||||
<button
|
||||
onClick={() => navigate(`/lessons/test/${topicId}`)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
borderRadius: radius.lg,
|
||||
border: 'none',
|
||||
background: `linear-gradient(135deg, #A970D6, #8B5CF6)`,
|
||||
color: '#FFFFFF',
|
||||
fontSize: '16px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 4px 15px rgba(169,112,214,0.4)',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.96)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
🎓 {t('topicFinalTest')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopicLessonsPage;
|
||||
@@ -0,0 +1,494 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLanguage } from '../i18n/LanguageContext';
|
||||
import { fonts, radius } from '../styles/theme';
|
||||
import { getNextTypingSentence, checkTypingSentence, getTypingHint, getTypingProgress, getTTS } from '../api';
|
||||
|
||||
interface TypingSentence {
|
||||
completed: boolean;
|
||||
sentence_id: number;
|
||||
translation: string;
|
||||
level: string;
|
||||
}
|
||||
|
||||
const TypingPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useLanguage();
|
||||
const [sentence, setSentence] = useState<TypingSentence | null>(null);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isCorrect, setIsCorrect] = useState<boolean | null>(null);
|
||||
const [showAnswer, setShowAnswer] = useState(false);
|
||||
const [correctAnswer, setCorrectAnswer] = useState('');
|
||||
const [totalCompleted, setTotalCompleted] = useState(0);
|
||||
const [totalSentences, setTotalSentences] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
const brandColor = '#2ECC71';
|
||||
const textColor = '#1A1A2E';
|
||||
const subColor = '#6B7280';
|
||||
const bgColor = '#F4F5F7';
|
||||
const cardBg = 'rgba(255,255,255,0.55)';
|
||||
|
||||
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
||||
background: cardBg,
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 8px 32px rgba(31,38,73,0.10), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
const fetchSentence = useCallback(() => {
|
||||
setLoading(true);
|
||||
setIsCorrect(null);
|
||||
setShowAnswer(false);
|
||||
setInputValue('');
|
||||
setCorrectAnswer('');
|
||||
setIsPlaying(false);
|
||||
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
}
|
||||
|
||||
getNextTypingSentence()
|
||||
.then((data: any) => {
|
||||
if (data.completed) {
|
||||
setSentence({ completed: true, sentence_id: 0, translation: '', level: '' });
|
||||
} else {
|
||||
setSentence(data);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
navigate('/menu');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
getTypingProgress()
|
||||
.then((data: any) => {
|
||||
setTotalCompleted(data.completed || 0);
|
||||
setTotalSentences(data.total || 0);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSentence();
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
}
|
||||
if ('speechSynthesis' in window) {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
};
|
||||
}, [fetchSentence]);
|
||||
|
||||
const handleCheck = async () => {
|
||||
if (!sentence || !inputValue.trim()) return;
|
||||
|
||||
try {
|
||||
const data = await checkTypingSentence(sentence.sentence_id, inputValue);
|
||||
setIsCorrect(data.correct);
|
||||
setCorrectAnswer(data.correct_answer);
|
||||
|
||||
if (data.correct) {
|
||||
setTotalCompleted(prev => prev + 1);
|
||||
setTimeout(() => {
|
||||
fetchSentence();
|
||||
}, 1500);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error checking sentence:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowAnswer = async () => {
|
||||
if (!sentence) return;
|
||||
try {
|
||||
const data = await getTypingHint(sentence.sentence_id);
|
||||
setCorrectAnswer(data.correct_answer);
|
||||
setShowAnswer(true);
|
||||
} catch (e) {
|
||||
console.error('Error getting hint:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const playAnswer = async () => {
|
||||
if (!correctAnswer) return;
|
||||
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
}
|
||||
|
||||
if ('speechSynthesis' in window) {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
|
||||
setIsPlaying(true);
|
||||
|
||||
try {
|
||||
const audioBlob = await getTTS(correctAnswer, 'male');
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
const audio = new Audio(audioUrl);
|
||||
audioRef.current = audio;
|
||||
|
||||
audio.onended = () => setIsPlaying(false);
|
||||
audio.onerror = () => setIsPlaying(false);
|
||||
|
||||
await audio.play();
|
||||
} catch (e) {
|
||||
console.error('Error playing TTS:', e);
|
||||
setIsPlaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: bgColor,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: subColor,
|
||||
fontFamily: fonts.main,
|
||||
fontSize: '18px',
|
||||
}}>
|
||||
{t('loading')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: bgColor,
|
||||
color: textColor,
|
||||
fontFamily: fonts.main,
|
||||
padding: '24px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ position: 'fixed', top: '-80px', right: '-60px', width: '260px', height: '260px', borderRadius: '50%', background: brandColor, opacity: 0.18, filter: 'blur(90px)', pointerEvents: 'none', zIndex: 0 }} />
|
||||
|
||||
<style>{`
|
||||
@keyframes fadeInUp {
|
||||
0% { transform: translateY(10px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes modalFadeIn {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
@keyframes modalSlideUp {
|
||||
0% { transform: translateY(20px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes correctPop {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-5px); }
|
||||
75% { transform: translateX(5px); }
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.1); }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div style={{ position: 'relative', zIndex: 1, display: 'flex', flexDirection: 'column', flex: 1 }}>
|
||||
|
||||
{/* Модалка с ответом */}
|
||||
{showAnswer && correctAnswer && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(20,22,35,0.25)', zIndex: 500,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
animation: 'modalFadeIn 0.2s ease', padding: '20px',
|
||||
backdropFilter: 'blur(4px)', WebkitBackdropFilter: 'blur(4px)',
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setShowAnswer(false); }}
|
||||
>
|
||||
<div style={{
|
||||
...glass({
|
||||
borderRadius: radius.lg,
|
||||
padding: '24px',
|
||||
width: '100%',
|
||||
maxWidth: '380px',
|
||||
textAlign: 'center',
|
||||
boxShadow: '0 24px 60px rgba(31,38,73,0.20), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
}),
|
||||
animation: 'modalSlideUp 0.3s ease',
|
||||
}}>
|
||||
<div style={{ fontSize: '32px', marginBottom: '12px' }}>🇵🇱</div>
|
||||
<h3 style={{ margin: '0 0 8px 0', fontSize: '16px', fontWeight: 700, color: subColor }}>
|
||||
{t('typingShowAnswer')}:
|
||||
</h3>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '10px',
|
||||
marginBottom: '20px',
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
<div style={{ fontSize: '22px', fontWeight: 800, color: '#1A1A2E' }}>
|
||||
{correctAnswer}
|
||||
</div>
|
||||
<button
|
||||
onClick={playAnswer}
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
border: `1px solid ${brandColor}40`,
|
||||
background: isPlaying ? `${brandColor}30` : 'rgba(255,255,255,0.9)',
|
||||
color: brandColor,
|
||||
fontSize: '18px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
animation: isPlaying ? 'pulse 0.5s ease infinite' : 'none',
|
||||
flexShrink: 0,
|
||||
boxShadow: '0 2px 8px rgba(31,38,73,0.08)',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.9)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
🔊
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAnswer(false)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '14px',
|
||||
borderRadius: radius.md,
|
||||
border: 'none',
|
||||
background: `linear-gradient(135deg, #2ECC71, #27AE60)`,
|
||||
color: '#FFFFFF',
|
||||
fontSize: '15px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{t('teacherClose')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Заголовок */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
marginBottom: '20px',
|
||||
animation: 'fadeInUp 0.3s ease',
|
||||
}}>
|
||||
<button
|
||||
onClick={() => navigate('/menu')}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '10px 18px',
|
||||
borderRadius: radius.md,
|
||||
}),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.95)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
← {t('back')}
|
||||
</button>
|
||||
<h1 style={{ fontSize: '22px', fontWeight: 900, margin: 0, color: textColor, flex: 1, textAlign: 'center' }}>
|
||||
⌨️ {t('typingTitle')}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Прогресс */}
|
||||
<div style={{ textAlign: 'center', marginBottom: '20px', color: subColor, fontSize: '13px' }}>
|
||||
{t('completed')}: {totalCompleted} / {totalSentences}
|
||||
</div>
|
||||
|
||||
{sentence?.completed ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 20px', animation: 'fadeInUp 0.3s ease' }}>
|
||||
<div style={{ fontSize: '64px', marginBottom: '16px' }}>🎉</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 700, color: textColor }}>
|
||||
{t('allSentencesCompleted')}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/menu')}
|
||||
style={{
|
||||
marginTop: '20px',
|
||||
padding: '14px 28px',
|
||||
borderRadius: radius.lg,
|
||||
border: 'none',
|
||||
background: `linear-gradient(135deg, #2ECC71, #27AE60)`,
|
||||
color: '#FFFFFF',
|
||||
fontSize: '15px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{t('back')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Перевод */}
|
||||
<div style={{
|
||||
...glass({
|
||||
padding: '24px',
|
||||
borderRadius: radius.lg,
|
||||
marginBottom: '20px',
|
||||
}),
|
||||
animation: 'fadeInUp 0.3s ease 0.1s both',
|
||||
}}>
|
||||
<div style={{ fontSize: '13px', color: subColor, marginBottom: '8px', fontWeight: 500 }}>
|
||||
{t('typingInstructions')}
|
||||
</div>
|
||||
<div style={{ fontSize: '20px', fontWeight: 700, color: brandColor, lineHeight: '1.4' }}>
|
||||
{sentence?.translation}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Поле ввода */}
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
placeholder={t('typingPlaceholder')}
|
||||
disabled={isCorrect === true}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
borderRadius: radius.lg,
|
||||
border: isCorrect === false ? `2px solid #E74C3C` : isCorrect === true ? `2px solid #2ECC71` : '1px solid rgba(255,255,255,0.7)',
|
||||
background: isCorrect === true ? 'rgba(46,204,113,0.1)' : cardBg,
|
||||
color: textColor,
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
fontFamily: fonts.main,
|
||||
outline: 'none',
|
||||
textAlign: 'center',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: isCorrect === false ? '0 0 15px rgba(231,76,60,0.3)' : '0 2px 8px rgba(31,38,73,0.06)',
|
||||
animation: isCorrect === false ? 'shake 0.4s ease' : 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Статус */}
|
||||
{isCorrect === true && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
marginTop: '16px',
|
||||
padding: '12px',
|
||||
background: 'rgba(46,204,113,0.1)',
|
||||
borderRadius: radius.md,
|
||||
animation: 'correctPop 0.3s ease',
|
||||
}}>
|
||||
<span style={{ fontSize: '16px', fontWeight: 700, color: '#2ECC71' }}>
|
||||
✅ {t('typingCorrect')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isCorrect === false && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
marginTop: '16px',
|
||||
padding: '12px',
|
||||
background: 'rgba(231,76,60,0.1)',
|
||||
borderRadius: radius.md,
|
||||
}}>
|
||||
<span style={{ fontSize: '16px', fontWeight: 700, color: '#E74C3C' }}>
|
||||
❌ {t('typingIncorrect')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Кнопки */}
|
||||
<div style={{ display: 'flex', gap: '10px', marginTop: '20px', animation: 'fadeInUp 0.3s ease 0.2s both' }}>
|
||||
{isCorrect === true ? (
|
||||
<button
|
||||
onClick={fetchSentence}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '16px',
|
||||
borderRadius: radius.lg,
|
||||
border: 'none',
|
||||
background: `linear-gradient(135deg, #2ECC71, #27AE60)`,
|
||||
color: '#FFFFFF',
|
||||
fontSize: '16px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{t('next')} →
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={handleShowAnswer}
|
||||
style={{
|
||||
flex: 1,
|
||||
...glass({
|
||||
padding: '16px',
|
||||
borderRadius: radius.lg,
|
||||
}),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
👁️ {t('typingShowAnswer')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCheck}
|
||||
disabled={!inputValue.trim()}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '16px',
|
||||
borderRadius: radius.lg,
|
||||
border: 'none',
|
||||
background: inputValue.trim()
|
||||
? `linear-gradient(135deg, #2ECC71, #27AE60)`
|
||||
: 'rgba(0,0,0,0.1)',
|
||||
color: '#FFFFFF',
|
||||
fontSize: '16px',
|
||||
fontWeight: 700,
|
||||
cursor: inputValue.trim() ? 'pointer' : 'default',
|
||||
opacity: inputValue.trim() ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
✓ {t('check')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TypingPage;
|
||||
@@ -0,0 +1,580 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLanguage } from '../i18n/LanguageContext';
|
||||
import { fonts, radius } from '../styles/theme';
|
||||
import { getNextQuiz, checkQuizAnswer, getQuizProgress, fetchProgress } from '../api';
|
||||
import RulesModal from '../components/RulesModal';
|
||||
import { useAchievements } from '../hooks/useAchievements';
|
||||
|
||||
interface QuizData {
|
||||
correct_polish: string;
|
||||
translation: string;
|
||||
options: string[];
|
||||
}
|
||||
|
||||
const WordQuizPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { t, language } = useLanguage();
|
||||
const [quiz, setQuiz] = useState<QuizData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [totalScore, setTotalScore] = useState(0);
|
||||
const [wordsGuessed, setWordsGuessed] = useState(0);
|
||||
const [correctStreak, setCorrectStreak] = useState(0);
|
||||
const [mistakes, setMistakes] = useState(0);
|
||||
const [selectedAnswer, setSelectedAnswer] = useState<string | null>(null);
|
||||
const [isCorrect, setIsCorrect] = useState<boolean | null>(null);
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [showRules, setShowRules] = useState(false);
|
||||
const [currentLevel, setCurrentLevel] = useState('A1');
|
||||
|
||||
const { activeAchievement, showAchievement } = useAchievements();
|
||||
|
||||
const isMountedRef = useRef(true);
|
||||
const rulesShownRef = useRef(false);
|
||||
const startTimeRef = useRef<number>(0);
|
||||
const maxMistakes = 3;
|
||||
|
||||
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
||||
background: 'rgba(255,255,255,0.55)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 8px 32px rgba(31,38,73,0.10), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
const brandColor = '#2ECC71';
|
||||
const palette = {
|
||||
wordquiz: '#E8933F',
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
// Автопоказ правил при первом входе
|
||||
if (!rulesShownRef.current) {
|
||||
rulesShownRef.current = true;
|
||||
const rulesSeen = localStorage.getItem('filwords_rules_wordquiz');
|
||||
if (!rulesSeen) {
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
setShowRules(true);
|
||||
localStorage.setItem('filwords_rules_wordquiz', '1');
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchQuiz = useCallback(() => {
|
||||
setLoading(true);
|
||||
setSelectedAnswer(null);
|
||||
setIsCorrect(null);
|
||||
setErrorMessage(null);
|
||||
setMistakes(0);
|
||||
startTimeRef.current = Date.now();
|
||||
|
||||
getNextQuiz()
|
||||
.then((data: any) => {
|
||||
if (isMountedRef.current) {
|
||||
setQuiz(data);
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
console.error('Error fetching quiz:', err);
|
||||
if (isMountedRef.current) {
|
||||
setErrorMessage('Failed to load quiz');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (isMountedRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchQuiz();
|
||||
fetchProgress()
|
||||
.then((data: any) => {
|
||||
if (isMountedRef.current) {
|
||||
setTotalScore(data.total_score || 0);
|
||||
if (data.current_level) {
|
||||
setCurrentLevel(data.current_level);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
getQuizProgress()
|
||||
.then((data: any) => {
|
||||
if (isMountedRef.current) {
|
||||
setWordsGuessed(data.words_guessed || 0);
|
||||
setCorrectStreak(data.correct_streak || 0);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [fetchQuiz]);
|
||||
|
||||
const handleAnswer = useCallback(async (answer: string) => {
|
||||
if (!quiz || isChecking || isCorrect !== null) return;
|
||||
|
||||
setSelectedAnswer(answer);
|
||||
setIsChecking(true);
|
||||
|
||||
const completionTime = Math.floor((Date.now() - startTimeRef.current) / 1000);
|
||||
|
||||
try {
|
||||
const data: any = await checkQuizAnswer(quiz.correct_polish, answer, mistakes, completionTime);
|
||||
|
||||
if (isMountedRef.current) {
|
||||
if (data.correct) {
|
||||
setIsCorrect(true);
|
||||
setTotalScore(prev => prev + (data.score || 0));
|
||||
setWordsGuessed(prev => prev + 1);
|
||||
setCorrectStreak(data.correct_streak || 0);
|
||||
|
||||
fetchProgress().catch(() => {});
|
||||
|
||||
// Показываем достижения
|
||||
if (data.new_achievements && data.new_achievements.length > 0) {
|
||||
data.new_achievements.forEach((ach: any) => {
|
||||
showAchievement({
|
||||
id: ach.code,
|
||||
icon: ach.icon,
|
||||
name: ach.name,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (window.Telegram?.WebApp?.HapticFeedback) {
|
||||
window.Telegram.WebApp.HapticFeedback.impactOccurred('heavy');
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
setMistakes(0);
|
||||
fetchQuiz();
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
setIsCorrect(false);
|
||||
const newMistakes = mistakes + 1;
|
||||
setMistakes(newMistakes);
|
||||
setCorrectStreak(0);
|
||||
|
||||
startTimeRef.current = Date.now();
|
||||
|
||||
if (window.Telegram?.WebApp?.HapticFeedback) {
|
||||
window.Telegram.WebApp.HapticFeedback.impactOccurred('medium');
|
||||
}
|
||||
|
||||
if (newMistakes >= maxMistakes) {
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
setMistakes(0);
|
||||
fetchQuiz();
|
||||
}
|
||||
}, 1500);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
setSelectedAnswer(null);
|
||||
setIsCorrect(null);
|
||||
}
|
||||
}, 800);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error checking answer:', err);
|
||||
if (isMountedRef.current) {
|
||||
setErrorMessage(err.message || 'Error checking answer');
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsChecking(false);
|
||||
}
|
||||
}
|
||||
}, [quiz, isChecking, isCorrect, mistakes, fetchQuiz, showAchievement]);
|
||||
|
||||
const getOptionStyle = (option: string): React.CSSProperties => {
|
||||
if (isCorrect === true && option === quiz?.correct_polish) {
|
||||
return {
|
||||
background: `linear-gradient(180deg, ${brandColor}E6, ${brandColor}CC)`,
|
||||
border: `2px solid ${brandColor}`,
|
||||
color: '#12271C',
|
||||
fontWeight: 700,
|
||||
boxShadow: `0 8px 24px ${brandColor}50, inset 0 1px 0 rgba(255,255,255,0.4)`,
|
||||
};
|
||||
}
|
||||
if (isCorrect === false && option === selectedAnswer) {
|
||||
return {
|
||||
background: 'linear-gradient(180deg, #E74C3CE6, #E74C3CCC)',
|
||||
border: '2px solid #E74C3C',
|
||||
color: '#FFFFFF',
|
||||
fontWeight: 700,
|
||||
boxShadow: '0 8px 24px rgba(231,76,60,0.35), inset 0 1px 0 rgba(255,255,255,0.3)',
|
||||
};
|
||||
}
|
||||
if (selectedAnswer === option && isChecking) {
|
||||
return {
|
||||
background: `${palette.wordquiz}25`,
|
||||
border: `2px solid ${palette.wordquiz}`,
|
||||
color: palette.wordquiz,
|
||||
fontWeight: 700,
|
||||
};
|
||||
}
|
||||
return {
|
||||
background: 'rgba(255,255,255,0.55)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
color: '#1A1A2E',
|
||||
fontWeight: 700,
|
||||
boxShadow: '0 6px 20px rgba(31,38,73,0.08), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
};
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: '#F4F5F7',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#6B7280',
|
||||
fontFamily: fonts.main,
|
||||
fontSize: '18px',
|
||||
}}>
|
||||
{t('loading')}...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: '#F4F5F7',
|
||||
color: '#1A1A2E',
|
||||
fontFamily: fonts.main,
|
||||
padding: '24px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ position: 'fixed', top: '-100px', right: '-80px', width: '280px', height: '280px', borderRadius: '50%', background: palette.wordquiz, opacity: 0.28, filter: 'blur(100px)', pointerEvents: 'none', zIndex: 0 }} />
|
||||
<div style={{ position: 'fixed', bottom: '-60px', left: '-60px', width: '240px', height: '240px', borderRadius: '50%', background: brandColor, opacity: 0.22, filter: 'blur(90px)', pointerEvents: 'none', zIndex: 0 }} />
|
||||
|
||||
<style>{`
|
||||
@keyframes fadeInUp {
|
||||
0% { transform: translateY(10px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes correctPop {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-5px); }
|
||||
75% { transform: translateX(5px); }
|
||||
}
|
||||
@keyframes firePop {
|
||||
0% { transform: scale(0); opacity: 0; }
|
||||
60% { transform: scale(1.3); opacity: 1; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
@keyframes achievementSlideDown {
|
||||
0% { transform: translateX(-50%) translateY(-80px); opacity: 0; }
|
||||
100% { transform: translateX(-50%) translateY(0); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div style={{ position: 'relative', zIndex: 1, display: 'flex', flexDirection: 'column', flex: 1 }}>
|
||||
|
||||
<RulesModal
|
||||
isOpen={showRules}
|
||||
onClose={() => setShowRules(false)}
|
||||
title={t('wordQuizRulesTitle') || '🎯 Word Quiz Rules'}
|
||||
content={t('wordQuizRulesContent') || 'Rules content loading...'}
|
||||
/>
|
||||
|
||||
{/* Плашка достижения */}
|
||||
{activeAchievement && (
|
||||
<div style={{
|
||||
position: 'fixed', top: '16px', left: '50%', transform: 'translateX(-50%)', zIndex: 900,
|
||||
background: 'linear-gradient(135deg, #FFD700, #FFA500)',
|
||||
color: '#FFFFFF', padding: '14px 20px', borderRadius: radius.lg,
|
||||
boxShadow: '0 8px 30px rgba(255, 215, 0, 0.5)',
|
||||
display: 'flex', alignItems: 'center', gap: '12px',
|
||||
minWidth: '250px', maxWidth: '90vw', animation: 'achievementSlideDown 0.4s ease',
|
||||
}}>
|
||||
<span style={{ fontSize: '28px', flexShrink: 0 }}>{activeAchievement.icon}</span>
|
||||
<div>
|
||||
<div style={{ fontSize: '11px', fontWeight: 600, opacity: 0.9, letterSpacing: '1px', textTransform: 'uppercase' }}>
|
||||
🏆 {t('achievement') || 'Achievement'}
|
||||
</div>
|
||||
<div style={{ fontSize: '15px', fontWeight: 800, marginTop: '2px' }}>
|
||||
{activeAchievement.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '24px',
|
||||
animation: 'fadeInUp 0.3s ease',
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<button
|
||||
onClick={() => navigate('/menu')}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '10px 18px',
|
||||
borderRadius: radius.md,
|
||||
}),
|
||||
color: '#1A1A2E',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.95)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
← {t('back')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowRules(true)}
|
||||
style={{
|
||||
...glass({
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: radius.md,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}),
|
||||
color: '#1A1A2E',
|
||||
fontSize: '18px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s ease',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.95)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'right', display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 800,
|
||||
color: brandColor,
|
||||
background: `${brandColor}15`,
|
||||
padding: '4px 10px',
|
||||
borderRadius: radius.sm,
|
||||
border: `1px solid ${brandColor}40`,
|
||||
}}>
|
||||
{currentLevel}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '11px', color: '#6B7280', marginBottom: '2px' }}>
|
||||
{t('score')}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 800,
|
||||
color: brandColor,
|
||||
}}>
|
||||
{totalScore}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{quiz && (
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '20px',
|
||||
animation: 'fadeInUp 0.3s ease 0.15s both',
|
||||
}}>
|
||||
<div style={{
|
||||
...glass({
|
||||
padding: '24px',
|
||||
borderRadius: radius.lg,
|
||||
textAlign: 'center',
|
||||
}),
|
||||
boxShadow: `0 8px 28px rgba(31,38,73,0.10), 0 0 20px ${brandColor}20, inset 0 1px 0 rgba(255,255,255,0.85)`,
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
color: '#6B7280',
|
||||
marginBottom: '8px',
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
{t('chooseTranslation') || 'Choose the correct translation:'}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '22px',
|
||||
fontWeight: 700,
|
||||
color: brandColor,
|
||||
padding: '10px',
|
||||
background: `${brandColor}15`,
|
||||
borderRadius: radius.md,
|
||||
}}>
|
||||
{quiz.translation}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
}}>
|
||||
{quiz.options.map((option, index) => (
|
||||
<button
|
||||
key={`${option}-${index}`}
|
||||
onClick={() => handleAnswer(option)}
|
||||
disabled={isChecking || isCorrect === true}
|
||||
style={{
|
||||
padding: '18px',
|
||||
borderRadius: radius.lg,
|
||||
fontSize: '17px',
|
||||
fontWeight: 700,
|
||||
cursor: isChecking || isCorrect === true ? 'default' : 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
animation: isCorrect === false && option === selectedAnswer
|
||||
? 'shake 0.4s ease'
|
||||
: isCorrect === true && option === quiz.correct_polish
|
||||
? 'correctPop 0.3s ease'
|
||||
: 'none',
|
||||
...getOptionStyle(option),
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
if (!isChecking && isCorrect === null) {
|
||||
e.currentTarget.style.transform = 'scale(0.96)';
|
||||
}
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '12px',
|
||||
background: 'rgba(231,76,60,0.08)',
|
||||
borderRadius: radius.md,
|
||||
color: '#E74C3C',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
❌ {errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mistakes >= maxMistakes && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '16px',
|
||||
background: 'rgba(231,76,60,0.08)',
|
||||
borderRadius: radius.lg,
|
||||
border: '2px solid rgba(231,76,60,0.3)',
|
||||
}}>
|
||||
<div style={{ fontSize: '16px', fontWeight: 700, color: '#E74C3C' }}>
|
||||
{t('outOfLives') || 'Out of lives!'}
|
||||
</div>
|
||||
<div style={{ fontSize: '13px', color: '#6B7280', marginTop: '4px' }}>
|
||||
+10 {t('points') || 'points'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCorrect === true && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '16px',
|
||||
background: `${brandColor}15`,
|
||||
borderRadius: radius.lg,
|
||||
border: `2px solid ${brandColor}40`,
|
||||
}}>
|
||||
<div style={{ fontSize: '16px', fontWeight: 700, color: brandColor }}>
|
||||
✅ {t('correctAnswer')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
...glass({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
gap: '24px',
|
||||
marginTop: '20px',
|
||||
padding: '12px 16px',
|
||||
borderRadius: radius.lg,
|
||||
}),
|
||||
animation: 'fadeInUp 0.3s ease 0.2s both',
|
||||
}}>
|
||||
<div style={{ textAlign: 'center', minWidth: '60px' }}>
|
||||
<div style={{ fontSize: '10px', color: '#6B7280', marginBottom: '2px' }}>
|
||||
{t('wordsGuessed') || 'Угадано'}
|
||||
</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 800, color: palette.wordquiz }}>
|
||||
{wordsGuessed}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', minWidth: '60px' }}>
|
||||
<div style={{ fontSize: '10px', color: '#6B7280', marginBottom: '2px' }}>
|
||||
{language === 'pl' ? 'Seria' : language === 'ru' ? 'Серия' : language === 'uk' ? 'Серія' : 'Streak'}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 800,
|
||||
color: correctStreak >= 5 ? brandColor : '#1A1A2E',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '4px',
|
||||
}}>
|
||||
{correctStreak >= 5 && (
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
animation: 'firePop 0.3s ease',
|
||||
fontSize: '16px',
|
||||
}}>🔥</span>
|
||||
)}
|
||||
{correctStreak}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WordQuizPage;
|
||||
@@ -0,0 +1,764 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useLanguage } from '../i18n/LanguageContext';
|
||||
import { fonts, radius } from '../styles/theme';
|
||||
import { getLevel, completeLevel, fetchProgress } from '../api';
|
||||
import LetterGrid from '../components/LetterGrid';
|
||||
import HintPanel from '../components/HintPanel';
|
||||
import RulesModal from '../components/RulesModal';
|
||||
import { useAchievements } from '../hooks/useAchievements';
|
||||
|
||||
interface LevelData {
|
||||
id: number;
|
||||
level_number: number;
|
||||
grid_size: number;
|
||||
grid_letters: string[][];
|
||||
word_paths?: Record<string, [number, number][]>;
|
||||
}
|
||||
|
||||
interface WordItem {
|
||||
word_pl: string;
|
||||
translation: string;
|
||||
}
|
||||
|
||||
interface ConfettiPiece {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
color: string;
|
||||
size: number;
|
||||
rotation: number;
|
||||
animationDuration: number;
|
||||
animationDelay: number;
|
||||
}
|
||||
|
||||
const WordSearchPage: React.FC = () => {
|
||||
const { levelNumber } = useParams<{ levelNumber: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useLanguage();
|
||||
|
||||
const [level, setLevel] = useState<LevelData | null>(null);
|
||||
const [words, setWords] = useState<WordItem[]>([]);
|
||||
const [foundWords, setFoundWords] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [firstLetterHintsUsed, setFirstLetterHintsUsed] = useState(0);
|
||||
const [showWordModal, setShowWordModal] = useState(false);
|
||||
const [firstLetterCell, setFirstLetterCell] = useState<[number, number] | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [completed, setCompleted] = useState(false);
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
const [confetti, setConfetti] = useState<ConfettiPiece[]>([]);
|
||||
const [newFoundWord, setNewFoundWord] = useState<string | null>(null);
|
||||
const [totalScore, setTotalScore] = useState(0);
|
||||
const [alreadyCompleted, setAlreadyCompleted] = useState(false);
|
||||
const [showRules, setShowRules] = useState(false);
|
||||
const [currentLevel, setCurrentLevel] = useState('A1');
|
||||
|
||||
const { activeAchievement, showAchievement, resetAchievements } = useAchievements();
|
||||
|
||||
const messageTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const errorTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const foundWordsRef = useRef<string[]>([]);
|
||||
const completedSentRef = useRef(false);
|
||||
const firstLetterHintsUsedRef = useRef(0);
|
||||
const isMountedRef = useRef(true);
|
||||
const rulesShownRef = useRef(false);
|
||||
|
||||
const startTimeRef = useRef<number>(0);
|
||||
const perfectStreakRef = useRef<number>(0);
|
||||
|
||||
const num = parseInt(levelNumber || '1');
|
||||
const totalLevels = 550;
|
||||
const maxFirstLetterHints = 3;
|
||||
|
||||
const confettiColors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD', '#98D8C8', '#F7DC6F', '#FF8A5C', '#2ECC71'];
|
||||
|
||||
const brandColor = '#2ECC71';
|
||||
const palette = { play: brandColor };
|
||||
const textColor = '#1A1A2E';
|
||||
const subColor = '#6B7280';
|
||||
const bgColor = '#F4F5F7';
|
||||
const cardBg = 'rgba(255,255,255,0.55)';
|
||||
|
||||
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
||||
background: cardBg,
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
boxShadow: '0 8px 32px rgba(31,38,73,0.10), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
const generateConfetti = useCallback(() => {
|
||||
const pieces: ConfettiPiece[] = [];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
pieces.push({
|
||||
id: i,
|
||||
x: Math.random() * 100,
|
||||
y: -10 - Math.random() * 20,
|
||||
color: confettiColors[Math.floor(Math.random() * confettiColors.length)],
|
||||
size: Math.random() * 8 + 4,
|
||||
rotation: Math.random() * 360,
|
||||
animationDuration: Math.random() * 2 + 2,
|
||||
animationDelay: Math.random() * 1.5,
|
||||
});
|
||||
}
|
||||
return pieces;
|
||||
}, []);
|
||||
|
||||
const checkWordMatch = useCallback((cells: [number, number][]): string | null => {
|
||||
if (!level?.word_paths) return null;
|
||||
|
||||
const cellsStr = cells.map(([r, c]) => `${r},${c}`).join('|');
|
||||
|
||||
for (const [word, path] of Object.entries(level.word_paths)) {
|
||||
if (foundWordsRef.current.includes(word)) continue;
|
||||
|
||||
const pathStr = path.map(([r, c]) => `${r},${c}`).join('|');
|
||||
|
||||
if (cellsStr === pathStr || cellsStr === path.slice().reverse().map(([r, c]) => `${r},${c}`).join('|')) {
|
||||
return word;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [level]);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
if (messageTimeoutRef.current) clearTimeout(messageTimeoutRef.current);
|
||||
if (errorTimeoutRef.current) clearTimeout(errorTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setCompleted(false);
|
||||
setIsTransitioning(false);
|
||||
setConfetti([]);
|
||||
setNewFoundWord(null);
|
||||
setShowWordModal(false);
|
||||
setErrorMessage(null);
|
||||
setAlreadyCompleted(false);
|
||||
setShowRules(false);
|
||||
resetAchievements();
|
||||
completedSentRef.current = false;
|
||||
perfectStreakRef.current = 0;
|
||||
startTimeRef.current = Date.now();
|
||||
|
||||
// Автопоказ правил при первом входе
|
||||
if (!rulesShownRef.current) {
|
||||
rulesShownRef.current = true;
|
||||
const rulesSeen = localStorage.getItem('filwords_rules_wordsearch');
|
||||
if (!rulesSeen) {
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
setShowRules(true);
|
||||
localStorage.setItem('filwords_rules_wordsearch', '1');
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
getLevel(num)
|
||||
.then((data: any) => {
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
setLevel(data.level);
|
||||
setWords(data.words || []);
|
||||
|
||||
if (data.progress?.completed) {
|
||||
const allWords = (data.words || []).map((w: WordItem) => w.word_pl);
|
||||
setFoundWords(allWords);
|
||||
foundWordsRef.current = allWords;
|
||||
setCompleted(true);
|
||||
setAlreadyCompleted(true);
|
||||
const used = data.progress?.first_letter_hints_used || 0;
|
||||
setFirstLetterHintsUsed(used);
|
||||
firstLetterHintsUsedRef.current = used;
|
||||
} else {
|
||||
setFoundWords([]);
|
||||
foundWordsRef.current = [];
|
||||
setFirstLetterHintsUsed(0);
|
||||
firstLetterHintsUsedRef.current = 0;
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
console.error('Error loading level:', err);
|
||||
if (isMountedRef.current) {
|
||||
navigate('/menu');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (isMountedRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
fetchProgress()
|
||||
.then((data: any) => {
|
||||
if (isMountedRef.current) {
|
||||
setTotalScore(data.total_score || 0);
|
||||
if (data.current_level) {
|
||||
setCurrentLevel(data.current_level);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [num, navigate, resetAchievements]);
|
||||
|
||||
const handleWordFound = useCallback(async (cells: [number, number][]) => {
|
||||
if (!level || completed || isTransitioning || completedSentRef.current) return;
|
||||
|
||||
const foundWord = checkWordMatch(cells);
|
||||
|
||||
if (foundWord) {
|
||||
perfectStreakRef.current += 1;
|
||||
|
||||
if (!foundWordsRef.current.includes(foundWord)) {
|
||||
const newFoundWords = [...foundWordsRef.current, foundWord];
|
||||
foundWordsRef.current = newFoundWords;
|
||||
setFoundWords(newFoundWords);
|
||||
|
||||
setNewFoundWord(foundWord);
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
setNewFoundWord(null);
|
||||
}
|
||||
}, 600);
|
||||
|
||||
showMessage(`✅ ${foundWord}`);
|
||||
|
||||
if (window.Telegram?.WebApp?.HapticFeedback) {
|
||||
window.Telegram.WebApp.HapticFeedback.impactOccurred('medium');
|
||||
}
|
||||
|
||||
const allWordsFound = newFoundWords.length === words.length;
|
||||
|
||||
if (allWordsFound && !isTransitioning && !completedSentRef.current) {
|
||||
completedSentRef.current = true;
|
||||
setIsTransitioning(true);
|
||||
setCompleted(true);
|
||||
|
||||
setConfetti(generateConfetti());
|
||||
|
||||
if (window.Telegram?.WebApp?.HapticFeedback) {
|
||||
window.Telegram.WebApp.HapticFeedback.impactOccurred('heavy');
|
||||
}
|
||||
|
||||
if (!alreadyCompleted) {
|
||||
try {
|
||||
const completionTime = Math.floor((Date.now() - startTimeRef.current) / 1000);
|
||||
|
||||
const result: any = await completeLevel(
|
||||
level.id,
|
||||
firstLetterHintsUsedRef.current,
|
||||
completionTime,
|
||||
perfectStreakRef.current
|
||||
);
|
||||
if (isMountedRef.current) {
|
||||
if (!result.already_completed) {
|
||||
setTotalScore(prev => prev + (result.score || 0));
|
||||
}
|
||||
if (result.new_achievements && result.new_achievements.length > 0) {
|
||||
result.new_achievements.forEach((ach: any) => {
|
||||
showAchievement({
|
||||
id: ach.code,
|
||||
icon: ach.icon,
|
||||
name: ach.name,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('Error completing level:', e);
|
||||
if (isMountedRef.current) {
|
||||
showError(`⚠️ ${e.message || 'Error saving progress'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (!isMountedRef.current) return;
|
||||
const nextLevel = num + 1;
|
||||
if (nextLevel <= totalLevels) {
|
||||
navigate(`/play/${nextLevel}`);
|
||||
} else {
|
||||
navigate('/menu');
|
||||
}
|
||||
}, 2500);
|
||||
}
|
||||
}
|
||||
} else if (cells.length > 1) {
|
||||
perfectStreakRef.current = 0;
|
||||
showError(`❌ ${t('wordNotFound')}`);
|
||||
if (window.Telegram?.WebApp?.HapticFeedback) {
|
||||
window.Telegram.WebApp.HapticFeedback.impactOccurred('light');
|
||||
}
|
||||
}
|
||||
}, [level, words.length, completed, isTransitioning, alreadyCompleted, num, navigate, generateConfetti, t, checkWordMatch, showAchievement]);
|
||||
|
||||
const handleFirstLetterHint = useCallback(() => {
|
||||
if (!level) return;
|
||||
|
||||
if (firstLetterHintsUsedRef.current >= maxFirstLetterHints) {
|
||||
showError('⚠️ ' + (t('noMoreFirstLetterHints') || 'No more first letter hints'));
|
||||
return;
|
||||
}
|
||||
|
||||
const newHintsUsed = firstLetterHintsUsedRef.current + 1;
|
||||
firstLetterHintsUsedRef.current = newHintsUsed;
|
||||
setFirstLetterHintsUsed(newHintsUsed);
|
||||
|
||||
const notFound = words.find(w => !foundWordsRef.current.includes(w.word_pl));
|
||||
if (notFound) {
|
||||
const paths = level.word_paths || {};
|
||||
const path = paths[notFound.word_pl];
|
||||
if (path && path[0]) {
|
||||
setFirstLetterCell(path[0] as [number, number]);
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
setFirstLetterCell(null);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
}, [level, words, t]);
|
||||
|
||||
const handleShowWords = useCallback(() => {
|
||||
setShowWordModal(prev => !prev);
|
||||
}, []);
|
||||
|
||||
const showMessage = useCallback((msg: string) => {
|
||||
if (!isMountedRef.current) return;
|
||||
setMessage(msg);
|
||||
if (messageTimeoutRef.current) clearTimeout(messageTimeoutRef.current);
|
||||
messageTimeoutRef.current = setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
setMessage(null);
|
||||
}
|
||||
}, 1500);
|
||||
}, []);
|
||||
|
||||
const showError = useCallback((msg: string) => {
|
||||
if (!isMountedRef.current) return;
|
||||
setErrorMessage(msg);
|
||||
if (errorTimeoutRef.current) clearTimeout(errorTimeoutRef.current);
|
||||
errorTimeoutRef.current = setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
setErrorMessage(null);
|
||||
}
|
||||
}, 2000);
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: bgColor,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: subColor,
|
||||
fontFamily: fonts.main,
|
||||
fontSize: '18px',
|
||||
}}>
|
||||
{t('loading')}...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!level) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: bgColor,
|
||||
color: textColor,
|
||||
fontFamily: fonts.main,
|
||||
padding: '16px',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ position: 'fixed', top: '-80px', left: '-60px', width: '260px', height: '260px', borderRadius: '50%', background: palette.play, opacity: 0.22, filter: 'blur(90px)', pointerEvents: 'none', zIndex: 0 }} />
|
||||
<div style={{ position: 'fixed', bottom: '-50px', right: '-40px', width: '240px', height: '240px', borderRadius: '50%', background: brandColor, opacity: 0.18, filter: 'blur(80px)', pointerEvents: 'none', zIndex: 0 }} />
|
||||
|
||||
<style>{`
|
||||
@keyframes confettiFall {
|
||||
0% { transform: translateY(0) rotate(0deg); opacity: 1; }
|
||||
100% { transform: translateY(100vh) rotate(720deg); opacity: 0; }
|
||||
}
|
||||
@keyframes messagePopIn {
|
||||
0% { opacity: 0; transform: translateX(-50%) translateY(-10px); }
|
||||
100% { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
@keyframes errorShake {
|
||||
0%, 100% { transform: translateX(-50%); }
|
||||
25% { transform: translateX(calc(-50% - 5px)); }
|
||||
75% { transform: translateX(calc(-50% + 5px)); }
|
||||
}
|
||||
@keyframes fadeInUp {
|
||||
0% { transform: translateY(10px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
}
|
||||
@keyframes modalFadeIn {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
@keyframes modalSlideUp {
|
||||
0% { transform: translateY(30px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes levelCompleteGlow {
|
||||
0%, 100% { box-shadow: 0 0 15px rgba(76, 217, 100, 0.3); }
|
||||
50% { box-shadow: 0 0 30px rgba(76, 217, 100, 0.6); }
|
||||
}
|
||||
@keyframes achievementSlideDown {
|
||||
0% { transform: translateX(-50%) translateY(-80px); opacity: 0; }
|
||||
100% { transform: translateX(-50%) translateY(0); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
||||
|
||||
<RulesModal
|
||||
isOpen={showRules}
|
||||
onClose={() => setShowRules(false)}
|
||||
title={t('wordSearchRulesTitle') || '📝 Word Search Rules'}
|
||||
content={t('wordSearchRulesContent') || 'Rules content loading...'}
|
||||
/>
|
||||
|
||||
{activeAchievement && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: '16px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 900,
|
||||
background: 'linear-gradient(135deg, #FFD700, #FFA500)',
|
||||
color: '#FFFFFF',
|
||||
padding: '14px 20px',
|
||||
borderRadius: radius.lg,
|
||||
boxShadow: '0 8px 30px rgba(255, 215, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
minWidth: '250px',
|
||||
maxWidth: '90vw',
|
||||
animation: 'achievementSlideDown 0.4s ease',
|
||||
}}>
|
||||
<span style={{ fontSize: '28px', flexShrink: 0 }}>{activeAchievement.icon}</span>
|
||||
<div>
|
||||
<div style={{ fontSize: '11px', fontWeight: 600, opacity: 0.9, letterSpacing: '1px', textTransform: 'uppercase' }}>
|
||||
🏆 {t('achievement') || 'Achievement'}
|
||||
</div>
|
||||
<div style={{ fontSize: '15px', fontWeight: 800, marginTop: '2px' }}>
|
||||
{activeAchievement.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confetti.map((piece) => (
|
||||
<div
|
||||
key={piece.id}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${piece.x}%`,
|
||||
top: `${piece.y}%`,
|
||||
width: `${piece.size}px`,
|
||||
height: `${piece.size * 1.5}px`,
|
||||
backgroundColor: piece.color,
|
||||
borderRadius: '2px',
|
||||
transform: `rotate(${piece.rotation}deg)`,
|
||||
animation: `confettiFall ${piece.animationDuration}s ease-in ${piece.animationDelay}s forwards`,
|
||||
zIndex: 1000,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{showWordModal && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(20,22,35,0.25)',
|
||||
zIndex: 500,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
animation: 'modalFadeIn 0.2s ease',
|
||||
padding: '20px',
|
||||
backdropFilter: 'blur(4px)',
|
||||
WebkitBackdropFilter: 'blur(4px)',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setShowWordModal(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
...glass({
|
||||
borderRadius: radius.lg,
|
||||
padding: '20px',
|
||||
width: '100%',
|
||||
maxWidth: '400px',
|
||||
maxHeight: '70vh',
|
||||
overflow: 'auto',
|
||||
}),
|
||||
boxShadow: '0 20px 60px rgba(31,38,73,0.20), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
animation: 'modalSlideUp 0.3s ease',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px',
|
||||
paddingBottom: '12px',
|
||||
borderBottom: '1px solid rgba(0,0,0,0.06)',
|
||||
}}>
|
||||
<h3 style={{ margin: 0, fontSize: '18px', fontWeight: 700, color: textColor }}>
|
||||
📖 {t('showWords')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowWordModal(false)}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '8px 16px',
|
||||
borderRadius: radius.md,
|
||||
}),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(0.95)';
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', justifyContent: 'center' }}>
|
||||
{words.map((w) => {
|
||||
const isFound = foundWords.includes(w.word_pl);
|
||||
return (
|
||||
<div
|
||||
key={w.word_pl}
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
borderRadius: radius.md,
|
||||
background: isFound
|
||||
? `${brandColor}20`
|
||||
: cardBg,
|
||||
border: `2px solid ${isFound ? brandColor : 'rgba(0,0,0,0.06)'}`,
|
||||
color: isFound ? '#1a7a3a' : textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: isFound ? 700 : 400,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
minWidth: '80px',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '16px' }}>{w.word_pl}</span>
|
||||
{w.translation && (
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
color: isFound ? brandColor : subColor,
|
||||
fontWeight: 400,
|
||||
}}>
|
||||
{w.translation}
|
||||
</span>
|
||||
)}
|
||||
{isFound && <span style={{ fontSize: '12px' }}>✅</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginTop: '16px',
|
||||
paddingTop: '12px',
|
||||
borderTop: '1px solid rgba(0,0,0,0.06)',
|
||||
textAlign: 'center',
|
||||
fontSize: '14px',
|
||||
color: subColor,
|
||||
}}>
|
||||
{foundWords.length}/{words.length} {t('foundWords').toLowerCase()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px',
|
||||
animation: 'fadeInUp 0.3s ease',
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<button onClick={() => navigate('/menu')}
|
||||
style={{
|
||||
...glass({
|
||||
padding: '10px 18px',
|
||||
borderRadius: radius.md,
|
||||
}),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.95)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
← {t('back')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowRules(true)}
|
||||
style={{
|
||||
...glass({
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: radius.md,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}),
|
||||
color: textColor,
|
||||
fontSize: '18px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s ease',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.95)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '14px', color: subColor }}>{t('level')} {num}</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: foundWords.length === words.length ? brandColor : palette.play,
|
||||
fontWeight: foundWords.length === words.length ? 700 : 400,
|
||||
}}>
|
||||
{foundWords.length}/{words.length} {t('foundWords').toLowerCase()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'right', minWidth: '70px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 800,
|
||||
color: brandColor,
|
||||
background: `${brandColor}15`,
|
||||
padding: '4px 8px',
|
||||
borderRadius: radius.sm,
|
||||
border: `1px solid ${brandColor}40`,
|
||||
}}>
|
||||
{currentLevel}
|
||||
</span>
|
||||
<div>
|
||||
<div style={{ fontSize: '11px', color: subColor, marginBottom: '2px' }}>{t('score')}</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: 800, color: brandColor }}>
|
||||
{totalScore.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<div style={{
|
||||
position: 'absolute', top: '60px', left: '50%', transform: 'translateX(-50%)',
|
||||
padding: '10px 20px', fontSize: '14px', fontWeight: 600, color: '#FFFFFF',
|
||||
background: errorMessage.startsWith('⚠️') ? '#F39C12' : '#E74C3C',
|
||||
borderRadius: radius.md,
|
||||
boxShadow: `0 4px 12px ${errorMessage.startsWith('⚠️') ? 'rgba(243,156,18,0.4)' : 'rgba(231,76,60,0.4)'}`,
|
||||
whiteSpace: 'nowrap', zIndex: 10,
|
||||
animation: errorMessage.startsWith('⚠️') ? 'messagePopIn 0.25s ease' : 'errorShake 0.4s ease',
|
||||
}}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{completed && (
|
||||
<div style={{
|
||||
textAlign: 'center', padding: '20px', marginBottom: '12px',
|
||||
background: `${brandColor}15`,
|
||||
borderRadius: radius.lg, border: `2px solid ${brandColor}40`,
|
||||
animation: 'levelCompleteGlow 1.5s ease infinite',
|
||||
}}>
|
||||
<div style={{ fontSize: '36px', marginBottom: '8px', animation: 'pulse 0.5s ease 0.2s both' }}>🎉</div>
|
||||
<div style={{ fontSize: '20px', fontWeight: 700, color: brandColor, animation: 'fadeInUp 0.5s ease 0.3s both' }}>
|
||||
{alreadyCompleted ? t('alreadyCompleted') || 'Already Completed!' : t('levelCompleted')}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: subColor, marginTop: '8px', animation: 'fadeInUp 0.5s ease 0.5s both' }}>
|
||||
{alreadyCompleted ? (t('alreadyCompletedDesc') || 'This level was completed earlier.') : `${t('loading')}...`}
|
||||
</div>
|
||||
{alreadyCompleted && (
|
||||
<button onClick={() => { const next = num + 1; if (next <= totalLevels) navigate(`/play/${next}`); else navigate('/menu'); }}
|
||||
style={{ marginTop: '12px', padding: '10px 20px', borderRadius: radius.md, border: 'none', background: `linear-gradient(180deg, ${brandColor}E6, ${brandColor}CC)`, color: '#12271C', fontSize: '14px', fontWeight: 600, cursor: 'pointer', boxShadow: `0 8px 24px ${brandColor}50`, animation: 'fadeInUp 0.5s ease 0.7s both' }}>
|
||||
{num < totalLevels ? (t('nextLevel') || 'Next Level') + ' →' : (t('backToMenu') || 'Back to Menu')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ position: 'relative', marginBottom: '16px' }}>
|
||||
{message && (
|
||||
<div style={{
|
||||
position: 'absolute', top: '-48px', left: '50%', transform: 'translateX(-50%)',
|
||||
padding: '10px 20px', fontSize: '16px', fontWeight: 700, color: '#FFFFFF',
|
||||
background: brandColor, borderRadius: radius.md, boxShadow: `0 4px 12px ${brandColor}40`,
|
||||
whiteSpace: 'nowrap', zIndex: 10, animation: 'messagePopIn 0.25s ease', pointerEvents: 'none',
|
||||
}}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<LetterGrid
|
||||
letters={level.grid_letters} gridSize={level.grid_size}
|
||||
onWordSelect={handleWordFound} foundWords={foundWords}
|
||||
firstLetterCell={firstLetterCell} wordPaths={level.word_paths || {}}
|
||||
newFoundWord={newFoundWord}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ animation: 'fadeInUp 0.3s ease 0.1s both' }}>
|
||||
<HintPanel
|
||||
onFirstLetter={handleFirstLetterHint} onShowWords={handleShowWords}
|
||||
showWordList={showWordModal} firstLetterHintsUsed={firstLetterHintsUsed}
|
||||
maxFirstLetterHints={maxFirstLetterHints} t={t}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WordSearchPage;
|
||||
@@ -0,0 +1,48 @@
|
||||
export const colors = {
|
||||
bg: '#F5F5F7',
|
||||
bgGradient: 'linear-gradient(180deg, #FFFFFF 0%, #F5F5F7 100%)',
|
||||
card: '#FFFFFF',
|
||||
cardHover: '#F0F0F5',
|
||||
modal: 'rgba(255,255,255,0.98)',
|
||||
accent: '#6C5CE7',
|
||||
accentLight: '#A29BFE',
|
||||
accentGlow: 'rgba(108,92,231,0.25)',
|
||||
success: '#34C759',
|
||||
successGlow: 'rgba(52,199,89,0.3)',
|
||||
gold: '#F9CA24',
|
||||
goldLight: '#FFE066',
|
||||
text: '#1A1A2E',
|
||||
textSecondary: '#8E8E93',
|
||||
textTertiary: '#C7C7CC',
|
||||
warning: '#FF9500',
|
||||
danger: '#FF3B30',
|
||||
border: 'rgba(0,0,0,0.06)',
|
||||
borderStrong: 'rgba(0,0,0,0.12)',
|
||||
cellDefault: '#FFFFFF',
|
||||
cellSelected: 'rgba(108,92,231,0.15)',
|
||||
cellFound: 'rgba(52,199,89,0.2)',
|
||||
cellFirst: 'rgba(249,202,36,0.3)',
|
||||
};
|
||||
|
||||
export const fonts = {
|
||||
main: '"Inter", -apple-system, BlinkMacSystemFont, sans-serif',
|
||||
display: '"Inter", -apple-system, BlinkMacSystemFont, sans-serif',
|
||||
};
|
||||
|
||||
export const radius = {
|
||||
sm: '12px',
|
||||
md: '16px',
|
||||
lg: '20px',
|
||||
xl: '24px',
|
||||
full: '9999px',
|
||||
};
|
||||
|
||||
export const shadows = {
|
||||
card: '0 2px 16px rgba(0,0,0,0.06)',
|
||||
button: '0 2px 12px rgba(0,0,0,0.06)',
|
||||
buttonHover: '0 4px 20px rgba(0,0,0,0.10)',
|
||||
cell: '0 1px 4px rgba(0,0,0,0.04)',
|
||||
glow: '0 0 20px rgba(108,92,231,0.3)',
|
||||
glowGreen: '0 0 20px rgba(52,199,89,0.4)',
|
||||
glowOrange: '0 0 20px rgba(255,149,0,0.4)',
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
export const getInitData = (): string => {
|
||||
if (typeof window !== 'undefined' && window.Telegram?.WebApp?.initData) {
|
||||
return window.Telegram.WebApp.initData;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
export const getUserId = (): number | null => {
|
||||
if (typeof window !== 'undefined' && window.Telegram?.WebApp?.initDataUnsafe?.user?.id) {
|
||||
return window.Telegram.WebApp.initDataUnsafe.user.id;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getTelegramLang = (): string => {
|
||||
if (typeof window !== 'undefined' && window.Telegram?.WebApp?.initDataUnsafe?.user?.language_code) {
|
||||
return window.Telegram.WebApp.initDataUnsafe.user.language_code;
|
||||
}
|
||||
return "pl";
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Telegram?: {
|
||||
WebApp: {
|
||||
initData: string;
|
||||
initDataUnsafe: {
|
||||
user?: {
|
||||
id: number;
|
||||
language_code: string;
|
||||
};
|
||||
};
|
||||
ready: () => void;
|
||||
expand: () => void;
|
||||
close: () => void;
|
||||
disableVerticalSwipes: () => void;
|
||||
MainButton: {
|
||||
setText: (text: string) => void;
|
||||
show: () => void;
|
||||
hide: () => void;
|
||||
onClick: (callback: () => void) => void;
|
||||
};
|
||||
BackButton: {
|
||||
show: () => void;
|
||||
hide: () => void;
|
||||
onClick: (callback: () => void) => void;
|
||||
};
|
||||
HapticFeedback: {
|
||||
impactOccurred: (style: 'light' | 'medium' | 'heavy' | 'rigid' | 'soft') => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
chunkSizeWarningLimit: 1000,
|
||||
},
|
||||
})
|
||||