30
.github/workflows/frontend.yml
vendored
Normal file
@ -0,0 +1,30 @@
|
||||
name: Deploy frontend
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
|
||||
jobs:
|
||||
goida:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build and deploy
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- name: Deploy site
|
||||
uses: appleboy/ssh-action@v1.2.1
|
||||
with:
|
||||
host: ${{ secrets.SSH_HOST }}
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
password: ${{ secrets.SSH_PASSWORD }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
port: 22
|
||||
script: |
|
||||
cd what_is_bot
|
||||
git pull origin master
|
||||
cd frontend
|
||||
npm i
|
||||
npm run build
|
||||
chown -R :www-data dist
|
||||
|
3
frontend/.env.example
Normal file
@ -0,0 +1,3 @@
|
||||
DEBUG=true
|
||||
VITE_API_BASE_URL=https://whatisbot-api.ruka.me/api/v1
|
||||
|
25
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,25 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.env
|
3
frontend/.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
9
frontend/README.md
Normal file
@ -0,0 +1,9 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
|
||||
## Setup for development
|
||||
|
||||
Clone this repository, fill `.env.local` with your values and run with `npm run dev`!
|
14
frontend/index.html
Normal file
@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
<title>Vite + Vue + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
3393
frontend/package-lock.json
generated
Normal file
27
frontend/package.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev-host": "vite --host",
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.12.1",
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.18",
|
||||
"vue-tg": "^0.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.1",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.1.2",
|
||||
"vite-plugin-vue-devtools": "^8.0.2",
|
||||
"vue-tsc": "^3.0.5"
|
||||
}
|
||||
}
|
3
frontend/public/flash.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg version="1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" enable-background="new 0 0 48 48">
|
||||
<polygon fill="#FFC107" points="33,22 23.6,22 30,5 19,5 13,26 21.6,26 17,45"/>
|
||||
</svg>
|
After Width: | Height: | Size: 196 B |
BIN
frontend/public/logo.png
Normal file
After Width: | Height: | Size: 10 KiB |
10
frontend/public/logo.svg
Normal file
After Width: | Height: | Size: 269 KiB |
6
frontend/public/refresh.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 32 32" version="1.1">
|
||||
<g id="surface1">
|
||||
<path style=" " d="M 16 4 C 10.886719 4 6.617188 7.160156 4.875 11.625 L 6.71875 12.375 C 8.175781 8.640625 11.710938 6 16 6 C 19.242188 6 22.132813 7.589844 23.9375 10 L 20 10 L 20 12 L 27 12 L 27 5 L 25 5 L 25 8.09375 C 22.808594 5.582031 19.570313 4 16 4 Z M 25.28125 19.625 C 23.824219 23.359375 20.289063 26 16 26 C 12.722656 26 9.84375 24.386719 8.03125 22 L 12 22 L 12 20 L 5 20 L 5 27 L 7 27 L 7 23.90625 C 9.1875 26.386719 12.394531 28 16 28 C 21.113281 28 25.382813 24.839844 27.125 20.375 Z "/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 693 B |
BIN
frontend/public/scan_example.png
Normal file
After Width: | Height: | Size: 99 KiB |
16
frontend/public/stol.svg
Normal file
After Width: | Height: | Size: 583 KiB |
1
frontend/public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
After Width: | Height: | Size: 1.5 KiB |
160
frontend/src/App.vue
Normal file
@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
import './style.css';
|
||||
import { useBackButton, useWebApp } from 'vue-tg';
|
||||
import { useAppStateStore } from './store/core';
|
||||
import { Debug } from "./utils/debug"
|
||||
|
||||
import CameraScreen from './screens/CameraScreen.vue';
|
||||
import LandingScreen from './screens/LandingScreen.vue';
|
||||
import ResultScreen from './screens/ResultScreen.vue';
|
||||
import QuizScreen from './screens/QuizScreen.vue';
|
||||
|
||||
import Room from './screens/game/Room.vue';
|
||||
import GameWelcomeScreen from './screens/game/GameWelcomeScreen.vue';
|
||||
import GameSettingsScreen from './screens/game/GameSettingsScreen.vue';
|
||||
|
||||
import { onMounted } from 'vue';
|
||||
import { auth, initializeAuth, isAuthenticated, joinRoom } from './api/request';
|
||||
import type { AuthRequest } from './api/models';
|
||||
|
||||
const store = useAppStateStore()
|
||||
const backButton = useBackButton()
|
||||
const webapp = useWebApp()
|
||||
|
||||
|
||||
if (backButton.onClick !== undefined) {
|
||||
backButton.onClick(returnToLanding)
|
||||
}
|
||||
|
||||
function returnToLanding() {
|
||||
store.$patch({appState: "landing"})
|
||||
if (backButton.hide !== undefined) {
|
||||
backButton.hide()
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticateUser() {
|
||||
try {
|
||||
const initData = webapp.initDataUnsafe
|
||||
|
||||
if (!initData?.user) {
|
||||
throw new Error('No user data available from Telegram')
|
||||
}
|
||||
|
||||
const authRequest: AuthRequest = {
|
||||
username: initData.user.username || `${initData.user.first_name}_${initData.user.id}`,
|
||||
tg_id: initData.user.id,
|
||||
last_name: initData.user.last_name ? initData.user.last_name : "",
|
||||
first_name: initData.user.first_name,
|
||||
photo_url: initData.user.photo_url ? initData.user.photo_url : ""
|
||||
}
|
||||
|
||||
Debug.log('Authenticating user:', authRequest.username)
|
||||
|
||||
const authResponse = await auth(authRequest)
|
||||
|
||||
if (authResponse.token !== undefined || authResponse.token !== null) {
|
||||
Debug.log('Authentication successful')
|
||||
await handleStartParameter()
|
||||
} else {
|
||||
throw new Error('Authentication failed')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Authentication error:', error)
|
||||
|
||||
if (Debug.enabled) {
|
||||
Debug.log('Auth failed in debug mode, allowing bypass')
|
||||
await handleStartParameter()
|
||||
} else {
|
||||
store.$patch({appState: "error_auth"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStartParameter() {
|
||||
try {
|
||||
const startParam = webapp.initDataUnsafe.start_param
|
||||
|
||||
Debug.log('Start parameter:', startParam)
|
||||
|
||||
if (startParam && startParam.startsWith('room_')) {
|
||||
const roomId = startParam.substring(5)
|
||||
|
||||
Debug.log('Attempting to join room:', roomId)
|
||||
|
||||
try {
|
||||
const joinResponse = await joinRoom(roomId)
|
||||
|
||||
if (joinResponse.success) {
|
||||
Debug.log('Successfully joined room:', roomId)
|
||||
store.$patch({
|
||||
room_id: roomId,
|
||||
appState: 'game_room'
|
||||
})
|
||||
return
|
||||
} else {
|
||||
console.error('Failed to join room:', joinResponse.message)
|
||||
store.$patch({appState: "landing"})
|
||||
}
|
||||
} catch (joinError) {
|
||||
console.error('Error joining room:', joinError)
|
||||
store.$patch({appState: "landing"})
|
||||
}
|
||||
} else {
|
||||
store.$patch({appState: "landing"})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error handling start parameter:', error)
|
||||
store.$patch({appState: "landing"})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const existingToken = initializeAuth()
|
||||
|
||||
if (webapp.platform != "android" && webapp.platform != "android_x" && webapp.platform !== "ios") {
|
||||
if (!(Debug.enabled && webapp.platform == "unknown")) {
|
||||
console.log(webapp.platform)
|
||||
store.$patch({appState: "error_not_mobile"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (existingToken && isAuthenticated()) {
|
||||
Debug.log('Found existing auth token')
|
||||
store.setAuthenticationStatus(true)
|
||||
// Проверяем параметр start для уже аутентифицированных пользователей
|
||||
await handleStartParameter()
|
||||
} else {
|
||||
Debug.log('No valid auth token, starting authentication')
|
||||
await authenticateUser()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LandingScreen v-if="store.appState == 'landing'"/>
|
||||
<CameraScreen v-if="store.appState == 'camera'" />
|
||||
<ResultScreen v-if="store.appState == 'results'" />
|
||||
<QuizScreen v-if="store.appState == 'quiz'" />
|
||||
<GameWelcomeScreen v-if="store.appState == 'game'" />
|
||||
<GameSettingsScreen v-if="store.appState == 'game_settings'" />
|
||||
<Room v-if="store.appState == 'game_room'" />
|
||||
|
||||
<div class="error" v-if="store.appState == 'error_not_mobile'">
|
||||
<div class="error-icon">❌</div>
|
||||
<div class="error-title">Требуется телефон</div>
|
||||
<div class="error-message">
|
||||
Вам потребуется камера для использования нашего приложения, поэтому используйте его из мобильной версии.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="error" v-if="store.appState == 'error_auth'">
|
||||
<div class="error-icon">❌</div>
|
||||
<div class="error-title">Хорошая попытка</div>
|
||||
<div class="error-message">
|
||||
Но приложение доступно только из Telegram
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
96
frontend/src/api/models.ts
Normal file
@ -0,0 +1,96 @@
|
||||
export type ApiError = {
|
||||
error: string
|
||||
};
|
||||
|
||||
export interface ImageDetectionResponse {
|
||||
example_russian: string;
|
||||
example_tatar: string;
|
||||
object_russian: string;
|
||||
object_tatar: string;
|
||||
pronunciation: string;
|
||||
transcription_ipa: string;
|
||||
description_russian: string;
|
||||
description_tatar: string;
|
||||
}
|
||||
|
||||
export interface ImageDetectionRequest {
|
||||
file: File;
|
||||
}
|
||||
|
||||
|
||||
export interface TextToSpeechRequest {
|
||||
speaker: string;
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface TextToSpeechResponse {
|
||||
audio_base64: string
|
||||
}
|
||||
|
||||
|
||||
export interface AuthRequest {
|
||||
first_name: string,
|
||||
last_name: string,
|
||||
photo_url: string,
|
||||
tg_id: number,
|
||||
username: string
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
first_name: string,
|
||||
last_name: string,
|
||||
photo_url: string,
|
||||
tg_id: number,
|
||||
username: string
|
||||
token: string
|
||||
}
|
||||
|
||||
|
||||
export interface Room {
|
||||
created_at: string;
|
||||
created_by: string;
|
||||
id: string;
|
||||
name: string;
|
||||
updated_at: string;
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
export interface RoomMember {
|
||||
first_name: string;
|
||||
id: string;
|
||||
joined_at: string;
|
||||
photo_url: string;
|
||||
room_id: string;
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
export interface RoomMembersResponse {
|
||||
members: RoomMember[];
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface UserRoomsRespomse {
|
||||
rooms: Room[];
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface JoinRoomResponse {
|
||||
message: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface CreateRoomRequest {
|
||||
rounds_count: number;
|
||||
word_category: string;
|
||||
}
|
||||
|
||||
export interface StartGameRequest {
|
||||
room_id: string;
|
||||
}
|
||||
|
||||
export interface StartGameResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
game_id?: string;
|
||||
error?: string;
|
||||
}
|
381
frontend/src/api/request.ts
Normal file
@ -0,0 +1,381 @@
|
||||
import axios, { type AxiosResponse } from 'axios';
|
||||
import { type ImageDetectionResponse, type ImageDetectionRequest, type TextToSpeechResponse, type AuthRequest, type AuthResponse, type Room, type JoinRoomResponse, type RoomMember, type RoomMembersResponse, type UserRoomsRespomse, type CreateRoomRequest, type StartGameResponse } from './models';
|
||||
|
||||
// Configure base URL - adjust this to match your backend URL
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
|
||||
|
||||
|
||||
export async function authenticateUser(authData: AuthRequest): Promise<AuthResponse> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/auth`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(authData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Authentication failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data: AuthResponse = await response.json();
|
||||
|
||||
if (data.token) {
|
||||
localStorage.setItem('authToken', data.token);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Auth request failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function getAuthToken(): string | null {
|
||||
return localStorage.getItem('authToken');
|
||||
}
|
||||
|
||||
export function clearAuthToken(): void {
|
||||
localStorage.removeItem('authToken');
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
return !!getAuthToken();
|
||||
}
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
/**
|
||||
* Send image file to the image detection endpoint
|
||||
* @param file - The image file to be analyzed
|
||||
* @returns Promise with the detection results
|
||||
*/
|
||||
export const detectImageObjects = async(file: File) => {
|
||||
try {
|
||||
// Create FormData to send the file
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response: AxiosResponse<ImageDetectionResponse> = await apiClient.post(
|
||||
'/image-detection',
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
// Handle API errors
|
||||
if (error.response?.data) {
|
||||
throw new Error(error.response.data.error || 'Image detection failed');
|
||||
}
|
||||
throw new Error(error.message || 'Network error occurred');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const textToSpeech = async(text: string): Promise<TextToSpeechResponse> => {
|
||||
try {
|
||||
const data = {speaker: "almaz", text: text}
|
||||
|
||||
const response: AxiosResponse<TextToSpeechResponse> = await apiClient.post(
|
||||
"/text-to-speech", JSON.stringify(data), {headers: {'Content-Type': 'application/json'}}
|
||||
);
|
||||
|
||||
return response.data
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (error.response?.data) {
|
||||
throw new Error(error.response.data.error || 'Text-to-speech failed');
|
||||
}
|
||||
throw new Error(error.message || 'Network error occurred');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alternative function that accepts ImageDetectionRequest object
|
||||
* @param request - Object containing the file to be analyzed
|
||||
* @returns Promise with the detection results
|
||||
*/
|
||||
export const detectImage = async (request: ImageDetectionRequest): Promise<ImageDetectionResponse> => {
|
||||
return detectImageObjects(request.file);
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload image file with progress tracking
|
||||
* @param file - The image file to be analyzed
|
||||
* @param onProgress - Optional callback to track upload progress
|
||||
* @returns Promise with the detection results
|
||||
*/
|
||||
export const detectImageWithProgress = async (
|
||||
file: File,
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<ImageDetectionResponse> => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response: AxiosResponse<ImageDetectionResponse> = await apiClient.post(
|
||||
'/image-detection',
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (onProgress && progressEvent.total) {
|
||||
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||
onProgress(progress);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (error.response?.data) {
|
||||
throw new Error(error.response.data.error || 'Image detection failed');
|
||||
}
|
||||
throw new Error(error.message || 'Network error occurred');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate file before sending to API
|
||||
* @param file - The file to validate
|
||||
* @returns true if valid, throws error if invalid
|
||||
*/
|
||||
export const validateImageFile = (file: File): boolean => {
|
||||
// Check file type
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
throw new Error('Invalid file type. Please upload a JPEG, PNG, GIF, or WebP image.');
|
||||
}
|
||||
|
||||
// Check file size (10MB limit)
|
||||
const maxSize = 10 * 1024 * 1024; // 10MB in bytes
|
||||
if (file.size > maxSize) {
|
||||
throw new Error('File size too large. Please upload an image smaller than 10MB.');
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Authenticate user with the backend
|
||||
* @param authData - User authentication data from Telegram
|
||||
* @returns Promise with authentication response including token
|
||||
*/
|
||||
export const auth = async (authData: AuthRequest): Promise<AuthResponse> => {
|
||||
try {
|
||||
const response: AxiosResponse<AuthResponse> = await apiClient.post(
|
||||
'/auth',
|
||||
authData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data.token) {
|
||||
localStorage.setItem('auth_token', response.data.token);
|
||||
|
||||
apiClient.defaults.headers.common['Authorization'] = `Bearer ${response.data.token}`;
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (error.response?.data) {
|
||||
throw new Error(error.response.data.error || 'Authentication failed');
|
||||
}
|
||||
throw new Error(error.message || 'Network error occurred during authentication');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const createRoom = async (roomParams: CreateRoomRequest): Promise<Room> => {
|
||||
try {
|
||||
const response: AxiosResponse<Room> = await apiClient.post("/rooms", roomParams, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (response.data.id) {
|
||||
return response.data
|
||||
} else {
|
||||
throw new Error(response.data.error ? response.data.error : "Unknown error while creating room")
|
||||
}
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (error.response?.data) {
|
||||
throw new Error(error.response.data.error || 'Unknown error while creating room');
|
||||
}
|
||||
throw new Error(error.message || 'Network error occurred during creating room');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const joinRoom = async (room_id: string): Promise<JoinRoomResponse> => {
|
||||
try {
|
||||
const response: AxiosResponse<JoinRoomResponse> = await apiClient.post("/rooms/join", {room_id: room_id}, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (response.data.success) {
|
||||
return response.data
|
||||
} else {
|
||||
throw new Error(response.data.message ? response.data.message : "Unknown error while joining room")
|
||||
}
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (error.response?.data) {
|
||||
throw new Error(error.response.data.error || 'Unknown error while joining room');
|
||||
}
|
||||
throw new Error(error.message || 'Network error occurred during joining room');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const getRoom = async (room_id: string): Promise<Room> => {
|
||||
try {
|
||||
const response: AxiosResponse<Room> = await apiClient.get(`/rooms/${room_id}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (response.data.id) {
|
||||
return response.data
|
||||
} else {
|
||||
throw new Error(response.data.error ? response.data.error : "Unknown error while fetching room information")
|
||||
}
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (error.response?.data) {
|
||||
throw new Error(error.response.data.error || 'Unknown error while fetching room information');
|
||||
}
|
||||
throw new Error(error.message || 'Network error occurred during joining room');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const getRoomMembers = async (room_id: string): Promise<RoomMember[]> => {
|
||||
try {
|
||||
const response: AxiosResponse<RoomMembersResponse> = await apiClient.get(`/rooms/${room_id}/members`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (response.data.members) {
|
||||
return response.data.members
|
||||
} else {
|
||||
throw new Error(response.data.error ? response.data.error : "Unknown error while fetching room members")
|
||||
}
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (error.response?.data) {
|
||||
throw new Error(error.response.data.error || 'Unknown error while fetching room members');
|
||||
}
|
||||
throw new Error(error.message || 'Network error occurred during fethcing room members');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const getUserRooms = async (room_id: string): Promise<Room[]> => {
|
||||
try {
|
||||
const response: AxiosResponse<UserRoomsRespomse> = await apiClient.get(`/users/${room_id}/rooms`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (response.data.rooms) {
|
||||
return response.data.rooms
|
||||
} else {
|
||||
throw new Error(response.data.error ? response.data.error : "Unknown error while fetching user rooms")
|
||||
}
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (error.response?.data) {
|
||||
throw new Error(error.response.data.error || 'Unknown error while fetching user rooms');
|
||||
}
|
||||
throw new Error(error.message || 'Network error occurred during fethcing user rooms');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const startGame = async (roomId: string): Promise<StartGameResponse> => {
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
|
||||
const response: AxiosResponse<StartGameResponse> = await apiClient.post('/games/start',
|
||||
{ room_id: roomId },
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { 'Authorization': `Bearer ${token}` })
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data.success) {
|
||||
return response.data;
|
||||
} else {
|
||||
throw new Error(response.data.message || response.data.error || "Unknown error while starting game");
|
||||
}
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (error.response?.data) {
|
||||
throw new Error(error.response.data.error || error.response.data.message || 'Unknown error while starting game');
|
||||
}
|
||||
throw new Error(error.message || 'Network error occurred during starting game');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const initializeAuth = (): string | null => {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (token) {
|
||||
apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return token;
|
||||
};
|
||||
|
||||
export default {
|
||||
detectImageObjects,
|
||||
detectImage,
|
||||
detectImageWithProgress,
|
||||
validateImageFile,
|
||||
textToSpeech,
|
||||
auth,
|
||||
initializeAuth,
|
||||
startGame,
|
||||
};
|
1
frontend/src/assets/vue.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
After Width: | Height: | Size: 496 B |
90
frontend/src/components/PlayerCard.vue
Normal file
@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { type RoomMember } from '../api/models';
|
||||
|
||||
defineProps<{
|
||||
member: RoomMember;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="player-card">
|
||||
<div class="player-avatar">
|
||||
{{ member.first_name.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<div class="player-info">
|
||||
<div class="player-name">{{ member.first_name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.player-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.player-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: 'OpenSans', sans-serif;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.player-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.player-name {
|
||||
font-family: 'OpenSans', sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: rgba(254, 254, 254, 1);
|
||||
}
|
||||
|
||||
.player-status {
|
||||
font-family: 'OpenSans', sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.player-status.online {
|
||||
color: #90EE90;
|
||||
}
|
||||
|
||||
.player-status.offline {
|
||||
color: rgba(254, 254, 254, 0.5);
|
||||
}
|
||||
|
||||
.player-status.ready {
|
||||
color: #FFD700;
|
||||
}
|
||||
|
||||
.player-status.playing {
|
||||
color: #FF6B6B;
|
||||
}
|
||||
|
||||
.player-score {
|
||||
font-family: 'OpenSans', sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #90EE90;
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
11
frontend/src/main.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import './utils/debug' // Initialize debug mode
|
||||
|
||||
const pinia = createPinia()
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(pinia)
|
||||
app.mount('#app')
|
507
frontend/src/screens/CameraScreen.vue
Normal file
@ -0,0 +1,507 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onUnmounted, onMounted } from 'vue';
|
||||
import { useAppStateStore } from '../store/core';
|
||||
import { useBackButton } from 'vue-tg';
|
||||
import { detectImageObjects, validateImageFile } from '../api/request';
|
||||
|
||||
declare global {
|
||||
interface MediaTrackCapabilities {
|
||||
torch?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface MediaTrackConstraintSet {
|
||||
torch?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
// брат ты буквально вызываешь init дважды, один раз в onMounted,
|
||||
// второй раз сразу при объявлении функции
|
||||
// типо из-за этого у тя камера багается
|
||||
|
||||
const video = ref<HTMLVideoElement | null>(null);
|
||||
const store = useAppStateStore();
|
||||
const backButton = useBackButton();
|
||||
|
||||
let frontStream: MediaStream | null = null;
|
||||
let backStream: MediaStream | null = null;
|
||||
let currentStream: MediaStream | null = null;
|
||||
let inactivityTimer: number | null = null;
|
||||
|
||||
const currentCamera = ref<'user' | 'environment'>('environment');
|
||||
const isLoading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const isSleeping = ref(true);
|
||||
|
||||
const flashSupported = ref(false);
|
||||
const flashOn = ref(false);
|
||||
|
||||
|
||||
async function startCamera() {
|
||||
if (currentStream) return;
|
||||
isSleeping.value = false;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
throw new Error('Камера недоступна. Убедитесь, что сайт открыт по HTTPS.');
|
||||
}
|
||||
|
||||
let streamToUse: MediaStream | null = null;
|
||||
if (currentCamera.value === 'user' && frontStream) {
|
||||
streamToUse = frontStream;
|
||||
} else if (currentCamera.value === 'environment' && backStream) {
|
||||
streamToUse = backStream;
|
||||
}
|
||||
|
||||
if (streamToUse) {
|
||||
streamToUse.getTracks().forEach(track => track.enabled = true);
|
||||
} else {
|
||||
streamToUse = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
facingMode: { ideal: currentCamera.value },
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 1280 }
|
||||
}
|
||||
});
|
||||
if (currentCamera.value === 'user') {
|
||||
frontStream = streamToUse;
|
||||
} else {
|
||||
backStream = streamToUse;
|
||||
}
|
||||
}
|
||||
|
||||
currentStream = streamToUse;
|
||||
if (video.value) {
|
||||
video.value.srcObject = currentStream;
|
||||
video.value.play();
|
||||
}
|
||||
|
||||
await checkFlashSupport();
|
||||
resetInactivityTimer();
|
||||
|
||||
} catch (err) {
|
||||
console.error('Ошибка при инициализации камеры:', err);
|
||||
if (err instanceof Error) {
|
||||
if (err.name === 'NotAllowedError') {
|
||||
error.value = 'Вы не разрешили доступ к камере. Проверьте настройки браузера.';
|
||||
} else if (err.name === 'NotFoundError') {
|
||||
error.value = 'Камера не найдена. Убедитесь, что она подключена и работает.';
|
||||
} else {
|
||||
error.value = 'Не удалось получить доступ к камере. Попробуйте перезагрузить страницу.';
|
||||
}
|
||||
} else {
|
||||
error.value = 'Произошла неизвестная ошибка с камерой.';
|
||||
}
|
||||
isSleeping.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function stopAllStreams() {
|
||||
[frontStream, backStream].forEach(s => {
|
||||
if (s) s.getTracks().forEach(track => track.enabled = false);
|
||||
});
|
||||
if (video.value) video.value.srcObject = null;
|
||||
currentStream = null;
|
||||
flashOn.value = false;
|
||||
flashSupported.value = false;
|
||||
}
|
||||
|
||||
function goToSleep() {
|
||||
console.log("Камера уходит в сон...");
|
||||
stopAllStreams();
|
||||
isSleeping.value = true;
|
||||
if (inactivityTimer) clearTimeout(inactivityTimer);
|
||||
}
|
||||
|
||||
function resetInactivityTimer() {
|
||||
if (inactivityTimer) clearTimeout(inactivityTimer);
|
||||
inactivityTimer = setTimeout(goToSleep, 30000); // 30 секунд бездействия
|
||||
}
|
||||
|
||||
async function wakeUpCamera() {
|
||||
if(isSleeping.value) {
|
||||
await startCamera();
|
||||
}
|
||||
}
|
||||
|
||||
// initCamera('environment'); // вот этот нах не нуж
|
||||
|
||||
onMounted(() => {
|
||||
// показываем кнопку для перехода на лендинг. работает только в миниаппке
|
||||
if (backButton.show !== undefined && backButton.onClick !== undefined) {
|
||||
backButton.onClick(() => {
|
||||
store.$patch({appState: "landing"})
|
||||
})
|
||||
backButton.show()
|
||||
}
|
||||
// Автоматически запускаем камеру при открытии экрана
|
||||
// таймер бездействия будет инициализирован внутри startCamera()
|
||||
startCamera();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// Останавливаем и очищаем все потоки при уходе со страницы
|
||||
[frontStream, backStream].forEach(s => {
|
||||
if (s) {
|
||||
try {
|
||||
s.getTracks().forEach(track => track.stop());
|
||||
} catch (e) {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
});
|
||||
frontStream = null;
|
||||
backStream = null;
|
||||
currentStream = null;
|
||||
if (inactivityTimer) {
|
||||
clearTimeout(inactivityTimer);
|
||||
inactivityTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function switchCamera() {
|
||||
if (isLoading.value) return;
|
||||
currentCamera.value = currentCamera.value === 'user' ? 'environment' : 'user';
|
||||
stopAllStreams();
|
||||
await startCamera();
|
||||
resetInactivityTimer();
|
||||
}
|
||||
|
||||
async function checkFlashSupport() {
|
||||
if (!currentStream) {
|
||||
flashSupported.value = false;
|
||||
return;
|
||||
}
|
||||
const track = currentStream.getVideoTracks()[0];
|
||||
const capabilities = track.getCapabilities();
|
||||
|
||||
flashSupported.value = !!capabilities.torch;
|
||||
if (!flashSupported.value) {
|
||||
flashOn.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFlash() {
|
||||
if (!flashSupported.value || !currentStream) return;
|
||||
const track = currentStream.getVideoTracks()[0];
|
||||
try {
|
||||
await track.applyConstraints({
|
||||
|
||||
advanced: [{ torch: !flashOn.value }]
|
||||
});
|
||||
flashOn.value = !flashOn.value;
|
||||
resetInactivityTimer();
|
||||
} catch(err) {
|
||||
console.error("Вспышка не сработала:", err);
|
||||
error.value = "Не удалось управлять вспышкой";
|
||||
}
|
||||
}
|
||||
|
||||
async function takePhoto() {
|
||||
if (!video.value || isLoading.value || !currentStream) return;
|
||||
resetInactivityTimer();
|
||||
error.value = null;
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.value.videoWidth;
|
||||
canvas.height = video.value.videoHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('Не удалось создать контекст для обработки изображения');
|
||||
|
||||
ctx.drawImage(video.value, 0, 0, canvas.width, canvas.height);
|
||||
const imageDataUrl = canvas.toDataURL('image/jpeg', 0.8);
|
||||
|
||||
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.8));
|
||||
if (!blob) throw new Error('Не удалось создать изображение');
|
||||
|
||||
const file = new File([blob], 'camera-capture.jpg', { type: 'image/jpeg' });
|
||||
validateImageFile(file);
|
||||
|
||||
store.$patch({ capturedImage: imageDataUrl });
|
||||
|
||||
// после того как фотка сделана, можн успыть
|
||||
goToSleep();
|
||||
|
||||
const result = await detectImageObjects(file);
|
||||
|
||||
store.$patch({
|
||||
appState: "results",
|
||||
title: result.object_russian,
|
||||
description: result.description_russian,
|
||||
transcription: result.transcription_ipa,
|
||||
exampleRussian: result.example_russian,
|
||||
exampleTatar: result.example_tatar,
|
||||
objectRussian: result.object_russian,
|
||||
objectTatar: result.object_tatar,
|
||||
pronunciation: result.pronunciation,
|
||||
transcriptionIPA: result.transcription_ipa,
|
||||
capturedImage: imageDataUrl,
|
||||
descriptionTatar: result.description_tatar,
|
||||
});
|
||||
|
||||
if (backButton.onClick !== undefined) {
|
||||
backButton.onClick(backToCamera);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error processing photo:', err);
|
||||
error.value = err instanceof Error ? err.message : 'Произошла ошибка при обработке фото';
|
||||
await wakeUpCamera();
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function backToCamera() {
|
||||
store.$patch({ appState: "camera", capturedImage: null });
|
||||
// камера по клику проснется
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="camera-screen" @click="wakeUpCamera">
|
||||
|
||||
<div v-if="isSleeping && !isLoading" class="sleep-overlay">
|
||||
<div class="sleep-icon">📷</div>
|
||||
<div class="sleep-text">Нажмите, чтобы включить камеру</div>
|
||||
</div>
|
||||
|
||||
<video ref="video" autoplay playsinline class="camera-video" :class="{ 'hidden': isSleeping }"></video>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="loading-overlay">
|
||||
<img v-if="store.capturedImage" :src="store.capturedImage" class="loading-bg-image" />
|
||||
<div class="scan-line"></div>
|
||||
<div class="loading-text">Анализ...</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isSleeping && !isLoading" class="controls-wrapper">
|
||||
<div class="side-controls left">
|
||||
<button
|
||||
v-if="flashSupported"
|
||||
class="control-btn"
|
||||
@click.stop="toggleFlash"
|
||||
:class="{ 'active': flashOn }"
|
||||
title="Вспышка"
|
||||
>
|
||||
<img src="/flash.svg">
|
||||
</button>
|
||||
</div>
|
||||
<div class="center-control">
|
||||
<button
|
||||
class="camera-btn"
|
||||
@click.stop="takePhoto"
|
||||
></button>
|
||||
</div>
|
||||
<div class="side-controls right">
|
||||
<button
|
||||
class="control-btn"
|
||||
@click.stop="switchCamera"
|
||||
title="Сменить камеру"
|
||||
>
|
||||
<img src="/refresh.svg">
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.camera-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
position: relative;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.camera-video {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.camera-video.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sleep-overlay {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
z-index: 10;
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.sleep-icon {
|
||||
font-size: 60px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.sleep-text {
|
||||
margin-top: 16px;
|
||||
font-size: 16px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(5px);
|
||||
overflow: hidden;
|
||||
}
|
||||
.loading-bg-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0.3;
|
||||
filter: blur(8px);
|
||||
z-index: -1;
|
||||
}
|
||||
.loading-text {
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
text-align: center;
|
||||
z-index: 2;
|
||||
text-shadow: 0 0 10px rgba(0,0,0,0.5);
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.scan-line {
|
||||
position: absolute;
|
||||
top: -10%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg, transparent, rgba(0, 255, 255, 0.8), transparent);
|
||||
box-shadow: 0 0 15px 2px rgba(0, 255, 255, 0.7);
|
||||
animation: scan 2.5s linear infinite;
|
||||
z-index: 1;
|
||||
}
|
||||
@keyframes scan {
|
||||
0% { top: -10%; }
|
||||
100% { top: 110%; }
|
||||
}
|
||||
|
||||
/* --- Новые контролы --- */
|
||||
.controls-wrapper {
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
bottom: 50px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 30px;
|
||||
z-index: 20;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.center-control {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.side-controls {
|
||||
width: 60px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.side-controls.left { justify-content: flex-start; }
|
||||
.side-controls.right { justify-content: flex-end; }
|
||||
|
||||
.control-btn {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
font-size: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s ease, transform 0.2s ease;
|
||||
color: white;
|
||||
}
|
||||
.control-btn:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
.control-btn.active {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.camera-btn {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
border: 5px solid #fff;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
.camera-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.camera-btn:active::before {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(255, 59, 48, 0.9);
|
||||
color: white;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
max-width: 90%;
|
||||
font-size: 14px;
|
||||
z-index: 50;
|
||||
}
|
||||
</style>
|
32
frontend/src/screens/LandingScreen.vue
Normal file
@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { useAppStateStore } from '../store/core';
|
||||
import { useBackButton } from 'vue-tg';
|
||||
import { defineEmits } from 'vue';
|
||||
|
||||
const store = useAppStateStore()
|
||||
const backButton = useBackButton()
|
||||
const emit = defineEmits(['logout'])
|
||||
|
||||
function openCamera() {
|
||||
store.$patch({appState: "camera"})
|
||||
if (backButton.show !== undefined) {
|
||||
backButton.show()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="landing" v-if="store.appState == 'landing'">
|
||||
<img src="/logo.svg" class="landing-logo" alt="Logo">
|
||||
<div class="bubbles">
|
||||
<div class="bubble">Сканируй мир</div>
|
||||
<div class="bubble">Изучай татарский</div>
|
||||
</div>
|
||||
<img src="/scan_example.png" alt="scan example" class="landing-image" />
|
||||
|
||||
<div class="bottom-app-button">
|
||||
<button class="start-btn" @click="openCamera">Сканировать</button>
|
||||
<button class="game-btn" @click="store.$patch({appState: 'game'})">Игра</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
195
frontend/src/screens/QuizScreen.vue
Normal file
@ -0,0 +1,195 @@
|
||||
<script setup lang="ts">
|
||||
import { MainButton, useBackButton } from 'vue-tg';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useAppStateStore } from '../store/core';
|
||||
import { Debug } from '../utils/debug';
|
||||
|
||||
const selectedVariant = ref("");
|
||||
const correctVariant = ref("Яблоко");
|
||||
const buttonTitle = ref("Подтвердить")
|
||||
const isAnswered = ref(false);
|
||||
|
||||
const store = useAppStateStore()
|
||||
const backButton = useBackButton()
|
||||
|
||||
onMounted(() => {
|
||||
if (backButton.show !== undefined && backButton.onClick !== undefined) {
|
||||
backButton.onClick(() => {
|
||||
store.$patch({appState: "landing"})
|
||||
})
|
||||
backButton.show()
|
||||
}
|
||||
})
|
||||
|
||||
function quizAnswerSubmit() {
|
||||
if (isAnswered.value) {
|
||||
selectedVariant.value = "";
|
||||
isAnswered.value = false;
|
||||
buttonTitle.value = "Подтвердить";
|
||||
return;
|
||||
}
|
||||
|
||||
// тут будет отправка ответа в апи, но нужно сделать контракт. короче todo: add backend integration
|
||||
isAnswered.value = true;
|
||||
buttonTitle.value = "Следующий вопрос";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="quiz">
|
||||
<h1 class="quiz-title">Квиз</h1>
|
||||
<h2 class="quiz-question">Выберите правильный перевод</h2>
|
||||
|
||||
<div class="chat-bubble">
|
||||
өчпочмак
|
||||
</div>
|
||||
|
||||
<div class="quiz-variants">
|
||||
<div
|
||||
class="quiz-variant"
|
||||
:class="{
|
||||
success: isAnswered && 'Яблоко' === correctVariant,
|
||||
fail: isAnswered && 'Яблоко' !== correctVariant,
|
||||
selected: selectedVariant === 'Яблоко',
|
||||
disabled: isAnswered
|
||||
}"
|
||||
@click="!isAnswered && (selectedVariant = 'Яблоко')"
|
||||
>
|
||||
Яблоко
|
||||
</div>
|
||||
<div
|
||||
class="quiz-variant"
|
||||
:class="{
|
||||
success: isAnswered && 'Пирог' === correctVariant,
|
||||
fail: isAnswered && selectedVariant == 'Пирог' && 'Пирог' !== correctVariant,
|
||||
selected: selectedVariant === 'Пирог',
|
||||
disabled: isAnswered
|
||||
}"
|
||||
@click="!isAnswered && (selectedVariant = 'Пирог')"
|
||||
>
|
||||
Пирог
|
||||
</div>
|
||||
<div
|
||||
class="quiz-variant"
|
||||
:class="{
|
||||
success: isAnswered && 'Булочка' === correctVariant,
|
||||
fail: isAnswered && selectedVariant == 'Булочка' && 'Булочка' !== correctVariant,
|
||||
selected: selectedVariant === 'Булочка',
|
||||
disabled: isAnswered
|
||||
}"
|
||||
@click="!isAnswered && (selectedVariant = 'Булочка')"
|
||||
>
|
||||
Булочка
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MainButton v-if="selectedVariant !== '' && !Debug.enabled" :text="buttonTitle" @click="quizAnswerSubmit" />
|
||||
<div class="bottom-app-button" v-if="Debug.enabled">
|
||||
<button class="start-btn" v-if="selectedVariant !== ''" @click="quizAnswerSubmit">{{ buttonTitle }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.quiz {
|
||||
padding-top: 80px;
|
||||
height: 100vh;
|
||||
color: #FEFEFE;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-left: 15px;
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 40px;
|
||||
text-align: center;
|
||||
font-family: 'OpenSans', sans-serif;
|
||||
font-weight: 400;
|
||||
font-style: 'SemiBold'
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 32px;
|
||||
text-align: left;
|
||||
font-family: 'OpenSans', sans-serif;
|
||||
font-weight: 400;
|
||||
font-style: 'SemiBold';
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.chat-bubble {
|
||||
position: relative;
|
||||
background-color: #248BDA;
|
||||
color: white;
|
||||
padding: 12px 28px;
|
||||
border-radius: 40px;
|
||||
font-size: 40px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
|
||||
font-family: 'OpenSans', sans-serif;
|
||||
font-style: 'SemiBold';
|
||||
|
||||
max-width: 261px;
|
||||
max-height: 95px;
|
||||
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chat-bubble::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
|
||||
left: 25px;
|
||||
bottom: -9px;
|
||||
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 15px solid transparent;
|
||||
border-right: 0px solid transparent;
|
||||
border-top: 18px solid #248BDA;
|
||||
}
|
||||
|
||||
.quiz-variants {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.quiz-variant {
|
||||
background-color: #D9D9D91A;
|
||||
padding: 16px 20px;
|
||||
border-radius: 12px;
|
||||
font-family: 'OpenSans', sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
font-size: 18px;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.quiz-variant.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.success {
|
||||
background-color: #355a35;
|
||||
}
|
||||
|
||||
.fail {
|
||||
background-color: #630209;
|
||||
}
|
||||
|
||||
.selected {
|
||||
/* background-color: #2929291a; */
|
||||
border: 2px solid #FFFFFF;
|
||||
}
|
||||
</style>
|
303
frontend/src/screens/ResultScreen.vue
Normal file
@ -0,0 +1,303 @@
|
||||
<script setup lang="ts">
|
||||
import { useBackButton } from 'vue-tg';
|
||||
import { useAppStateStore } from '../store/core';
|
||||
import { onMounted } from 'vue';
|
||||
import { textToSpeech } from '../api/request';
|
||||
|
||||
const store = useAppStateStore()
|
||||
const backButton = useBackButton()
|
||||
|
||||
function backToCamera() {
|
||||
store.$patch({
|
||||
appState: "camera",
|
||||
capturedImage: null
|
||||
})
|
||||
}
|
||||
|
||||
function playAudio() {
|
||||
if (store.tts_audio) {
|
||||
try {
|
||||
// Создаем аудио объект из base64 строки
|
||||
const audio = new Audio(`data:audio/mp3;base64,${store.tts_audio}`)
|
||||
audio.play()
|
||||
} catch (error) {
|
||||
console.error('Ошибка воспроизведения аудио:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (backButton.onClick !== undefined) {
|
||||
backButton.onClick(backToCamera)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (store.objectTatar !== "") {
|
||||
const data = await textToSpeech(store.objectTatar)
|
||||
store.$patch({tts_audio: data.audio_base64})
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="results-screen">
|
||||
<img
|
||||
:src="store.capturedImage || '/scan_example.png'"
|
||||
class="result-image"
|
||||
alt="Сфотографированное изображение"
|
||||
>
|
||||
|
||||
<div class="content">
|
||||
<div class="title-section">
|
||||
<div class="title-with-audio">
|
||||
<h1>{{ store.objectTatar }}</h1>
|
||||
<button
|
||||
class="audio-button"
|
||||
:class="{ 'loading': !store.tts_audio }"
|
||||
@click="playAudio"
|
||||
:disabled="!store.tts_audio"
|
||||
aria-label="Воспроизвести произношение"
|
||||
>
|
||||
<!-- Спиннер -->
|
||||
<div v-if="!store.tts_audio" class="spinner">
|
||||
<svg width="33" height="33" viewBox="0 0 33 33" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="16.5" cy="16.5" r="12" stroke="rgba(255, 255, 255, 0.3)" stroke-width="2"/>
|
||||
<circle cx="16.5" cy="16.5" r="12" stroke="white" stroke-width="2"
|
||||
stroke-linecap="round" stroke-dasharray="60" stroke-dashoffset="15"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Иконка воспроизведения -->
|
||||
<svg v-else width="33" height="33" viewBox="0 0 33 33" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_13087_382)">
|
||||
<mask id="mask0_13087_382" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="33" height="33">
|
||||
<path d="M0 0H33V33H0V0Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_13087_382)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.4344 23.3064H10.0031L14.8034 28.1067C15.1705 28.4739 15.6382 28.7239 16.1474 28.8253C16.6567 28.9266 17.1845 28.8746 17.6642 28.6759C18.1439 28.4772 18.5538 28.1407 18.8422 27.7089C19.1306 27.2772 19.2845 26.7696 19.2844 26.2504V9.22491C19.2845 8.7057 19.1306 8.19813 18.8422 7.76639C18.5538 7.33465 18.1439 6.99814 17.6642 6.79944C17.1845 6.60073 16.6567 6.54875 16.1474 6.65007C15.6382 6.7514 15.1705 7.00147 14.8034 7.36866L10.0031 12.1689H4.4344C3.69594 12.1689 2.98772 12.4623 2.46555 12.9844C1.94338 13.5066 1.65002 14.2148 1.65002 14.9533L1.65002 20.522C1.65002 21.2605 1.94338 21.9687 2.46555 22.4909C2.98772 23.0131 3.69594 23.3064 4.4344 23.3064ZM27.9382 27.2101C27.4463 27.8022 26.559 27.7985 26.0152 27.2547C25.4731 26.7108 25.4806 25.8328 25.9576 25.2313C27.6508 23.1011 28.5704 20.4589 28.5656 17.7377C28.5699 15.0171 27.6504 12.3756 25.9576 10.2458C25.4806 9.64256 25.4731 8.76641 26.017 8.22253C26.559 7.67865 27.4463 7.67494 27.9382 8.26708C30.1475 10.9281 31.3547 14.2791 31.35 17.7377C31.35 21.3388 30.0692 24.6392 27.9382 27.2101ZM23.9696 23.247C23.5129 23.8651 22.6219 23.8596 22.078 23.3157C21.5342 22.7737 21.5509 21.8975 21.9667 21.2515C22.6399 20.2033 22.9975 18.9835 22.9969 17.7377C22.9969 16.4439 22.6182 15.2392 21.9667 14.2256C21.5509 13.5797 21.5342 12.7035 22.078 12.1596C22.6219 11.6158 23.5129 11.6102 23.9714 12.2302C25.1093 13.7709 25.7813 15.6754 25.7813 17.7377C25.784 19.7209 25.1488 21.6525 23.9696 23.247Z" fill="white"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_13087_382">
|
||||
<rect width="33" height="33" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="translation-section">
|
||||
<h2>{{ store.objectRussian }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="translation-section">
|
||||
<div class="transcription">
|
||||
{{ store.transcriptionIPA }}
|
||||
</div>
|
||||
<div class="pronunciation" v-if="store.pronunciation">
|
||||
Произношение: {{ store.pronunciation }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="description-section" v-if="store.description || store.descriptionTatar">
|
||||
<div class="language-switcher">
|
||||
<button
|
||||
class="language-button"
|
||||
:class="{ active: store.selectedDescriptionLanguage === 'ru' }"
|
||||
@click="store.selectedDescriptionLanguage = 'ru'"
|
||||
>
|
||||
RU
|
||||
</button>
|
||||
<button
|
||||
class="language-button"
|
||||
:class="{ active: store.selectedDescriptionLanguage === 'tat' }"
|
||||
@click="store.selectedDescriptionLanguage = 'tat'"
|
||||
>
|
||||
ТАТ
|
||||
</button>
|
||||
</div>
|
||||
<p class="description">
|
||||
{{ store.selectedDescriptionLanguage === 'ru' ? store.description : store.descriptionTatar }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="examples-section" v-if="store.exampleRussian || store.exampleTatar">
|
||||
<h3>Примеры использования</h3>
|
||||
<div class="example" v-if="store.exampleRussian">
|
||||
<strong>Русский:</strong> {{ store.exampleRussian }}
|
||||
</div>
|
||||
<div class="example" v-if="store.exampleTatar">
|
||||
<strong>Татарский:</strong> {{ store.exampleTatar }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.result-image {
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
border-radius: 16px;
|
||||
margin-bottom: 24px;
|
||||
object-fit: cover;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.results-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
min-height: 100vh;
|
||||
padding-top: 60px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.title-section h1 {
|
||||
font-size: 28px;
|
||||
margin: 0 0 16px 0;
|
||||
color: #90EE90;
|
||||
}
|
||||
|
||||
.title-with-audio {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.title-with-audio h1 {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.audio-button {
|
||||
background: none;
|
||||
border: none;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
outline: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.audio-button:active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.audio-button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.audio-button.loading {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.translation-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.translation-section h2 {
|
||||
font-size: 24px;
|
||||
margin: 0 0 12px 0;
|
||||
color: rgba(254, 254, 254, 1);
|
||||
font-weight: 400;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.transcription {
|
||||
font-size: 18px;
|
||||
color: rgba(255, 255, 255, 1);
|
||||
font-style: italic;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pronunciation {
|
||||
font-size: 16px;
|
||||
color: rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
.description-section {
|
||||
margin: 24px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.language-switcher {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.language-button {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 20px;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
min-width: 50px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.language-button.active {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: rgba(0, 0, 0, 0.9);
|
||||
border-color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 16px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.examples-section {
|
||||
text-align: left;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.examples-section h3 {
|
||||
font-size: 18px;
|
||||
margin: 0 0 12px 0;
|
||||
color: rgba(254, 254, 254, 1);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.example {
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.example strong {
|
||||
color: rgba(254, 254, 254, 1);
|
||||
}
|
||||
</style>
|
201
frontend/src/screens/game/GameSettingsScreen.vue
Normal file
@ -0,0 +1,201 @@
|
||||
<script setup lang="ts">
|
||||
import { useAppStateStore } from '../../store/core';
|
||||
import { useBackButton } from 'vue-tg';
|
||||
import { ref, defineEmits, onMounted } from 'vue';
|
||||
|
||||
import { createRoom } from '../../api/request';
|
||||
|
||||
import "../../utils/debug"
|
||||
|
||||
const store = useAppStateStore()
|
||||
const backButton = useBackButton()
|
||||
const emit = defineEmits(['logout'])
|
||||
|
||||
interface Category {
|
||||
display_name: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const roundsCount = ref(3);
|
||||
const selectedCategory = ref("")
|
||||
const categories = ref<Category[]>([{display_name: "Дом", name: "home"},{display_name: "Улица", name: "street"},{display_name: "Путеводитель по городу", name: "city"}])
|
||||
|
||||
onMounted(() => {
|
||||
if (backButton.show !== undefined && backButton.onClick !== undefined) {
|
||||
backButton.onClick(() => {
|
||||
store.$patch({appState: "game"})
|
||||
})
|
||||
backButton.show()
|
||||
}
|
||||
})
|
||||
|
||||
async function createRoomClick () {
|
||||
if (!selectedCategory.value) {
|
||||
console.error('Категория не выбрана');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const roomParams = {
|
||||
rounds_count: roundsCount.value,
|
||||
word_category: selectedCategory.value
|
||||
};
|
||||
const result = await createRoom(roomParams);
|
||||
console.log('Room created:', result);
|
||||
store.$patch({
|
||||
room_id: result.id,
|
||||
appState: 'game_room'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating room:', error);
|
||||
// return // на проде вернуть
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="game-settings" v-if="store.appState == 'game_settings'">
|
||||
<h1 class="game-title">Настройки игры</h1>
|
||||
|
||||
<div class="settings-section">
|
||||
<h2 class="section-title">Количество раундов</h2>
|
||||
<div class="rounds-selector">
|
||||
<button
|
||||
v-for="rounds in [3, 5, 7, 10]"
|
||||
:key="rounds"
|
||||
class="rounds-option"
|
||||
:class="{ selected: roundsCount === rounds }"
|
||||
@click="roundsCount = rounds"
|
||||
>
|
||||
{{ rounds }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h2 class="section-title">Выберите категорию</h2>
|
||||
<div class="categories-list">
|
||||
<div
|
||||
v-for="category in categories"
|
||||
:key="category.name"
|
||||
class="category-variant"
|
||||
:class="{ selected: selectedCategory === category.name }"
|
||||
@click="selectedCategory = category.name"
|
||||
>
|
||||
{{ category.display_name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bottom-app-button">
|
||||
<button
|
||||
class="start-btn"
|
||||
:disabled="!selectedCategory"
|
||||
@click="createRoomClick"
|
||||
>
|
||||
Создать комнату
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.game-settings {
|
||||
padding-top: 60px;
|
||||
height: 100vh;
|
||||
color: #FEFEFE;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
padding-bottom: 100px;
|
||||
gap: 32px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.game-title {
|
||||
font-size: 32px;
|
||||
text-align: center;
|
||||
font-family: 'OpenSans', sans-serif;
|
||||
font-weight: 600;
|
||||
color: #90EE90;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 20px;
|
||||
font-family: 'OpenSans', sans-serif;
|
||||
font-weight: 600;
|
||||
color: rgba(254, 254, 254, 1);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rounds-selector {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rounds-option {
|
||||
background-color: #D9D9D91A;
|
||||
border: none;
|
||||
padding: 12px 20px;
|
||||
border-radius: 12px;
|
||||
font-family: 'OpenSans', sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #FEFEFE;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.rounds-option.selected {
|
||||
border: 2px solid #FFFFFF;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.rounds-option:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.categories-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.category-variant {
|
||||
background-color: #D9D9D91A;
|
||||
padding: 16px 20px;
|
||||
border-radius: 12px;
|
||||
font-family: 'OpenSans', sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
color: #FEFEFE;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.category-variant.selected {
|
||||
border: 2px solid #FFFFFF;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.category-variant:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.start-btn:disabled {
|
||||
background: rgba(254, 254, 254, 0.3);
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
54
frontend/src/screens/game/GameWelcomeScreen.vue
Normal file
@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { useAppStateStore } from '../../store/core';
|
||||
import { useBackButton } from 'vue-tg';
|
||||
import { defineEmits, onMounted } from 'vue';
|
||||
|
||||
import "../../utils/debug"
|
||||
|
||||
const store = useAppStateStore()
|
||||
const backButton = useBackButton()
|
||||
const emit = defineEmits(['logout'])
|
||||
|
||||
onMounted(() => {
|
||||
if (backButton.show !== undefined && backButton.onClick !== undefined) {
|
||||
backButton.onClick(() => {
|
||||
store.$patch({appState: "landing"})
|
||||
})
|
||||
backButton.show()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="landing" v-if="store.appState == 'game'">
|
||||
<h1 class="game-title">Игра</h1>
|
||||
<div class="description-section">
|
||||
<p>Это дуэль друзей в реальном мире. Вы получаете случайное слово, ваша задача — быстрее соперника найти
|
||||
этот предмет у себя дома, навести на него камеру и отсканировать. Скорость, смекалка и азарт!
|
||||
</p>
|
||||
</div>
|
||||
<img src="/stol.svg" alt="scan example" class="landing-image" />
|
||||
|
||||
<div class="bottom-app-button">
|
||||
<button class="start-btn" @click="store.$patch({appState: 'game_settings'})">Создать комнату</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.description-section {
|
||||
font-family: 'OpenSans', sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
text-align: left;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.game-title {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.landing {
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
131
frontend/src/screens/game/Room.vue
Normal file
@ -0,0 +1,131 @@
|
||||
<script setup lang="ts">
|
||||
import PlayerCard from '../../components/PlayerCard.vue';
|
||||
import { onMounted, ref, computed } from 'vue';
|
||||
import { getRoomMembers, startGame as apiStartGame } from '../../api/request';
|
||||
import type { RoomMember } from '../../api/models';
|
||||
import { useAppStateStore } from '../../store/core';
|
||||
|
||||
const store = useAppStateStore()
|
||||
const players = ref<RoomMember[]>([])
|
||||
const isStartingGame = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const inviteLink = computed(() => {
|
||||
return `https://t.me/what_is_tat_bot?startapp=room_${store.room_id}`
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
players.value = await getRoomMembers(store.room_id)
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении участников комнаты:', error)
|
||||
errorMessage.value = 'Не удалось загрузить участников комнаты'
|
||||
}
|
||||
})
|
||||
|
||||
function copyInviteLink() {
|
||||
navigator.clipboard.writeText(inviteLink.value).then(() => {
|
||||
console.log('Ссылка скопирована!');
|
||||
}).catch(err => {
|
||||
console.error('Ошибка при копировании: ', err);
|
||||
});
|
||||
}
|
||||
|
||||
async function startGame() {
|
||||
if (players.value.length < 2) {
|
||||
errorMessage.value = 'Для начала игры необходимо минимум 2 игрока'
|
||||
return;
|
||||
}
|
||||
|
||||
isStartingGame.value = true;
|
||||
errorMessage.value = '';
|
||||
|
||||
try {
|
||||
const result = await apiStartGame(store.room_id);
|
||||
|
||||
if (result.success) {
|
||||
console.log('Игра успешно начата!', result);
|
||||
|
||||
if (result.game_id) {
|
||||
store.game_id = result.game_id;
|
||||
console.log('Game ID:', result.game_id);
|
||||
}
|
||||
|
||||
store.setAppState('quiz');
|
||||
} else {
|
||||
errorMessage.value = result.message || 'Ошибка при начале игры';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при начале игры:', error);
|
||||
errorMessage.value = error instanceof Error ? error.message : 'Неизвестная ошибка при начале игры';
|
||||
} finally {
|
||||
isStartingGame.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="room-screen">
|
||||
<div class="room-header">
|
||||
<h1 class="room-title">Комната</h1>
|
||||
<p class="room-description">Отправьте ссылку-приглашение своим друзьям, чтобы сыграть вместе!</p>
|
||||
</div>
|
||||
|
||||
<div class="invite-section">
|
||||
<div class="invite-link">
|
||||
<span class="link-text">{{ inviteLink }}</span>
|
||||
<button class="copy-btn" @click="copyInviteLink">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16 1H4C2.9 1 2 1.9 2 3V17H4V3H16V1ZM19 5H8C6.9 5 6 5.9 6 7V21C6 22.1 6.9 23 8 23H19C20.1 23 21 22.1 21 21V7C21 5.9 20.1 5 19 5ZM19 21H8V7H19V21Z" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="players-section">
|
||||
<div class="players-header">
|
||||
<h2 class="players-title">Игроки</h2>
|
||||
</div>
|
||||
<div class="players-list">
|
||||
<PlayerCard
|
||||
v-for="player in players"
|
||||
:key="player.id"
|
||||
:member="player"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="error-message">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<div class="bottom-app-button">
|
||||
<button
|
||||
class="start-btn"
|
||||
:disabled="players.length < 2 || isStartingGame"
|
||||
@click="startGame"
|
||||
>
|
||||
<span v-if="isStartingGame">Начинаем игру...</span>
|
||||
<span v-else>Начать игру</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.error-message {
|
||||
background-color: #fee;
|
||||
color: #d00;
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #fcc;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.start-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
80
frontend/src/store/core.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { Debug } from '../utils/debug'
|
||||
|
||||
export const useAppStateStore = defineStore('appStates', () => {
|
||||
const appState = ref("auth") // Start with auth state instead of landing
|
||||
const title = ref("")
|
||||
const transcription = ref("")
|
||||
const description = ref("")
|
||||
|
||||
// Authentication state
|
||||
const isAuthenticated = ref(false)
|
||||
const currentUser = ref<{
|
||||
id: string
|
||||
username: string
|
||||
email: string
|
||||
} | null>(null)
|
||||
|
||||
// Additional fields from API response
|
||||
const exampleRussian = ref("")
|
||||
const exampleTatar = ref("")
|
||||
const objectRussian = ref("")
|
||||
const objectTatar = ref("")
|
||||
const pronunciation = ref("")
|
||||
const transcriptionIPA = ref("")
|
||||
const descriptionTatar = ref("")
|
||||
const tts_audio = ref("")
|
||||
|
||||
// game
|
||||
const room_id = ref("")
|
||||
const game_id = ref("")
|
||||
|
||||
// UI state
|
||||
const selectedDescriptionLanguage = ref<'ru' | 'tat'>('ru')
|
||||
|
||||
// Photo data
|
||||
const capturedImage = ref<string | null>(null)
|
||||
|
||||
if (Debug.enabled) {
|
||||
watch(appState, (newState, oldState) => {
|
||||
Debug.log('App state changed:', { from: oldState, to: newState })
|
||||
})
|
||||
}
|
||||
|
||||
const setAppState = (state: string) => {
|
||||
Debug.log('Setting app state to:', state)
|
||||
appState.value = state
|
||||
}
|
||||
|
||||
const setAuthenticationStatus = (authenticated: boolean, user?: { id: string, username: string, email: string }) => {
|
||||
isAuthenticated.value = authenticated
|
||||
currentUser.value = user || null
|
||||
Debug.log('Authentication status changed:', { authenticated, user })
|
||||
}
|
||||
|
||||
Debug.log('AppState store initialized with state:', appState.value)
|
||||
|
||||
return {
|
||||
appState,
|
||||
setAppState,
|
||||
isAuthenticated,
|
||||
currentUser,
|
||||
setAuthenticationStatus,
|
||||
title,
|
||||
transcription,
|
||||
description,
|
||||
exampleRussian,
|
||||
exampleTatar,
|
||||
objectRussian,
|
||||
objectTatar,
|
||||
pronunciation,
|
||||
transcriptionIPA,
|
||||
capturedImage,
|
||||
descriptionTatar,
|
||||
selectedDescriptionLanguage,
|
||||
tts_audio,
|
||||
room_id,
|
||||
game_id
|
||||
}
|
||||
})
|
511
frontend/src/style.css
Normal file
@ -0,0 +1,511 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300..800;1,300..800&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Old+Standard+TT:ital,wght@0,400;0,700;1,400&display=swap');
|
||||
|
||||
body {
|
||||
background: #303030;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.landing {
|
||||
padding-top: 60px;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 60px;
|
||||
position: relative;
|
||||
|
||||
.user-menu-container {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.landing-title {
|
||||
font-weight: 600;
|
||||
font-size: 88px;
|
||||
color: #90EE90;
|
||||
font-family: 'OpenSans', sans-serif;
|
||||
}
|
||||
|
||||
.user-menu-button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
color: rgba(254, 254, 254, 1);
|
||||
font-family: OpenSans, sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.user-menu-button:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.user-menu-dropdown {
|
||||
position: absolute;
|
||||
top: 50px;
|
||||
right: 0;
|
||||
background: rgba(20, 30, 40, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
min-width: 200px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.username {
|
||||
font-family: OpenSans, sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: rgba(254, 254, 254, 1);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.email {
|
||||
font-family: OpenSans, sans-serif;
|
||||
font-size: 14px;
|
||||
color: rgba(254, 254, 254, 0.7);
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
font-family: OpenSans, sans-serif;
|
||||
font-size: 14px;
|
||||
color: rgba(254, 254, 254, 0.9);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.logout-item {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.bubbles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
border: 2px solid rgba(255, 255, 255, 1);
|
||||
border-radius: 100px;
|
||||
padding: 3px 33px 3px 33px;
|
||||
|
||||
font-weight: 400;
|
||||
font-family: OpenSans, sans-serif;
|
||||
font-size: 24px;
|
||||
color: rgba(254, 254, 254, 1);
|
||||
white-space: nowrap;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.bubble:first-child {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.bubble:last-child {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.game-title {
|
||||
font-weight: 600;
|
||||
font-size: 48px;
|
||||
color: #90EE90;
|
||||
font-family: 'OpenSans', sans-serif;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.description-section {
|
||||
max-width: 600px;
|
||||
text-align: center;
|
||||
|
||||
p {
|
||||
font-family: 'OpenSans', sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
color: rgba(254, 254, 254, 0.9);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Адаптивные стили для изображений на лендинге */
|
||||
.landing-logo {
|
||||
width: min(300px, 80vw);
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.landing-image {
|
||||
width: min(400px, 90vw);
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Дополнительная адаптивность для изображений */
|
||||
@media screen and (max-width: 480px) {
|
||||
.landing-logo {
|
||||
width: min(200px, 70vw);
|
||||
}
|
||||
|
||||
.landing-image {
|
||||
width: min(300px, 85vw);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 320px) {
|
||||
.landing-logo {
|
||||
width: min(150px, 60vw);
|
||||
}
|
||||
|
||||
.landing-image {
|
||||
width: min(250px, 80vw);
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
gap: 24px;
|
||||
|
||||
.error-icon {
|
||||
font-size: 64px;
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-family: OpenSans, sans-serif;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: rgba(254, 254, 254, 1);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-family: OpenSans, sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
color: rgba(254, 254, 254, 0.8);
|
||||
line-height: 1.5;
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
.results-screen {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
padding-top: 60px;
|
||||
color: rgba(254, 254, 254, 1);
|
||||
font-family: OpenSans, sans-serif;
|
||||
gap: 8px;
|
||||
|
||||
.title-section {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.transcription {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-app-button {
|
||||
position: fixed;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
.start-btn {
|
||||
pointer-events: auto;
|
||||
background: #FEFEFE;
|
||||
color: black;
|
||||
border: none;
|
||||
padding: 14px 36px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
flex: 2;
|
||||
font-family: 'OpenSans', sans-serif;
|
||||
font-weight: 500;
|
||||
}
|
||||
.start-btn:active { transform: translateY(1px); }
|
||||
|
||||
.game-btn {
|
||||
pointer-events: auto;
|
||||
background: transparent;
|
||||
color: #FEFEFE;
|
||||
border: 2px solid #FEFEFE;
|
||||
padding: 14px 36px;
|
||||
border-radius: 28px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
font-family: 'OpenSans', sans-serif;
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.game-btn:active { transform: translateY(1px); }
|
||||
|
||||
/* Authentication Styles */
|
||||
.auth-screen {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.auth-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 40px;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.auth-logo {
|
||||
width: 120px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.auth-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.auth-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 3px solid rgba(255, 255, 255, 1);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
font-family: OpenSans, sans-serif;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: rgba(254, 254, 254, 1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-message {
|
||||
font-family: OpenSans, sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: rgba(254, 254, 254, 0.8);
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.auth-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
background: linear-gradient(90deg, #6a11cb 0%, #2575fc 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 24px;
|
||||
font-family: OpenSans, sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 6px 16px rgba(37, 117, 252, 0.25);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.retry-button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.retry-button:hover {
|
||||
box-shadow: 0 8px 20px rgba(37, 117, 252, 0.35);
|
||||
}
|
||||
|
||||
/* Room Screen Styles */
|
||||
.room-screen {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
padding-bottom: 100px;
|
||||
gap: 24px;
|
||||
color: rgba(254, 254, 254, 1);
|
||||
font-family: 'OpenSans', sans-serif;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.room-title {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: #90EE90;
|
||||
margin: 0 0 12px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.room-description {
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: #fff;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.invite-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.invite-label {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: rgba(254, 254, 254, 1);
|
||||
}
|
||||
|
||||
.invite-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.link-text {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: rgba(254, 254, 254, 0.9);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
color: rgba(254, 254, 254, 0.8);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.players-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.players-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.players-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: rgba(254, 254, 254, 1);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.players-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.start-btn:disabled {
|
||||
background: rgba(254, 254, 254, 0.3);
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.room-screen {
|
||||
padding-top: 60px;
|
||||
}
|
61
frontend/src/utils/debug.ts
Normal file
@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Debug utility for development mode
|
||||
*/
|
||||
export class Debug {
|
||||
private static isEnabled = import.meta.env.DEBUG === 'true'
|
||||
|
||||
static get enabled(): boolean {
|
||||
return this.isEnabled
|
||||
}
|
||||
|
||||
static log(message: any, ...args: any[]): void {
|
||||
if (this.isEnabled) {
|
||||
console.log(`[DEBUG]`, message, ...args)
|
||||
}
|
||||
}
|
||||
|
||||
static warn(message: any, ...args: any[]): void {
|
||||
if (this.isEnabled) {
|
||||
console.warn(`[DEBUG WARN]`, message, ...args)
|
||||
}
|
||||
}
|
||||
|
||||
static error(message: any, ...args: any[]): void {
|
||||
if (this.isEnabled) {
|
||||
console.error(`[DEBUG ERROR]`, message, ...args)
|
||||
}
|
||||
}
|
||||
|
||||
static info(label: string, data: any): void {
|
||||
if (this.isEnabled) {
|
||||
console.group(`[DEBUG INFO] ${label}`)
|
||||
console.log(data)
|
||||
console.groupEnd()
|
||||
}
|
||||
}
|
||||
|
||||
static time<T>(label: string, fn: () => T): T {
|
||||
if (this.isEnabled) {
|
||||
console.time(`[DEBUG TIME] ${label}`)
|
||||
const result = fn()
|
||||
console.timeEnd(`[DEBUG TIME] ${label}`)
|
||||
return result
|
||||
}
|
||||
return fn()
|
||||
}
|
||||
|
||||
static async timeAsync<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
||||
if (this.isEnabled) {
|
||||
console.time(`[DEBUG TIME] ${label}`)
|
||||
const result = await fn()
|
||||
console.timeEnd(`[DEBUG TIME] ${label}`)
|
||||
return result
|
||||
}
|
||||
return fn()
|
||||
}
|
||||
}
|
||||
|
||||
if (Debug.enabled) {
|
||||
(window as any).debug = Debug
|
||||
Debug.log('Debug mode enabled. Access debug utilities via window.debug')
|
||||
}
|
10
frontend/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly DEBUG: string
|
||||
// Add other env variables here as needed
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
15
frontend/tsconfig.app.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
7
frontend/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
25
frontend/tsconfig.node.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
26
frontend/vite.config.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
// import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
|
||||
return {
|
||||
plugins: [vue()],
|
||||
/* говно вью тулс, я вырубил, можешь врубить если надо
|
||||
plugins: [vue(), vueDevTools()], */
|
||||
define: {
|
||||
'import.meta.env.DEBUG': JSON.stringify(env.DEBUG || 'false'),
|
||||
},
|
||||
// можно убрать, эт я для удобства врубил
|
||||
server: {
|
||||
host: true,
|
||||
open: false
|
||||
},
|
||||
build: {
|
||||
sourcemap: env.DEBUG === 'true'
|
||||
}
|
||||
}
|
||||
})
|