1
Some checks failed
Deploy frontend / Build and deploy (push) Failing after 6s

This commit is contained in:
Timur Kh. 2025-09-14 10:58:33 +03:00
commit bb8f6c7af0
36 changed files with 6437 additions and 0 deletions

30
.github/workflows/frontend.yml vendored Normal file
View 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
View File

@ -0,0 +1,3 @@
DEBUG=true
VITE_API_BASE_URL=https://whatisbot-api.ruka.me/api/v1

25
frontend/.gitignore vendored Normal file
View 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
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

9
frontend/README.md Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

27
frontend/package.json Normal file
View 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"
}
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

10
frontend/public/logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 269 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

16
frontend/public/stol.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 583 KiB

1
frontend/public/vite.svg Normal file
View 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
View 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>

View 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
View 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,
};

View 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

View 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
View 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')

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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;
}

View 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
View 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
}

View 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
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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
View 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'
}
}
})