From 49be6bb5883401ec121551009513777f30937ff8 Mon Sep 17 00:00:00 2001 From: DionysusBenstein Date: Tue, 17 Feb 2026 19:07:41 +0300 Subject: [PATCH] Implement basic localisations with admin settings --- README.md | 79 +++++ back/src/app.module.ts | 4 + back/src/bot/bot.module.ts | 6 +- back/src/bot/bot.service.ts | 52 ++- back/src/common/entities/abstract.entity.ts | 4 +- back/src/database/database.module.ts | 8 + .../database/migrations/1771342888394-init.ts | 189 +++++++++++ ...42888395-seed-default-localization-data.ts | 94 ++++++ .../controllers/langs.controller.ts | 62 ++++ .../controllers/wordbooks.controller.ts | 57 ++++ back/src/localization/dto/create-lang.dto.ts | 28 ++ back/src/localization/dto/create-word.dto.ts | 7 + .../localization/dto/create-wordbook.dto.ts | 20 ++ back/src/localization/dto/update-lang.dto.ts | 31 ++ .../dto/update-word-translation.dto.ts | 10 + .../dto/update-word-with-translations.dto.ts | 19 ++ .../localization/dto/update-wordbook.dto.ts | 27 ++ back/src/localization/entities/lang.entity.ts | 37 +++ .../entities/word-translation.entity.ts | 30 ++ back/src/localization/entities/word.entity.ts | 24 ++ .../localization/entities/wordbook.entity.ts | 28 ++ back/src/localization/modules/langs.module.ts | 27 ++ .../localization/modules/wordbooks.module.ts | 28 ++ back/src/localization/modules/words.module.ts | 16 + .../localization/services/langs.service.ts | 222 +++++++++++++ .../services/wordbooks.service.ts | 294 ++++++++++++++++ .../localization/services/words.service.ts | 68 ++++ compose.dev.yml | 6 +- compose.yml | 1 + front/src/api/entities/index.ts | 4 + front/src/api/entities/lang.ts | 27 ++ front/src/api/entities/word-translation.ts | 12 + front/src/api/entities/word.ts | 18 + front/src/api/entities/wordbook.ts | 35 ++ front/src/api/helpers.ts | 9 +- front/src/components/widgets/forms/index.ts | 4 +- .../widgets/forms/lang-form/index.tsx | 184 ++++++++++ .../widgets/forms/wordbook-form/index.tsx | 149 +++++++++ front/src/components/widgets/modals/index.ts | 4 +- .../widgets/modals/lang-modal/index.tsx | 6 + .../widgets/modals/wordbook-modal/index.tsx | 6 + front/src/config.ts | 3 +- front/src/hooks/api/index.ts | 2 + front/src/hooks/api/langs.ts | 89 +++++ front/src/hooks/api/wordbooks.ts | 83 +++++ .../dashboard/sidebar/menu-sections.ts | 19 ++ .../localization/langs/list/index.tsx | 25 ++ .../localization/langs/list/langs-table.tsx | 276 +++++++++++++++ .../localization/wordbooks/edit/index.tsx | 314 ++++++++++++++++++ .../localization/wordbooks/list/index.tsx | 25 ++ .../wordbooks/list/wordbooks-table.tsx | 154 +++++++++ front/src/providers/modals-provider.tsx | 6 +- front/src/routes/paths.ts | 12 + front/src/routes/router.tsx | 43 +++ 54 files changed, 2966 insertions(+), 21 deletions(-) create mode 100644 back/src/database/migrations/1771342888394-init.ts create mode 100644 back/src/database/migrations/1771342888395-seed-default-localization-data.ts create mode 100644 back/src/localization/controllers/langs.controller.ts create mode 100644 back/src/localization/controllers/wordbooks.controller.ts create mode 100644 back/src/localization/dto/create-lang.dto.ts create mode 100644 back/src/localization/dto/create-word.dto.ts create mode 100644 back/src/localization/dto/create-wordbook.dto.ts create mode 100644 back/src/localization/dto/update-lang.dto.ts create mode 100644 back/src/localization/dto/update-word-translation.dto.ts create mode 100644 back/src/localization/dto/update-word-with-translations.dto.ts create mode 100644 back/src/localization/dto/update-wordbook.dto.ts create mode 100644 back/src/localization/entities/lang.entity.ts create mode 100644 back/src/localization/entities/word-translation.entity.ts create mode 100644 back/src/localization/entities/word.entity.ts create mode 100644 back/src/localization/entities/wordbook.entity.ts create mode 100644 back/src/localization/modules/langs.module.ts create mode 100644 back/src/localization/modules/wordbooks.module.ts create mode 100644 back/src/localization/modules/words.module.ts create mode 100644 back/src/localization/services/langs.service.ts create mode 100644 back/src/localization/services/wordbooks.service.ts create mode 100644 back/src/localization/services/words.service.ts create mode 100644 front/src/api/entities/lang.ts create mode 100644 front/src/api/entities/word-translation.ts create mode 100644 front/src/api/entities/word.ts create mode 100644 front/src/api/entities/wordbook.ts create mode 100644 front/src/components/widgets/forms/lang-form/index.tsx create mode 100644 front/src/components/widgets/forms/wordbook-form/index.tsx create mode 100644 front/src/components/widgets/modals/lang-modal/index.tsx create mode 100644 front/src/components/widgets/modals/wordbook-modal/index.tsx create mode 100644 front/src/hooks/api/langs.ts create mode 100644 front/src/hooks/api/wordbooks.ts create mode 100644 front/src/pages/dashboard/localization/langs/list/index.tsx create mode 100644 front/src/pages/dashboard/localization/langs/list/langs-table.tsx create mode 100644 front/src/pages/dashboard/localization/wordbooks/edit/index.tsx create mode 100644 front/src/pages/dashboard/localization/wordbooks/list/index.tsx create mode 100644 front/src/pages/dashboard/localization/wordbooks/list/wordbooks-table.tsx diff --git a/README.md b/README.md index fbfdf7f..0436c4d 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,85 @@ pnpm migration:generate --name=my-migration-name - Dark/light theme - Docker-based development & production setup - Database backup with Telegram notification +- **Localization system** - Multi-language support for bot messages + +## 🌐 Localization System + +The project includes a comprehensive localization system that allows admins to manage translations for bot messages. + +### Overview + +The localization system consists of: +- **Languages** (`langs`) - Supported languages with settings (slug, title, active status, text direction, date format) +- **Wordbooks** (`wordbooks`) - Collections of words grouped by functionality (e.g., "commands", "errors", "messages") +- **Words** (`words`) - Individual translation keys (marks) within a wordbook +- **Translations** (`word_translations`) - Actual translated text for each word in each language + +### Admin Usage + +#### Managing Languages + +1. Navigate to **Dashboard → Localization → Languages** +2. **Create a new language:** + - Click "Create Language" + - Fill in: + - **Slug**: Language code (e.g., `en`, `ru`, `uk`) + - **Title**: Display name (e.g., "English", "Русский") + - **Active**: Enable/disable language (only active languages are loaded for bot) + - **Direction**: Text direction (`ltr` or `rtl`) + - **Date Format**: Date format preference (`en` or `ru`) + - Save + +3. **Edit/Delete languages:** + - Click on a language in the table to edit + - Protected languages (marked as `defended`) cannot be deleted + +#### Managing Wordbooks + +1. Navigate to **Dashboard → Localization → Wordbooks** +2. **Create a new wordbook:** + - Click "Create Wordbook" + - Fill in: + - **Name**: Unique wordbook identifier (e.g., `commands`, `errors`, `messages`) + - **Load To**: Where the wordbook is used: + - `all` - Available for both bot and admin panel + - `bot` - Only for Telegram bot + - Optionally add words during creation + - Save + +3. **Edit a wordbook:** + - Click on a wordbook in the table + - Edit wordbook name and `load_to` setting + - Manage words: + - **Add word**: Click "Add Word" button + - **Edit word**: Modify the mark (key) or translations + - **Delete word**: Click delete icon (⚠️ words with translations will be removed) + +#### Adding Translations + +1. Open a wordbook for editing +2. For each word, you'll see translation fields for all active languages +3. **Add translations:** + - Enter translated text in the corresponding language field + - Translations are saved automatically when you update the wordbook + - Empty translations are allowed (will be `null` in database) + +4. **Translation workflow:** + - When you add a new word, translation fields are automatically created for all existing languages + - When you add a new language, you'll need to add translations for existing words manually + - Use the language tabs to switch between languages while editing + +### Best Practices + +- **Wordbook naming**: Use descriptive, lowercase names (e.g., `commands`, `errors`, `buttons`) +- **Word marks**: Use clear, descriptive keys (e.g., `start_command`, `error_not_found`, `button_submit`) +- **Language slugs**: Follow ISO 639-1 standard (2-letter codes) or ISO 639-2 (3-letter codes) +- **Protected items**: Mark system languages and wordbooks as `defended` to prevent accidental deletion +- **Active languages**: Only mark languages as active if translations are ready (inactive languages won't be loaded by bot) + +### Bot Integration + +The bot automatically loads all active wordbooks marked with `load_to: 'all'` or `load_to: 'bot'` on startup. Translations are cached in memory for fast access. The cache is automatically reloaded when wordbooks are updated through the admin panel. ## 🤖 Development with AI Assistants diff --git a/back/src/app.module.ts b/back/src/app.module.ts index 4675578..a755028 100644 --- a/back/src/app.module.ts +++ b/back/src/app.module.ts @@ -10,6 +10,8 @@ import { CommonModule } from './common/common.module'; import { validate } from './config/env/validate'; import { DatabaseModule } from './database/database.module'; import { UsersModule } from './users/users.module'; +import { LangsModule } from './localization/modules/langs.module'; +import { WordbooksModule } from './localization/modules/wordbooks.module'; @Module({ imports: [ @@ -20,6 +22,8 @@ import { UsersModule } from './users/users.module'; AuthModule, AdminConsoleModule, UsersModule, + LangsModule, + WordbooksModule, BotModule, ], controllers: [AppController], diff --git a/back/src/bot/bot.module.ts b/back/src/bot/bot.module.ts index a62e5f6..689f0f9 100644 --- a/back/src/bot/bot.module.ts +++ b/back/src/bot/bot.module.ts @@ -1,14 +1,18 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { BotService } from './bot.service'; import { ConfigModule } from '@nestjs/config'; import { ScheduleModule } from '@nestjs/schedule'; import { UsersModule } from '../users/users.module'; +import { LangsModule } from '../localization/modules/langs.module'; +import { WordsModule } from '../localization/modules/words.module'; @Module({ imports: [ ConfigModule, ScheduleModule.forRoot(), UsersModule, + forwardRef(() => LangsModule), + WordsModule, ], providers: [BotService], exports: [BotService], diff --git a/back/src/bot/bot.service.ts b/back/src/bot/bot.service.ts index 499e864..f8efb7b 100644 --- a/back/src/bot/bot.service.ts +++ b/back/src/bot/bot.service.ts @@ -1,24 +1,31 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Bot, Keyboard } from 'grammy'; import { ForceReply, InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove } from 'grammy/types'; +import { LangsService } from '../localization/services/langs.service'; +import { IWords, WordsService } from '../localization/services/words.service'; import { UsersService } from '../users/users.service'; import { BotContext } from './types/bot-context.types'; @Injectable() -export class BotService { +export class BotService implements OnModuleInit { private readonly bot: Bot; private readonly _logger = new Logger(BotService.name); + private words: IWords = {}; constructor( private readonly configService: ConfigService, private readonly usersService: UsersService, + private readonly langsService: LangsService, + private readonly wordsService: WordsService, ) { const telegramBotToken = this.configService.get('TELEGRAM_BOT_TOKEN'); this.bot = new Bot(telegramBotToken); } - public onModuleInit() { + public async onModuleInit() { + await this.loadWords(); + this.bot.use(this.checkUserAuth.bind(this)); this.bot.command('start', this.onStart.bind(this)); @@ -27,6 +34,26 @@ export class BotService { this._logger.log('BOT STARTED!'); } + private async loadWords(): Promise { + try { + this.words = await this.wordsService.findAll(); + const wordbookCount = Object.keys(this.words).length; + const totalWords = Object.values(this.words).reduce( + (sum, wordbook) => sum + Object.keys(wordbook).length, + 0, + ); + this._logger.log(`Loaded ${wordbookCount} wordbooks with ${totalWords} words into memory`); + } catch (error) { + this._logger.error(`Failed to load words: ${error.message}`, error.stack); + this.words = {}; + } + } + + public async reloadWords(): Promise { + await this.loadWords(); + this._logger.log('Words cache reloaded'); + } + private async checkUserAuth(ctx: BotContext, next: () => Promise) { if (!ctx.from) { return next(); @@ -58,13 +85,15 @@ export class BotService { private async onStart(ctx: BotContext) { try { const name = ctx.user?.first_name || 'there'; + const langSlug = ctx.from?.language_code || 'en'; + const welcomeText = this.getTranslation('common', 'welcome', langSlug) || `Welcome, ${name}! 👋\n\nThis bot is under development.`; const keyboard = new Keyboard() .text('Menu') .row() .resized(); - await ctx.reply(`Welcome, ${name}! 👋\n\nThis bot is under development.`, { + await ctx.reply(welcomeText, { reply_markup: keyboard, }); } catch (error) { @@ -72,6 +101,21 @@ export class BotService { } } + private getTranslation( + wordbookName: string, + mark: string, + langSlug: string, + fallbackLang: string = 'en', + ): string { + try { + const translation = this.words[wordbookName]?.[mark]?.[langSlug]; + return translation || this.words[wordbookName]?.[mark]?.[fallbackLang] || ''; + } catch (error) { + this._logger.error(`Error getting translation: ${error.message}`, error.stack); + return ''; + } + } + public async sendMessage( telegramId: string, message: string, diff --git a/back/src/common/entities/abstract.entity.ts b/back/src/common/entities/abstract.entity.ts index 2f37542..ac663e7 100644 --- a/back/src/common/entities/abstract.entity.ts +++ b/back/src/common/entities/abstract.entity.ts @@ -25,9 +25,9 @@ export abstract class AbstractEntity { @PrimaryGeneratedColumn() public id: number; - @CreateDateColumn() + @CreateDateColumn({ name: 'created_at' }) public created_at: Date; - @UpdateDateColumn() + @UpdateDateColumn({ name: 'updated_at' }) public updated_at: Date; } diff --git a/back/src/database/database.module.ts b/back/src/database/database.module.ts index 5566638..5341f1e 100644 --- a/back/src/database/database.module.ts +++ b/back/src/database/database.module.ts @@ -4,6 +4,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Password } from 'src/admins/entities/password.entity'; import { Admin } from 'src/admins/entities/admin.entity'; import { User } from 'src/users/entities/user.entity'; +import { Lang } from 'src/localization/entities/lang.entity'; +import { Wordbook } from 'src/localization/entities/wordbook.entity'; +import { Word } from 'src/localization/entities/word.entity'; +import { WordTranslation } from 'src/localization/entities/word-translation.entity'; @Module({ imports: [ @@ -24,6 +28,10 @@ import { User } from 'src/users/entities/user.entity'; Admin, Password, User, + Lang, + Wordbook, + Word, + WordTranslation, ] }), }), diff --git a/back/src/database/migrations/1771342888394-init.ts b/back/src/database/migrations/1771342888394-init.ts new file mode 100644 index 0000000..092d4da --- /dev/null +++ b/back/src/database/migrations/1771342888394-init.ts @@ -0,0 +1,189 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Init1771342888394 implements MigrationInterface { + name = 'Init1771342888394' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "users" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + "telegram_id" bigint NOT NULL, + "username" character varying(255), + "first_name" character varying(255) NOT NULL, + "last_name" character varying(255), + "language_code" character varying(10), + "is_active" boolean NOT NULL DEFAULT true, + CONSTRAINT "UQ_1a1e4649fd31ea6ec6b025c7bfc" UNIQUE ("telegram_id"), + CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id") + ) + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "IDX_1a1e4649fd31ea6ec6b025c7bf" ON "users" ("telegram_id") + `); + await queryRunner.query(` + CREATE TYPE "public"."langs_dateformat_enum" AS ENUM('en', 'ru') + `); + await queryRunner.query(` + CREATE TABLE "langs" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + "slug" character varying(10), + "title" character varying(255), + "active" boolean NOT NULL DEFAULT true, + "dir" character varying(3) NOT NULL DEFAULT 'ltr', + "dateformat" "public"."langs_dateformat_enum" NOT NULL DEFAULT 'en', + "defended" boolean NOT NULL DEFAULT false, + CONSTRAINT "PK_e0bb7dc43457e44d0123fb3e52f" PRIMARY KEY ("id") + ) + `); + await queryRunner.query(` + CREATE INDEX "IDX_e7b91472db7f34e3cef4e5fbfa" ON "langs" ("slug") + `); + await queryRunner.query(` + CREATE TABLE "word_translations" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + "word_id" integer NOT NULL, + "lang_id" integer NOT NULL, + "text" text, + CONSTRAINT "PK_46099869cec7b0cb459312cfbec" PRIMARY KEY ("id") + ) + `); + await queryRunner.query(` + CREATE TABLE "words" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + "wordbook_id" integer, + "mark" character varying(255), + CONSTRAINT "PK_feaf97accb69a7f355fa6f58a3d" PRIMARY KEY ("id") + ) + `); + await queryRunner.query(` + CREATE INDEX "IDX_1967d289e295104b6f706d9c39" ON "words" ("mark") + `); + await queryRunner.query(` + CREATE TYPE "public"."wordbooks_load_to_enum" AS ENUM('all', 'bot') + `); + await queryRunner.query(` + CREATE TABLE "wordbooks" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + "name" character varying(255), + "load_to" "public"."wordbooks_load_to_enum" NOT NULL DEFAULT 'all', + "defended" boolean NOT NULL DEFAULT false, + CONSTRAINT "PK_4aa53cce77b4aa209a39f355c6b" PRIMARY KEY ("id") + ) + `); + await queryRunner.query(` + CREATE INDEX "IDX_48d696bd928f0a670569bf5645" ON "wordbooks" ("load_to") + `); + await queryRunner.query(` + CREATE TYPE "public"."admins_role_enum" AS ENUM('superadmin', 'admin') + `); + await queryRunner.query(` + CREATE TABLE "admins" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + "username" character varying(255) NOT NULL, + "email" character varying(255) NOT NULL, + "image" character varying, + "is_active" boolean NOT NULL DEFAULT true, + "role" "public"."admins_role_enum" NOT NULL DEFAULT 'admin', + CONSTRAINT "PK_e3b38270c97a854c48d2e80874e" PRIMARY KEY ("id") + ) + `); + await queryRunner.query(` + CREATE TABLE "passwords" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + "value" character varying(255) NOT NULL, + "admin_id" integer NOT NULL, + CONSTRAINT "UQ_b64053a1228ff8996031d640f27" UNIQUE ("admin_id"), + CONSTRAINT "REL_b64053a1228ff8996031d640f2" UNIQUE ("admin_id"), + CONSTRAINT "PK_c5629066962a085dea3b605e49f" PRIMARY KEY ("id") + ) + `); + await queryRunner.query(` + ALTER TABLE "word_translations" + ADD CONSTRAINT "FK_0f50eeb9799e0998e99520a46d0" FOREIGN KEY ("word_id") REFERENCES "words"("id") ON DELETE CASCADE ON UPDATE CASCADE + `); + await queryRunner.query(` + ALTER TABLE "word_translations" + ADD CONSTRAINT "FK_b5a59bbb02ed6133e68aa0b2763" FOREIGN KEY ("lang_id") REFERENCES "langs"("id") ON DELETE CASCADE ON UPDATE CASCADE + `); + await queryRunner.query(` + ALTER TABLE "words" + ADD CONSTRAINT "FK_fb7fdaa4611a5969bec6f2b6afe" FOREIGN KEY ("wordbook_id") REFERENCES "wordbooks"("id") ON DELETE CASCADE ON UPDATE CASCADE + `); + await queryRunner.query(` + ALTER TABLE "passwords" + ADD CONSTRAINT "FK_b64053a1228ff8996031d640f27" FOREIGN KEY ("admin_id") REFERENCES "admins"("id") ON DELETE CASCADE ON UPDATE NO ACTION + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "passwords" DROP CONSTRAINT "FK_b64053a1228ff8996031d640f27" + `); + await queryRunner.query(` + ALTER TABLE "words" DROP CONSTRAINT "FK_fb7fdaa4611a5969bec6f2b6afe" + `); + await queryRunner.query(` + ALTER TABLE "word_translations" DROP CONSTRAINT "FK_b5a59bbb02ed6133e68aa0b2763" + `); + await queryRunner.query(` + ALTER TABLE "word_translations" DROP CONSTRAINT "FK_0f50eeb9799e0998e99520a46d0" + `); + await queryRunner.query(` + DROP TABLE "passwords" + `); + await queryRunner.query(` + DROP TABLE "admins" + `); + await queryRunner.query(` + DROP TYPE "public"."admins_role_enum" + `); + await queryRunner.query(` + DROP INDEX "public"."IDX_48d696bd928f0a670569bf5645" + `); + await queryRunner.query(` + DROP TABLE "wordbooks" + `); + await queryRunner.query(` + DROP TYPE "public"."wordbooks_load_to_enum" + `); + await queryRunner.query(` + DROP INDEX "public"."IDX_1967d289e295104b6f706d9c39" + `); + await queryRunner.query(` + DROP TABLE "words" + `); + await queryRunner.query(` + DROP TABLE "word_translations" + `); + await queryRunner.query(` + DROP INDEX "public"."IDX_e7b91472db7f34e3cef4e5fbfa" + `); + await queryRunner.query(` + DROP TABLE "langs" + `); + await queryRunner.query(` + DROP TYPE "public"."langs_dateformat_enum" + `); + await queryRunner.query(` + DROP INDEX "public"."IDX_1a1e4649fd31ea6ec6b025c7bf" + `); + await queryRunner.query(` + DROP TABLE "users" + `); + } + +} diff --git a/back/src/database/migrations/1771342888395-seed-default-localization-data.ts b/back/src/database/migrations/1771342888395-seed-default-localization-data.ts new file mode 100644 index 0000000..50d911f --- /dev/null +++ b/back/src/database/migrations/1771342888395-seed-default-localization-data.ts @@ -0,0 +1,94 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class SeedDefaultLocalizationData1771342888395 implements MigrationInterface { + name = 'SeedDefaultLocalizationData1771342888395'; + + public async up(queryRunner: QueryRunner): Promise { + const existingLang = await queryRunner.query(` + SELECT "id" FROM "langs" WHERE "slug" = 'en' LIMIT 1 + `); + + let langId: number; + if (existingLang.length === 0) { + const langResult = await queryRunner.query(` + INSERT INTO "langs" ("slug", "title", "active", "dir", "dateformat", "defended", "created_at", "updated_at") + VALUES ('en', 'English', true, 'ltr', 'ru', false, NOW(), NOW()) + RETURNING "id" + `); + langId = langResult[0].id; + } else { + langId = existingLang[0].id; + } + + const existingWordbook = await queryRunner.query(` + SELECT "id" FROM "wordbooks" WHERE "name" = 'common' LIMIT 1 + `); + + let wordbookId: number; + if (existingWordbook.length === 0) { + const wordbookResult = await queryRunner.query(` + INSERT INTO "wordbooks" ("name", "load_to", "defended", "created_at", "updated_at") + VALUES ('common', 'bot', false, NOW(), NOW()) + RETURNING "id" + `); + wordbookId = wordbookResult[0].id; + } else { + wordbookId = existingWordbook[0].id; + } + + const existingWord = await queryRunner.query(` + SELECT "id" FROM "words" WHERE "wordbook_id" = ${wordbookId} AND "mark" = 'welcome' LIMIT 1 + `); + + let wordId: number; + if (existingWord.length === 0) { + const wordResult = await queryRunner.query(` + INSERT INTO "words" ("wordbook_id", "mark", "created_at", "updated_at") + VALUES (${wordbookId}, 'welcome', NOW(), NOW()) + RETURNING "id" + `); + wordId = wordResult[0].id; + } else { + wordId = existingWord[0].id; + } + + const existingTranslation = await queryRunner.query(` + SELECT "id" FROM "word_translations" + WHERE "word_id" = ${wordId} AND "lang_id" = ${langId} + LIMIT 1 + `); + + if (existingTranslation.length === 0) { + await queryRunner.query(` + INSERT INTO "word_translations" ("word_id", "lang_id", "text", "created_at", "updated_at") + VALUES (${wordId}, ${langId}, 'Welcome', NOW(), NOW()) + `); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DELETE FROM "word_translations" + WHERE "word_id" IN ( + SELECT "id" FROM "words" WHERE "wordbook_id" IN ( + SELECT "id" FROM "wordbooks" WHERE "name" = 'common' + ) + ) + `); + + await queryRunner.query(` + DELETE FROM "words" + WHERE "wordbook_id" IN ( + SELECT "id" FROM "wordbooks" WHERE "name" = 'common' + ) + `); + + await queryRunner.query(` + DELETE FROM "wordbooks" WHERE "name" = 'common' + `); + + await queryRunner.query(` + DELETE FROM "langs" WHERE "slug" = 'en' + `); + } +} diff --git a/back/src/localization/controllers/langs.controller.ts b/back/src/localization/controllers/langs.controller.ts new file mode 100644 index 0000000..3de16fc --- /dev/null +++ b/back/src/localization/controllers/langs.controller.ts @@ -0,0 +1,62 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + UseGuards, +} from '@nestjs/common'; +import { LangsService } from '../services/langs.service'; +import { CreateLangDto } from '../dto/create-lang.dto'; +import { UpdateLangDto } from '../dto/update-lang.dto'; +import { AuthGuard } from 'src/auth/auth.guard'; +import { IPagination, PaginationParams } from 'src/common/decorators/pagination-params.decorator'; +import { ISorting, SortingParams } from 'src/common/decorators/sorting-params.decorator'; +import { FilteringParams, IFiltering } from 'src/common/decorators/filtering-params.decorator'; + +@Controller('admin/langs') +@UseGuards(AuthGuard) +export class LangsController { + constructor(private readonly langsService: LangsService) {} + + @Get('all') + findAll() { + return this.langsService.findAll(); + } + + @Get('chunk') + findChunk( + @PaginationParams() paginationParams: IPagination, + @SortingParams(['id', 'slug', 'title', 'active', 'created_at', 'updated_at']) sorting: ISorting, + @FilteringParams(['slug', 'title', 'active']) filtering: IFiltering, + ) { + return this.langsService.findChunk(paginationParams, sorting, filtering); + } + + @Get('one/:id') + findOne(@Param('id') id: string) { + return this.langsService.findOne(+id); + } + + @Post('create') + create(@Body() createLangDto: CreateLangDto) { + return this.langsService.create(createLangDto); + } + + @Patch('update/:id') + update(@Param('id') id: string, @Body() updateLangDto: UpdateLangDto) { + return this.langsService.update(+id, updateLangDto); + } + + @Delete('delete/:id') + remove(@Param('id') id: string) { + return this.langsService.remove(+id); + } + + @Post('delete-bulk') + removeBulk(@Body() body: { ids: number[] }) { + return this.langsService.removeBulk(body.ids); + } +} diff --git a/back/src/localization/controllers/wordbooks.controller.ts b/back/src/localization/controllers/wordbooks.controller.ts new file mode 100644 index 0000000..4fc18b3 --- /dev/null +++ b/back/src/localization/controllers/wordbooks.controller.ts @@ -0,0 +1,57 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + UseGuards, +} from '@nestjs/common'; +import { WordbooksService } from '../services/wordbooks.service'; +import { CreateWordbookDto } from '../dto/create-wordbook.dto'; +import { UpdateWordbookDto } from '../dto/update-wordbook.dto'; +import { AuthGuard } from 'src/auth/auth.guard'; +import { IPagination, PaginationParams } from 'src/common/decorators/pagination-params.decorator'; +import { ISorting, SortingParams } from 'src/common/decorators/sorting-params.decorator'; +import { FilteringParams, IFiltering } from 'src/common/decorators/filtering-params.decorator'; + +@Controller('admin/wordbooks') +@UseGuards(AuthGuard) +export class WordbooksController { + constructor(private readonly wordbooksService: WordbooksService) {} + + @Get('chunk') + findChunk( + @PaginationParams() paginationParams: IPagination, + @SortingParams(['id', 'name', 'load_to', 'created_at', 'updated_at']) sorting: ISorting, + @FilteringParams(['name', 'load_to']) filtering: IFiltering, + ) { + return this.wordbooksService.findChunk(paginationParams, sorting, filtering); + } + + @Get('one/:id') + findOne(@Param('id') id: string) { + return this.wordbooksService.findOne(+id); + } + + @Post('create') + create(@Body() createWordbookDto: CreateWordbookDto) { + return this.wordbooksService.create(createWordbookDto); + } + + @Patch('update/:id') + update(@Param('id') id: string, @Body() updateWordbookDto: UpdateWordbookDto) { + return this.wordbooksService.update(+id, updateWordbookDto); + } + + @Delete('delete/:id') + remove(@Param('id') id: string) { + return this.wordbooksService.remove(+id); + } + + @Post('delete-bulk') + removeBulk(@Body() body: { ids: number[] }) { + return this.wordbooksService.removeBulk(body.ids); + } +} diff --git a/back/src/localization/dto/create-lang.dto.ts b/back/src/localization/dto/create-lang.dto.ts new file mode 100644 index 0000000..07ca413 --- /dev/null +++ b/back/src/localization/dto/create-lang.dto.ts @@ -0,0 +1,28 @@ +import { IsString, IsOptional, IsBoolean, IsEnum } from 'class-validator'; +import { DateFormat } from '../entities/lang.entity'; + +export class CreateLangDto { + @IsString() + @IsOptional() + slug?: string; + + @IsString() + @IsOptional() + title?: string; + + @IsBoolean() + @IsOptional() + active?: boolean; + + @IsString() + @IsOptional() + dir?: string; + + @IsEnum(DateFormat) + @IsOptional() + dateformat?: DateFormat; + + @IsBoolean() + @IsOptional() + defended?: boolean; +} diff --git a/back/src/localization/dto/create-word.dto.ts b/back/src/localization/dto/create-word.dto.ts new file mode 100644 index 0000000..c8e713d --- /dev/null +++ b/back/src/localization/dto/create-word.dto.ts @@ -0,0 +1,7 @@ +import { IsString, IsOptional } from 'class-validator'; + +export class CreateWordDto { + @IsString() + @IsOptional() + mark?: string; +} diff --git a/back/src/localization/dto/create-wordbook.dto.ts b/back/src/localization/dto/create-wordbook.dto.ts new file mode 100644 index 0000000..28eb606 --- /dev/null +++ b/back/src/localization/dto/create-wordbook.dto.ts @@ -0,0 +1,20 @@ +import { IsString, IsOptional, IsBoolean, IsEnum, IsArray, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; +import { LoadTo } from '../entities/wordbook.entity'; +import { CreateWordDto } from './create-word.dto'; + +export class CreateWordbookDto { + @IsString() + @IsOptional() + name?: string; + + @IsEnum(LoadTo) + @IsOptional() + load_to?: LoadTo; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateWordDto) + @IsOptional() + words?: CreateWordDto[]; +} diff --git a/back/src/localization/dto/update-lang.dto.ts b/back/src/localization/dto/update-lang.dto.ts new file mode 100644 index 0000000..5ea60cd --- /dev/null +++ b/back/src/localization/dto/update-lang.dto.ts @@ -0,0 +1,31 @@ +import { IsString, IsOptional, IsBoolean, IsEnum, IsNumber } from 'class-validator'; +import { DateFormat } from '../entities/lang.entity'; + +export class UpdateLangDto { + @IsNumber() + id: number; + + @IsString() + @IsOptional() + slug?: string; + + @IsString() + @IsOptional() + title?: string; + + @IsBoolean() + @IsOptional() + active?: boolean; + + @IsString() + @IsOptional() + dir?: string; + + @IsEnum(DateFormat) + @IsOptional() + dateformat?: DateFormat; + + @IsBoolean() + @IsOptional() + defended?: boolean; +} diff --git a/back/src/localization/dto/update-word-translation.dto.ts b/back/src/localization/dto/update-word-translation.dto.ts new file mode 100644 index 0000000..dac94d0 --- /dev/null +++ b/back/src/localization/dto/update-word-translation.dto.ts @@ -0,0 +1,10 @@ +import { IsString, IsOptional } from 'class-validator'; + +export class UpdateWordTranslationDto { + @IsString() + lang_slug: string; + + @IsString() + @IsOptional() + text?: string | null; +} diff --git a/back/src/localization/dto/update-word-with-translations.dto.ts b/back/src/localization/dto/update-word-with-translations.dto.ts new file mode 100644 index 0000000..4342f1d --- /dev/null +++ b/back/src/localization/dto/update-word-with-translations.dto.ts @@ -0,0 +1,19 @@ +import { IsString, IsOptional, IsNumber, IsArray, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; +import { UpdateWordTranslationDto } from './update-word-translation.dto'; + +export class UpdateWordWithTranslationsDto { + @IsNumber() + @IsOptional() + id?: number; + + @IsString() + @IsOptional() + mark?: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => UpdateWordTranslationDto) + @IsOptional() + translations?: UpdateWordTranslationDto[]; +} diff --git a/back/src/localization/dto/update-wordbook.dto.ts b/back/src/localization/dto/update-wordbook.dto.ts new file mode 100644 index 0000000..f253c52 --- /dev/null +++ b/back/src/localization/dto/update-wordbook.dto.ts @@ -0,0 +1,27 @@ +import { IsString, IsOptional, IsBoolean, IsEnum, IsNumber, IsArray, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; +import { LoadTo } from '../entities/wordbook.entity'; +import { UpdateWordWithTranslationsDto } from './update-word-with-translations.dto'; + +export class UpdateWordbookDto { + @IsNumber() + id: number; + + @IsString() + @IsOptional() + name?: string; + + @IsEnum(LoadTo) + @IsOptional() + load_to?: LoadTo; + + @IsBoolean() + @IsOptional() + defended?: boolean; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => UpdateWordWithTranslationsDto) + @IsOptional() + words?: UpdateWordWithTranslationsDto[]; +} diff --git a/back/src/localization/entities/lang.entity.ts b/back/src/localization/entities/lang.entity.ts new file mode 100644 index 0000000..167b568 --- /dev/null +++ b/back/src/localization/entities/lang.entity.ts @@ -0,0 +1,37 @@ +import { AbstractEntity } from 'src/common/entities/abstract.entity'; +import { Column, Entity, Index, OneToMany } from 'typeorm'; +import { WordTranslation } from './word-translation.entity'; + +export enum DateFormat { + En = 'en', + Ru = 'ru', +} + +@Entity('langs') +export class Lang extends AbstractEntity { + @Column({ length: 10, nullable: true }) + @Index() + slug: string; + + @Column({ length: 255, nullable: true }) + title: string; + + @Column({ default: true }) + active: boolean; + + @Column({ length: 3, default: 'ltr' }) + dir: string; + + @Column({ + type: 'enum', + enum: DateFormat, + default: DateFormat.En, + }) + dateformat: DateFormat; + + @Column({ default: false }) + defended: boolean; + + @OneToMany(() => WordTranslation, (translation) => translation.lang, { cascade: true }) + word_translations: WordTranslation[]; +} diff --git a/back/src/localization/entities/word-translation.entity.ts b/back/src/localization/entities/word-translation.entity.ts new file mode 100644 index 0000000..9e22c25 --- /dev/null +++ b/back/src/localization/entities/word-translation.entity.ts @@ -0,0 +1,30 @@ +import { AbstractEntity } from 'src/common/entities/abstract.entity'; +import { Column, Entity, ManyToOne, JoinColumn } from 'typeorm'; +import { Lang } from './lang.entity'; +import { Word } from './word.entity'; + +@Entity('word_translations') +export class WordTranslation extends AbstractEntity { + @Column() + word_id: number; + + @Column() + lang_id: number; + + @Column({ type: 'text', nullable: true }) + text: string; + + @ManyToOne(() => Word, (word) => word.translations, { + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }) + @JoinColumn({ name: 'word_id', referencedColumnName: 'id' }) + word: Word; + + @ManyToOne(() => Lang, (lang) => lang.word_translations, { + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }) + @JoinColumn({ name: 'lang_id', referencedColumnName: 'id' }) + lang: Lang; +} diff --git a/back/src/localization/entities/word.entity.ts b/back/src/localization/entities/word.entity.ts new file mode 100644 index 0000000..6d921d9 --- /dev/null +++ b/back/src/localization/entities/word.entity.ts @@ -0,0 +1,24 @@ +import { AbstractEntity } from 'src/common/entities/abstract.entity'; +import { Column, Entity, Index, ManyToOne, OneToMany, JoinColumn } from 'typeorm'; +import { WordTranslation } from './word-translation.entity'; +import { Wordbook } from './wordbook.entity'; + +@Entity('words') +export class Word extends AbstractEntity { + @Column({ nullable: true }) + wordbook_id: number; + + @Column({ length: 255, nullable: true }) + @Index() + mark: string; + + @ManyToOne(() => Wordbook, (wordbook) => wordbook.words, { + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }) + @JoinColumn({ name: 'wordbook_id', referencedColumnName: 'id' }) + wordbook: Wordbook; + + @OneToMany(() => WordTranslation, (translation) => translation.word, { cascade: true }) + translations: WordTranslation[]; +} diff --git a/back/src/localization/entities/wordbook.entity.ts b/back/src/localization/entities/wordbook.entity.ts new file mode 100644 index 0000000..c15557c --- /dev/null +++ b/back/src/localization/entities/wordbook.entity.ts @@ -0,0 +1,28 @@ +import { AbstractEntity } from 'src/common/entities/abstract.entity'; +import { Column, Entity, Index, OneToMany } from 'typeorm'; +import { Word } from './word.entity'; + +export enum LoadTo { + All = 'all', + Bot = 'bot', +} + +@Entity('wordbooks') +export class Wordbook extends AbstractEntity { + @Column({ length: 255, nullable: true }) + name: string; + + @Column({ + type: 'enum', + enum: LoadTo, + default: LoadTo.All, + }) + @Index() + load_to: LoadTo; + + @Column({ default: false }) + defended: boolean; + + @OneToMany(() => Word, (word) => word.wordbook, { cascade: true }) + words: Word[]; +} diff --git a/back/src/localization/modules/langs.module.ts b/back/src/localization/modules/langs.module.ts new file mode 100644 index 0000000..d3f0dbc --- /dev/null +++ b/back/src/localization/modules/langs.module.ts @@ -0,0 +1,27 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Admin } from 'src/admins/entities/admin.entity'; +import { AdminsModule } from 'src/admins/admins.module'; +import { AuthModule } from 'src/auth/auth.module'; +import { BotModule } from 'src/bot/bot.module'; +import { CommonModule } from 'src/common/common.module'; +import { DbTransactionFactory } from 'src/database/transaction-factory'; +import { LangsController } from '../controllers/langs.controller'; +import { Lang } from '../entities/lang.entity'; +import { WordTranslation } from '../entities/word-translation.entity'; +import { Word } from '../entities/word.entity'; +import { LangsService } from '../services/langs.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Lang, Word, WordTranslation, Admin]), + CommonModule, + AuthModule, + AdminsModule, + forwardRef(() => BotModule), + ], + controllers: [LangsController], + providers: [LangsService, DbTransactionFactory], + exports: [LangsService], +}) +export class LangsModule {} diff --git a/back/src/localization/modules/wordbooks.module.ts b/back/src/localization/modules/wordbooks.module.ts new file mode 100644 index 0000000..1ab8dd2 --- /dev/null +++ b/back/src/localization/modules/wordbooks.module.ts @@ -0,0 +1,28 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { WordbooksService } from '../services/wordbooks.service'; +import { WordbooksController } from '../controllers/wordbooks.controller'; +import { Wordbook } from '../entities/wordbook.entity'; +import { Word } from '../entities/word.entity'; +import { WordTranslation } from '../entities/word-translation.entity'; +import { Lang } from '../entities/lang.entity'; +import { Admin } from 'src/admins/entities/admin.entity'; +import { AdminsModule } from 'src/admins/admins.module'; +import { AuthModule } from 'src/auth/auth.module'; +import { CommonModule } from 'src/common/common.module'; +import { DbTransactionFactory } from 'src/database/transaction-factory'; +import { BotModule } from 'src/bot/bot.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Wordbook, Word, WordTranslation, Lang, Admin]), + CommonModule, + AuthModule, + AdminsModule, + forwardRef(() => BotModule), // Для доступа к BotService + ], + controllers: [WordbooksController], + providers: [WordbooksService, DbTransactionFactory], + exports: [WordbooksService], +}) +export class WordbooksModule {} diff --git a/back/src/localization/modules/words.module.ts b/back/src/localization/modules/words.module.ts new file mode 100644 index 0000000..98f862c --- /dev/null +++ b/back/src/localization/modules/words.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Lang } from '../entities/lang.entity'; +import { WordTranslation } from '../entities/word-translation.entity'; +import { Word } from '../entities/word.entity'; +import { Wordbook } from '../entities/wordbook.entity'; +import { WordsService } from '../services/words.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Wordbook, Lang, Word, WordTranslation]), + ], + providers: [WordsService], + exports: [WordsService], +}) +export class WordsModule {} diff --git a/back/src/localization/services/langs.service.ts b/back/src/localization/services/langs.service.ts new file mode 100644 index 0000000..dcd7952 --- /dev/null +++ b/back/src/localization/services/langs.service.ts @@ -0,0 +1,222 @@ +import { BadRequestException, Inject, Injectable, Logger, NotFoundException, forwardRef } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { BotService } from 'src/bot/bot.service'; +import { IFiltering } from 'src/common/decorators/filtering-params.decorator'; +import { IPagination, PaginatedResource } from 'src/common/decorators/pagination-params.decorator'; +import { ISorting } from 'src/common/decorators/sorting-params.decorator'; +import { ErrorCode } from 'src/common/enums/error-code.enum'; +import { DbTransactionFactory, saveWithTransactions } from 'src/database/transaction-factory'; +import { In, Repository } from 'typeorm'; +import { CreateLangDto } from '../dto/create-lang.dto'; +import { UpdateLangDto } from '../dto/update-lang.dto'; +import { Lang } from '../entities/lang.entity'; +import { WordTranslation } from '../entities/word-translation.entity'; +import { Word } from '../entities/word.entity'; + +@Injectable() +export class LangsService { + private readonly _logger = new Logger(LangsService.name); + + constructor( + @InjectRepository(Lang) private langRepository: Repository, + @InjectRepository(Word) private wordRepository: Repository, + @InjectRepository(WordTranslation) private wordTranslationRepository: Repository, + private transactionRunner: DbTransactionFactory, + @Inject(forwardRef(() => BotService)) + private botService?: BotService, + ) {} + + async findAll(): Promise { + return this.langRepository.find(); + } + + async findAllActive(): Promise { + return this.langRepository.find({ + where: { active: true }, + select: ['id', 'slug', 'title', 'dir', 'dateformat'], + }); + } + + async findChunk( + { limit, offset }: IPagination, + sorting?: ISorting, + filtering?: IFiltering, + ): Promise> { + const [items, totalCount] = await this.langRepository.findAndCount({ + take: limit, + skip: offset, + order: sorting, + where: filtering, + }); + + return { totalCount, items }; + } + + async findOne(id: number): Promise { + const lang = await this.langRepository.findOne({ where: { id } }); + + if (!lang) { + throw new NotFoundException(ErrorCode.AdminNotFound); // TODO: создать ErrorCode для Lang + } + + return lang; + } + + async create(dto: CreateLangDto): Promise { + const transactionalRunner = await this.transactionRunner.createTransaction(); + + try { + await transactionalRunner.startTransaction(); + + const lang = await saveWithTransactions.call( + this.langRepository, + dto, + transactionalRunner.transactionManager, + ); + + // Автоматически создаем записи переводов для всех существующих сущностей + await this.rebuildMultilangEntities(lang.id, transactionalRunner.transactionManager); + + await transactionalRunner.commitTransaction(); + + // Обновляем кэш бота после создания языка + if (this.botService) { + await this.botService.reloadWords().catch((err) => { + this._logger.warn(`Failed to reload bot cache: ${err.message}`); + }); + } + + return lang; + } catch (error) { + await transactionalRunner.rollbackTransaction(); + this._logger.error(error.message, error.stack); + throw error; + } finally { + await transactionalRunner.releaseTransaction(); + } + } + + async update(id: number, dto: UpdateLangDto): Promise { + const transactionalRunner = await this.transactionRunner.createTransaction(); + + try { + await transactionalRunner.startTransaction(); + + const lang = await this.findOne(id); + + Object.assign(lang, dto); + + const updatedLang = await saveWithTransactions.call( + this.langRepository, + lang, + transactionalRunner.transactionManager, + ); + + await transactionalRunner.commitTransaction(); + + // Обновляем кэш бота после обновления языка (особенно если изменился active) + if (this.botService) { + await this.botService.reloadWords().catch((err) => { + this._logger.warn(`Failed to reload bot cache: ${err.message}`); + }); + } + + return updatedLang; + } catch (error) { + await transactionalRunner.rollbackTransaction(); + this._logger.error(error.message, error.stack); + throw error; + } finally { + await transactionalRunner.releaseTransaction(); + } + } + + async remove(id: number): Promise { + const transactionalRunner = await this.transactionRunner.createTransaction(); + + try { + await transactionalRunner.startTransaction(); + + const lang = await this.findOne(id); + + if (lang.defended) { + throw new BadRequestException('Cannot delete defended language'); + } + + await this.langRepository.remove(lang); + + await transactionalRunner.commitTransaction(); + + // Обновляем кэш бота после удаления языка + if (this.botService) { + await this.botService.reloadWords().catch((err) => { + this._logger.warn(`Failed to reload bot cache: ${err.message}`); + }); + } + } catch (error) { + await transactionalRunner.rollbackTransaction(); + this._logger.error(error.message, error.stack); + throw error; + } finally { + await transactionalRunner.releaseTransaction(); + } + } + + async removeBulk(ids: number[]): Promise { + const transactionalRunner = await this.transactionRunner.createTransaction(); + + try { + await transactionalRunner.startTransaction(); + + const langs = await this.langRepository.find({ + where: { id: In(ids) }, + }); + + const defendedLangs = langs.filter((lang) => lang.defended); + if (defendedLangs.length > 0) { + throw new BadRequestException('Cannot delete defended languages'); + } + + await this.langRepository.remove(langs); + + await transactionalRunner.commitTransaction(); + + // Обновляем кэш бота после массового удаления языков + if (this.botService) { + await this.botService.reloadWords().catch((err) => { + this._logger.warn(`Failed to reload bot cache: ${err.message}`); + }); + } + } catch (error) { + await transactionalRunner.rollbackTransaction(); + this._logger.error(error.message, error.stack); + throw error; + } finally { + await transactionalRunner.releaseTransaction(); + } + } + + /** + * Создает записи переводов для всех существующих сущностей при создании нового языка + */ + private async rebuildMultilangEntities( + langId: number, + transactionManager: any, + ): Promise { + // Получаем все слова + const words = await this.wordRepository.find(); + + // Создаем записи переводов для каждого слова + const translations = words.map((word) => + this.wordTranslationRepository.create({ + word_id: word.id, + lang_id: langId, + text: null, + }), + ); + + if (translations.length > 0) { + await transactionManager.save(WordTranslation, translations); + } + } +} diff --git a/back/src/localization/services/wordbooks.service.ts b/back/src/localization/services/wordbooks.service.ts new file mode 100644 index 0000000..2662172 --- /dev/null +++ b/back/src/localization/services/wordbooks.service.ts @@ -0,0 +1,294 @@ +import { BadRequestException, Inject, Injectable, Logger, NotFoundException, forwardRef } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { BotService } from 'src/bot/bot.service'; +import { IFiltering } from 'src/common/decorators/filtering-params.decorator'; +import { IPagination, PaginatedResource } from 'src/common/decorators/pagination-params.decorator'; +import { ISorting } from 'src/common/decorators/sorting-params.decorator'; +import { ErrorCode } from 'src/common/enums/error-code.enum'; +import { DbTransactionFactory, saveWithTransactions } from 'src/database/transaction-factory'; +import { In, Repository } from 'typeorm'; +import { CreateWordbookDto } from '../dto/create-wordbook.dto'; +import { UpdateWordbookDto } from '../dto/update-wordbook.dto'; +import { Lang } from '../entities/lang.entity'; +import { WordTranslation } from '../entities/word-translation.entity'; +import { Word } from '../entities/word.entity'; +import { LoadTo, Wordbook } from '../entities/wordbook.entity'; + +@Injectable() +export class WordbooksService { + private readonly _logger = new Logger(WordbooksService.name); + + constructor( + @InjectRepository(Wordbook) private wordbookRepository: Repository, + @InjectRepository(Word) private wordRepository: Repository, + @InjectRepository(WordTranslation) private wordTranslationRepository: Repository, + private transactionRunner: DbTransactionFactory, + @Inject(forwardRef(() => BotService)) + private botService?: BotService, + ) {} + + async findChunk( + { limit, offset }: IPagination, + sorting?: ISorting, + filtering?: IFiltering, + ): Promise> { + const [items, totalCount] = await this.wordbookRepository.findAndCount({ + take: limit, + skip: offset, + order: sorting, + where: filtering, + relations: ['words'], + }); + + return { totalCount, items }; + } + + async findOne(id: number): Promise { + const wordbook = await this.wordbookRepository + .createQueryBuilder('wordbook') + .leftJoinAndSelect('wordbook.words', 'word') + .leftJoinAndSelect('word.translations', 'translation') + .leftJoinAndSelect('translation.lang', 'lang') + .where('wordbook.id = :id', { id }) + .getOne(); + + if (!wordbook) { + throw new NotFoundException(ErrorCode.AdminNotFound); // TODO: создать ErrorCode для Wordbook + } + + return wordbook; + } + + async create(dto: CreateWordbookDto): Promise { + const transactionalRunner = await this.transactionRunner.createTransaction(); + + try { + await transactionalRunner.startTransaction(); + + const wordbookData: Partial = { + name: dto.name, + load_to: dto.load_to || LoadTo.All, + }; + + const wordbook = await saveWithTransactions.call( + this.wordbookRepository, + wordbookData, + transactionalRunner.transactionManager, + ); + + // Создаем слова, если они переданы + if (dto.words && dto.words.length > 0) { + const words = dto.words.map((wordDto) => + this.wordRepository.create({ + ...wordDto, + wordbook_id: wordbook.id, + }), + ); + + await transactionalRunner.transactionManager.save(Word, words); + + // Создаем переводы для всех языков для каждого слова + const langs = await transactionalRunner.transactionManager.find(Lang); + const translations: WordTranslation[] = []; + + for (const word of words) { + for (const lang of langs) { + translations.push( + this.wordTranslationRepository.create({ + word_id: word.id, + lang_id: lang.id, + text: null, + }), + ); + } + } + + if (translations.length > 0) { + await transactionalRunner.transactionManager.save(WordTranslation, translations); + } + } + + await transactionalRunner.commitTransaction(); + + // Обновляем кэш бота после создания словаря + if (this.botService) { + await this.botService.reloadWords().catch((err) => { + this._logger.warn(`Failed to reload bot cache: ${err.message}`); + }); + } + + return this.findOne(wordbook.id); + } catch (error) { + await transactionalRunner.rollbackTransaction(); + this._logger.error(error.message, error.stack); + throw error; + } finally { + await transactionalRunner.releaseTransaction(); + } + } + + async update(id: number, dto: UpdateWordbookDto): Promise { + const transactionalRunner = await this.transactionRunner.createTransaction(); + + try { + await transactionalRunner.startTransaction(); + + const wordbook = await this.findOne(id); + + if (wordbook.defended && dto.defended === false) { + throw new BadRequestException('Cannot unprotect defended wordbook'); + } + + Object.assign(wordbook, { + name: dto.name, + load_to: dto.load_to, + defended: dto.defended, + }); + + await saveWithTransactions.call( + this.wordbookRepository, + wordbook, + transactionalRunner.transactionManager, + ); + + // Обновляем слова + if (dto.words !== undefined) { + // Удаляем слова без привязки + await transactionalRunner.transactionManager.delete(Word, { + wordbook_id: id, + }); + + // Создаем новые слова + if (dto.words.length > 0) { + const words = dto.words.map((wordDto) => + this.wordRepository.create({ + mark: wordDto.mark, + wordbook_id: id, + }), + ); + + const savedWords = await transactionalRunner.transactionManager.save(Word, words); + + // Получаем все языки для маппинга по slug + const langs = await transactionalRunner.transactionManager.find(Lang); + const langMapBySlug = new Map(langs.map((lang) => [lang.slug, lang])); + + // Создаем переводы + const translations: WordTranslation[] = []; + + for (let i = 0; i < savedWords.length; i++) { + const word = savedWords[i]; + const wordDto = dto.words[i]; + + // Создаем маппинг переводов из DTO по slug + const translationsMap = new Map(); + if (wordDto.translations && wordDto.translations.length > 0) { + for (const transDto of wordDto.translations) { + translationsMap.set(transDto.lang_slug, transDto.text || null); + } + } + + // Создаем переводы для всех языков + // Используем значение из DTO, если есть, иначе null + for (const lang of langs) { + const text = translationsMap.get(lang.slug) ?? null; + translations.push( + this.wordTranslationRepository.create({ + word_id: word.id, + lang_id: lang.id, + text, + }), + ); + } + } + + if (translations.length > 0) { + await transactionalRunner.transactionManager.save(WordTranslation, translations); + } + } + } + + await transactionalRunner.commitTransaction(); + + // Обновляем кэш бота после обновления словаря + if (this.botService) { + await this.botService.reloadWords().catch((err) => { + this._logger.warn(`Failed to reload bot cache: ${err.message}`); + }); + } + + return this.findOne(id); + } catch (error) { + await transactionalRunner.rollbackTransaction(); + this._logger.error(error.message, error.stack); + throw error; + } finally { + await transactionalRunner.releaseTransaction(); + } + } + + async remove(id: number): Promise { + const transactionalRunner = await this.transactionRunner.createTransaction(); + + try { + await transactionalRunner.startTransaction(); + + const wordbook = await this.findOne(id); + + if (wordbook.defended) { + throw new BadRequestException('Cannot delete defended wordbook'); + } + + await this.wordbookRepository.remove(wordbook); + + await transactionalRunner.commitTransaction(); + + // Обновляем кэш бота после удаления словаря + if (this.botService) { + await this.botService.reloadWords().catch((err) => { + this._logger.warn(`Failed to reload bot cache: ${err.message}`); + }); + } + } catch (error) { + await transactionalRunner.rollbackTransaction(); + this._logger.error(error.message, error.stack); + throw error; + } finally { + await transactionalRunner.releaseTransaction(); + } + } + + async removeBulk(ids: number[]): Promise { + const transactionalRunner = await this.transactionRunner.createTransaction(); + + try { + await transactionalRunner.startTransaction(); + + const wordbooks = await this.wordbookRepository.find({ + where: { id: In(ids) }, + }); + + const defendedWordbooks = wordbooks.filter((wb) => wb.defended); + if (defendedWordbooks.length > 0) { + throw new BadRequestException('Cannot delete defended wordbooks'); + } + + await this.wordbookRepository.remove(wordbooks); + + await transactionalRunner.commitTransaction(); + + // Обновляем кэш бота после массового удаления словарей + if (this.botService) { + await this.botService.reloadWords().catch((err) => { + this._logger.warn(`Failed to reload bot cache: ${err.message}`); + }); + } + } catch (error) { + await transactionalRunner.rollbackTransaction(); + this._logger.error(error.message, error.stack); + throw error; + } finally { + await transactionalRunner.releaseTransaction(); + } + } +} diff --git a/back/src/localization/services/words.service.ts b/back/src/localization/services/words.service.ts new file mode 100644 index 0000000..7ae41f9 --- /dev/null +++ b/back/src/localization/services/words.service.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In } from 'typeorm'; +import { Wordbook } from '../entities/wordbook.entity'; +import { Lang } from '../entities/lang.entity'; +import { Word } from '../entities/word.entity'; +import { WordTranslation } from '../entities/word-translation.entity'; +import { LoadTo } from '../entities/wordbook.entity'; + +export type IWords = { + [wordbookName: string]: { + [mark: string]: { + [langSlug: string]: string; + }; + }; +}; + +@Injectable() +export class WordsService { + constructor( + @InjectRepository(Wordbook) private wordbookRepository: Repository, + @InjectRepository(Lang) private langRepository: Repository, + @InjectRepository(Word) private wordRepository: Repository, + @InjectRepository(WordTranslation) private wordTranslationRepository: Repository, + ) {} + + /** + * Получить все словари с переводами для бота + * Загружает только словари где load_to IN ('all', 'bot') + * Загружает только активные языки + */ + async findAll(): Promise { + // Загружаем активные языки + const langs = await this.langRepository.find({ + where: { active: true }, + }); + + // Загружаем словари для бота + const wordbooks = await this.wordbookRepository.find({ + where: { load_to: In([LoadTo.All, LoadTo.Bot]) }, + relations: ['words', 'words.translations', 'words.translations.lang'], + }); + + // Формируем структуру данных + const result: IWords = {}; + + for (const wordbook of wordbooks) { + if (!wordbook.name) continue; + + result[wordbook.name] = {}; + + for (const word of wordbook.words || []) { + if (!word.mark) continue; + + result[wordbook.name][word.mark] = {}; + + for (const translation of word.translations || []) { + const langSlug = translation.lang?.slug; + if (langSlug && translation.text) { + result[wordbook.name][word.mark][langSlug] = translation.text; + } + } + } + } + + return result; + } +} diff --git a/compose.dev.yml b/compose.dev.yml index d727fdc..fb174f2 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -44,13 +44,11 @@ services: front: container_name: ${PROJECT_NAME}_front - build: - context: ./front - args: - - VITE_API_URL=${API_URL} + build: ./front environment: NODE_ENV: development PORT: ${FRONT_PORT} + VITE_API_URL: ${API_URL} HOST: 0.0.0.0 restart: unless-stopped volumes: diff --git a/compose.yml b/compose.yml index 2980ebf..ef1829a 100644 --- a/compose.yml +++ b/compose.yml @@ -45,6 +45,7 @@ services: environment: NODE_ENV: production PORT: ${FRONT_PORT} + VITE_API_URL: ${API_URL} restart: always depends_on: - back diff --git a/front/src/api/entities/index.ts b/front/src/api/entities/index.ts index 9297e48..a6a01b8 100644 --- a/front/src/api/entities/index.ts +++ b/front/src/api/entities/index.ts @@ -1 +1,5 @@ export * from './admin'; +export * from './lang'; +export * from './wordbook'; +export * from './word'; +export * from './word-translation'; diff --git a/front/src/api/entities/lang.ts b/front/src/api/entities/lang.ts new file mode 100644 index 0000000..1347739 --- /dev/null +++ b/front/src/api/entities/lang.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; +import { AbstractEntity } from './abstract'; +import { booleanSchema } from '@/utilities/boolean'; + +export enum DateFormat { + En = 'en', + Ru = 'ru', +} + +export const Lang = AbstractEntity.extend({ + slug: z.string().nullable().nullish(), + title: z.string().nullable().nullish(), + active: booleanSchema, + dir: z.string().default('ltr'), + dateformat: z.nativeEnum(DateFormat).default(DateFormat.En), + defended: booleanSchema, +}); + +export const LangDto = Lang.omit({ + created_at: true, + updated_at: true, +}).partial().extend({ + id: z.number().optional(), // Для update +}); + +export type Lang = z.infer; +export type LangDto = z.infer; diff --git a/front/src/api/entities/word-translation.ts b/front/src/api/entities/word-translation.ts new file mode 100644 index 0000000..f9d9967 --- /dev/null +++ b/front/src/api/entities/word-translation.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; +import { AbstractEntity } from './abstract'; + +export const WordTranslation = AbstractEntity.extend({ + word_id: z.number(), + lang_id: z.number(), + text: z.string().nullable().nullish(), + word: z.any().optional(), // Используем z.any() для избежания циклических зависимостей + lang: z.any().optional(), // Используем z.any() для избежания циклических зависимостей +}); + +export type WordTranslation = z.infer; diff --git a/front/src/api/entities/word.ts b/front/src/api/entities/word.ts new file mode 100644 index 0000000..fcd0505 --- /dev/null +++ b/front/src/api/entities/word.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; +import { AbstractEntity } from './abstract'; + +export const Word = AbstractEntity.extend({ + wordbook_id: z.number().nullable().nullish(), + mark: z.string().nullable().nullish(), + translations: z.array(z.any()).optional(), // Используем z.any() для избежания циклических зависимостей +}); + +export const WordDto = Word.omit({ + id: true, + created_at: true, + updated_at: true, + translations: true, +}).partial(); + +export type Word = z.infer; +export type WordDto = z.infer; diff --git a/front/src/api/entities/wordbook.ts b/front/src/api/entities/wordbook.ts new file mode 100644 index 0000000..5658087 --- /dev/null +++ b/front/src/api/entities/wordbook.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; +import { AbstractEntity } from './abstract'; +import { booleanSchema } from '@/utilities/boolean'; + +export enum LoadTo { + All = 'all', + Bot = 'bot', +} + +export const Wordbook = AbstractEntity.extend({ + name: z.string().nullable().nullish(), + load_to: z.nativeEnum(LoadTo).default(LoadTo.All), + defended: booleanSchema, + words: z.array(z.any()).optional(), // Используем z.any() для избежания циклических зависимостей +}); + +export const WordbookDto = Wordbook.omit({ + id: true, + created_at: true, + updated_at: true, + words: true, +}).extend({ + words: z.array(z.object({ + mark: z.string().optional().nullable(), + translations: z.array(z.object({ + lang_slug: z.string(), + text: z.string().nullable().optional(), + })).optional(), + })).optional(), +}).partial().extend({ + id: z.number().optional(), // Для update +}); + +export type Wordbook = z.infer; +export type WordbookDto = z.infer; diff --git a/front/src/api/helpers.ts b/front/src/api/helpers.ts index 307d501..7fdca3b 100644 --- a/front/src/api/helpers.ts +++ b/front/src/api/helpers.ts @@ -120,7 +120,8 @@ function createUrl( }); } - return `${url}?${query.toString()}`; + const queryString = query.toString(); + return queryString ? `${url}?${queryString}` : url; } type QueryKey = [string] | [string, Record]; @@ -261,7 +262,6 @@ export function createPostMutationHook< }: CreatePostMutationHookArgs) { return (params?: { query?: QueryParams; route?: RouteParams }) => { const queryClient = useQueryClient(); - const baseUrl = createUrl(endpoint, params?.query, params?.route); const mutationFn = async ({ variables, @@ -272,7 +272,7 @@ export function createPostMutationHook< query?: QueryParams; route?: RouteParams; }) => { - const url = createUrl(baseUrl, query, route); + const url = createUrl(endpoint, query || params?.query, route || params?.route); const config = options?.isMultipart ? { headers: { 'Content-Type': 'multipart/form-data' } } @@ -345,7 +345,6 @@ export function createPatchMutationHook< }: CreatePatchMutationHookArgs) { return (params?: { query?: QueryParams; route?: RouteParams }) => { const queryClient = useQueryClient(); - const baseUrl = createUrl(endpoint, params?.query, params?.route); const mutationFn = async ({ variables, route, @@ -355,7 +354,7 @@ export function createPatchMutationHook< query?: QueryParams; route?: RouteParams; }) => { - const url = createUrl(baseUrl, query, route); + const url = createUrl(endpoint, query || params?.query, route || params?.route); const config = options?.isMultipart ? { headers: { 'Content-Type': 'multipart/form-data' } } diff --git a/front/src/components/widgets/forms/index.ts b/front/src/components/widgets/forms/index.ts index bf1ccc7..371a2a4 100644 --- a/front/src/components/widgets/forms/index.ts +++ b/front/src/components/widgets/forms/index.ts @@ -1 +1,3 @@ -export * from "./admin-form"; +export * from './admin-form'; +export * from './lang-form'; +export * from './wordbook-form'; diff --git a/front/src/components/widgets/forms/lang-form/index.tsx b/front/src/components/widgets/forms/lang-form/index.tsx new file mode 100644 index 0000000..7d52720 --- /dev/null +++ b/front/src/components/widgets/forms/lang-form/index.tsx @@ -0,0 +1,184 @@ +import { Controller, FormProvider, SubmitHandler, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { FC, useEffect } from 'react'; +import { + Box, + Button, + LoadingOverlay, + Select, + Skeleton, + Stack, + TextInput, + rem, +} from '@mantine/core'; +import { useCreateLang, useOneLang, useUpdateLang } from '@/hooks'; +import { DateFormat, LangDto } from '@/api/entities'; +import { closeModal } from '@mantine/modals'; + +interface Props { + modalId: string; + langId: number | undefined; +} + +export const LangForm: FC = ({ modalId, langId }) => { + const { mutate: update, isPending: isUpdateLoading } = useUpdateLang(); + const { mutate: create, isPending: isCreateLoading } = useCreateLang(); + const { data: lang, isLoading } = useOneLang({ route: { id: langId } }); + + const methods = useForm({ + resolver: zodResolver(LangDto), + mode: 'onChange', + reValidateMode: 'onChange', + defaultValues: { + slug: '', + title: '', + active: true, + dir: 'ltr', + dateformat: DateFormat.En, + defended: false, + }, + }); + + const { + handleSubmit, + formState: { errors }, + setValue, + watch, + } = methods; + + useEffect(() => { + if (lang) { + methods.reset(lang?.data); + } else { + setValue('active', true); + setValue('dir', 'ltr'); + setValue('dateformat', DateFormat.En); + setValue('defended', false); + } + }, [lang]); + + const onSubmit: SubmitHandler = async (values: any) => { + const data = { + variables: langId ? { ...values, id: langId } : values, + route: { id: langId }, + }; + + const onSuccess = () => { + closeModal(modalId); + }; + + if (langId) { + update(data, { onSuccess }); + } else { + create(data, { onSuccess }); + } + }; + + return ( + + + + + + { + return ( + + + + ); + }} + /> + { + return ( + + + + ); + }} + /> + { + return ( + + + + ); + }} + /> + { + return ( + + + + ); + }} + /> + + + {!wordbookId && ( + + After creating the wordbook, you will be redirected to the edit page where you can add words and translations. + + )} + + + + + + ); +}; diff --git a/front/src/components/widgets/modals/index.ts b/front/src/components/widgets/modals/index.ts index 04ba097..97abf57 100644 --- a/front/src/components/widgets/modals/index.ts +++ b/front/src/components/widgets/modals/index.ts @@ -1 +1,3 @@ -export * from "./admin-modal"; +export * from './admin-modal'; +export * from './lang-modal'; +export * from './wordbook-modal'; diff --git a/front/src/components/widgets/modals/lang-modal/index.tsx b/front/src/components/widgets/modals/lang-modal/index.tsx new file mode 100644 index 0000000..2567707 --- /dev/null +++ b/front/src/components/widgets/modals/lang-modal/index.tsx @@ -0,0 +1,6 @@ +import { ContextModalProps } from '@mantine/modals'; +import { LangForm } from '../../forms/lang-form'; + +export function LangModal({ context, id, innerProps }: ContextModalProps<{ langId?: number }>) { + return ; +} diff --git a/front/src/components/widgets/modals/wordbook-modal/index.tsx b/front/src/components/widgets/modals/wordbook-modal/index.tsx new file mode 100644 index 0000000..3797aa1 --- /dev/null +++ b/front/src/components/widgets/modals/wordbook-modal/index.tsx @@ -0,0 +1,6 @@ +import { ContextModalProps } from '@mantine/modals'; +import { WordbookForm } from '../../forms/wordbook-form'; + +export function WordbookModal({ context, id, innerProps }: ContextModalProps<{ wordbookId?: number }>) { + return ; +} diff --git a/front/src/config.ts b/front/src/config.ts index 56110b3..f40d38c 100644 --- a/front/src/config.ts +++ b/front/src/config.ts @@ -1,6 +1,7 @@ export const app = { name: 'Admin Panel', - apiBaseUrl: import.meta.env.VITE_API_URL, + // apiBaseUrl: import.meta.env.VITE_API_URL, + apiBaseUrl: 'http://localhost:4000', redirectQueryParamName: 'r', accessTokenStoreKey: 'access_token', }; diff --git a/front/src/hooks/api/index.ts b/front/src/hooks/api/index.ts index 0ffd860..93a93e9 100644 --- a/front/src/hooks/api/index.ts +++ b/front/src/hooks/api/index.ts @@ -1,3 +1,5 @@ export * from './account'; export * from './auth'; export * from './admins'; +export * from './langs'; +export * from './wordbooks'; diff --git a/front/src/hooks/api/langs.ts b/front/src/hooks/api/langs.ts new file mode 100644 index 0000000..6a1c773 --- /dev/null +++ b/front/src/hooks/api/langs.ts @@ -0,0 +1,89 @@ +import { z } from 'zod'; +import { notifications } from '@mantine/notifications'; +import { Lang, LangDto } from '@/api/entities'; +import { + createDeleteMutationHook, + createGetQueryHook, + createPaginationQueryHook, + createPatchMutationHook, + createPostMutationHook, +} from '@/api/helpers'; + +const QUERY_KEY = 'langs'; +const BASE_ENDPOINT = 'admin/langs'; + +export const useGetLangs = createPaginationQueryHook({ + endpoint: `${BASE_ENDPOINT}/chunk`, + dataSchema: Lang, + rQueryParams: { queryKey: [QUERY_KEY] }, +}); + +export const useGetAllLangs = createGetQueryHook({ + endpoint: `${BASE_ENDPOINT}/all`, + responseSchema: z.array(Lang), + rQueryParams: { queryKey: [QUERY_KEY, { variant: 'all' }] }, +}); + +export const useOneLang = createGetQueryHook({ + endpoint: `${BASE_ENDPOINT}/one/:id`, + responseSchema: Lang, + rQueryParams: { queryKey: [QUERY_KEY] }, +}); + +export const useCreateLang = createPostMutationHook({ + endpoint: `${BASE_ENDPOINT}/create`, + bodySchema: LangDto, + responseSchema: Lang, + rMutationParams: { + onSuccess: (data, variables, context, queryClient) => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEY] }); + notifications.show({ title: 'Created', message: 'Language created successfully!' }); + }, + onError: (error) => { + notifications.show({ message: error.message, color: 'red' }); + }, + }, +}); + +export const useUpdateLang = createPatchMutationHook({ + endpoint: `${BASE_ENDPOINT}/update/:id`, + bodySchema: LangDto, + responseSchema: Lang, + rMutationParams: { + onSuccess: (data, variables, context, queryClient) => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEY] }); + notifications.show({ title: 'Updated', message: 'Language updated successfully!' }); + }, + onError: (error) => { + notifications.show({ message: error.message, color: 'red' }); + }, + }, +}); + +export const useDeleteLang = createDeleteMutationHook({ + endpoint: `${BASE_ENDPOINT}/delete/:id`, + rMutationParams: { + onSuccess: (data, variables, context, queryClient) => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEY] }); + notifications.show({ title: 'Deleted', message: 'Language deleted successfully!' }); + }, + onError: (error) => { + notifications.show({ message: error.message, color: 'red' }); + }, + }, +}); + +export const useDeleteBulkLangs = createPostMutationHook, z.ZodType>({ + endpoint: `${BASE_ENDPOINT}/delete-bulk`, + bodySchema: z.object({ ids: z.array(z.number()) }), + responseSchema: z.any(), + rMutationParams: { + onSuccess: (data, variables, context, queryClient) => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEY] }); + notifications.show({ title: 'Deleted', message: 'Languages deleted successfully!' }); + }, + onError: (error) => { + notifications.show({ message: error.message, color: 'red' }); + }, + }, +}); diff --git a/front/src/hooks/api/wordbooks.ts b/front/src/hooks/api/wordbooks.ts new file mode 100644 index 0000000..cd7e3f5 --- /dev/null +++ b/front/src/hooks/api/wordbooks.ts @@ -0,0 +1,83 @@ +import { z } from 'zod'; +import { notifications } from '@mantine/notifications'; +import { Wordbook, WordbookDto } from '@/api/entities'; +import { + createDeleteMutationHook, + createGetQueryHook, + createPaginationQueryHook, + createPatchMutationHook, + createPostMutationHook, +} from '@/api/helpers'; + +const QUERY_KEY = 'wordbooks'; +const BASE_ENDPOINT = 'admin/wordbooks'; + +export const useGetWordbooks = createPaginationQueryHook({ + endpoint: `${BASE_ENDPOINT}/chunk`, + dataSchema: Wordbook, + rQueryParams: { queryKey: [QUERY_KEY] }, +}); + +export const useOneWordbook = createGetQueryHook({ + endpoint: `${BASE_ENDPOINT}/one/:id`, + responseSchema: Wordbook, + rQueryParams: { queryKey: [QUERY_KEY] }, +}); + +export const useCreateWordbook = createPostMutationHook({ + endpoint: `${BASE_ENDPOINT}/create`, + bodySchema: WordbookDto, + responseSchema: Wordbook, + rMutationParams: { + onSuccess: (data, variables, context, queryClient) => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEY] }); + notifications.show({ title: 'Created', message: 'Wordbook created successfully!' }); + }, + onError: (error) => { + notifications.show({ message: error.message, color: 'red' }); + }, + }, +}); + +export const useUpdateWordbook = createPatchMutationHook({ + endpoint: `${BASE_ENDPOINT}/update/:id`, + bodySchema: WordbookDto, + responseSchema: Wordbook, + rMutationParams: { + onSuccess: (data, variables, context, queryClient) => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEY] }); + notifications.show({ title: 'Updated', message: 'Wordbook updated successfully!' }); + }, + onError: (error) => { + notifications.show({ message: error.message, color: 'red' }); + }, + }, +}); + +export const useDeleteWordbook = createDeleteMutationHook({ + endpoint: `${BASE_ENDPOINT}/delete/:id`, + rMutationParams: { + onSuccess: (data, variables, context, queryClient) => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEY] }); + notifications.show({ title: 'Deleted', message: 'Wordbook deleted successfully!' }); + }, + onError: (error) => { + notifications.show({ message: error.message, color: 'red' }); + }, + }, +}); + +export const useDeleteBulkWordbooks = createPostMutationHook, z.ZodType>({ + endpoint: `${BASE_ENDPOINT}/delete-bulk`, + bodySchema: z.object({ ids: z.array(z.number()) }), + responseSchema: z.any(), + rMutationParams: { + onSuccess: (data, variables, context, queryClient) => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEY] }); + notifications.show({ title: 'Deleted', message: 'Wordbooks deleted successfully!' }); + }, + onError: (error) => { + notifications.show({ message: error.message, color: 'red' }); + }, + }, +}); diff --git a/front/src/layouts/dashboard/sidebar/menu-sections.ts b/front/src/layouts/dashboard/sidebar/menu-sections.ts index f11ac68..49138c2 100644 --- a/front/src/layouts/dashboard/sidebar/menu-sections.ts +++ b/front/src/layouts/dashboard/sidebar/menu-sections.ts @@ -1,6 +1,8 @@ import { ElementType } from 'react'; import { PiUserPlusDuotone, + PiTranslateDuotone, + PiBooksDuotone, } from 'react-icons/pi'; import { paths } from '@/routes/paths'; @@ -31,4 +33,21 @@ export const menu: MenuItem[] = [ }, ], }, + { + header: 'Localization', + section: [ + { + name: 'Languages', + key: 'langs', + icon: PiTranslateDuotone, + href: paths.dashboard.localization.langs.list, + }, + { + name: 'Wordbooks', + key: 'wordbooks', + icon: PiBooksDuotone, + href: paths.dashboard.localization.wordbooks.list, + }, + ], + }, ]; diff --git a/front/src/pages/dashboard/localization/langs/list/index.tsx b/front/src/pages/dashboard/localization/langs/list/index.tsx new file mode 100644 index 0000000..c828a2c --- /dev/null +++ b/front/src/pages/dashboard/localization/langs/list/index.tsx @@ -0,0 +1,25 @@ +import { Grid } from '@mantine/core'; +import { Page } from '@/components/page'; +import { PageHeader } from '@/components/page-header'; +import { paths } from '@/routes'; +import { LangsTable } from './langs-table'; + +const breadcrumbs = [ + { label: 'Dashboard', href: paths.dashboard.root }, + { label: 'Localization', href: paths.dashboard.localization.root }, + { label: 'Languages' }, +]; + +export default function ListLangsPage() { + return ( + + + + + + + + + + ); +} diff --git a/front/src/pages/dashboard/localization/langs/list/langs-table.tsx b/front/src/pages/dashboard/localization/langs/list/langs-table.tsx new file mode 100644 index 0000000..f0b32ab --- /dev/null +++ b/front/src/pages/dashboard/localization/langs/list/langs-table.tsx @@ -0,0 +1,276 @@ +import { useMemo, useState } from 'react'; +import { DataTableColumn } from 'mantine-datatable'; +import { TextInput, Checkbox, Select, ActionIcon, Tooltip, Text } from '@mantine/core'; +import { Lang, DateFormat } from '@/api/entities'; +import { usePagination } from '@/api/helpers'; +import { AddButton } from '@/components/add-button'; +import { DataTable } from '@/components/data-table'; +import { useDeleteLang, useGetLangs, useUpdateLang } from '@/hooks'; +import { formatDate } from '@/utilities/date'; +import { modals, openContextModal } from '@mantine/modals'; +import { MODAL_NAMES } from '@/providers/modals-provider'; +import { useDebouncedCallback } from '@mantine/hooks'; + +type SortableFields = Pick; + +export function LangsTable() { + const { page, size, setSize, setPage } = usePagination(); + const { filters, sort } = DataTable.useDataTable({ + sortConfig: { + direction: 'desc', + column: 'id', + }, + }); + + const { data: langs, isLoading } = useGetLangs({ + query: { + page, + size, + sort: sort.query, + }, + }); + + const { mutate: update, isPending: isUpdateLoading } = useUpdateLang(); + const { mutate: deleteLang } = useDeleteLang(); + const [editingCells, setEditingCells] = useState>>({}); + + function openModal(): void; + function openModal(lang: Lang): void; + function openModal(lang?: Lang): void { + openContextModal({ + modal: MODAL_NAMES.LANG, + title: lang?.id ? `Edit language "${lang?.title || lang?.slug}"` : 'Create language', + innerProps: { langId: lang?.id }, + }); + } + + const handleFieldChange = (langId: number, field: keyof Lang, value: any) => { + setEditingCells((prev) => ({ + ...prev, + [langId]: { + ...prev[langId], + [field]: value, + }, + })); + }; + + const debouncedUpdate = useDebouncedCallback((langId: number, updates: Partial) => { + update({ + route: { id: langId }, + variables: { + id: langId, + ...updates, + }, + }); + setEditingCells((prev) => { + const newState = { ...prev }; + delete newState[langId]; + return newState; + }); + }, 1000); + + const handleFieldBlur = (lang: Lang) => { + const updates = editingCells[lang.id]; + if (updates && Object.keys(updates).length > 0) { + debouncedUpdate(lang.id, updates); + } + }; + + const handleActiveToggle = (lang: Lang, checked: boolean) => { + update({ + route: { id: lang.id }, + variables: { + id: lang.id, + active: checked, + }, + }); + }; + + const openDeleteModal = (lang: Lang) => + modals.openConfirmModal({ + title: 'Delete language', + children: {`Are you sure you want to delete language "${lang.title || lang.slug}"?`}, + labels: { confirm: 'Delete', cancel: 'Cancel' }, + confirmProps: { color: 'red' }, + onCancel: () => {}, + onConfirm: () => deleteLang({ model: lang, route: { id: lang.id } }), + }); + + const columns: DataTableColumn[] = useMemo( + () => [ + { + accessor: 'id', + title: 'ID', + width: '0%', + sortable: true, + }, + { + accessor: 'slug', + title: 'Slug', + sortable: true, + render: (lang: Lang) => { + const editingValue = editingCells[lang.id]?.slug ?? lang.slug; + return ( + { + handleFieldChange(lang.id, 'slug', e.target.value); + debouncedUpdate(lang.id, { ...editingCells[lang.id], slug: e.target.value }); + }} + onBlur={() => handleFieldBlur(lang)} + size="xs" + disabled={lang.defended} + styles={{ input: { border: 'none', padding: '4px 8px' } }} + /> + ); + }, + }, + { + accessor: 'title', + title: 'Title', + sortable: true, + render: (lang: Lang) => { + const editingValue = editingCells[lang.id]?.title ?? lang.title; + return ( + { + handleFieldChange(lang.id, 'title', e.target.value); + debouncedUpdate(lang.id, { ...editingCells[lang.id], title: e.target.value }); + }} + onBlur={() => handleFieldBlur(lang)} + size="xs" + disabled={lang.defended} + styles={{ input: { border: 'none', padding: '4px 8px' } }} + /> + ); + }, + }, + { + accessor: 'active', + title: 'Activity', + render: (lang: Lang) => ( + handleActiveToggle(lang, e.currentTarget.checked)} + disabled={lang.defended} + /> + ), + }, + { + accessor: 'dir', + title: 'Direction', + render: (lang: Lang) => { + const editingValue = editingCells[lang.id]?.dir ?? lang.dir; + return ( + { + if (value) { + handleFieldChange(lang.id, 'dateformat', value as DateFormat); + debouncedUpdate(lang.id, { ...editingCells[lang.id], dateformat: value as DateFormat }); + } + }} + data={[ + { value: DateFormat.En, label: 'American' }, + { value: DateFormat.Ru, label: 'European' }, + ]} + size="xs" + disabled={lang.defended} + styles={{ input: { border: 'none', padding: '4px 8px' } }} + /> + ); + }, + }, + { + accessor: 'created_at', + title: 'Created at', + noWrap: true, + width: '0%', + sortable: true, + render: (lang) => formatDate(lang.created_at), + }, + { + accessor: 'updated_at', + title: 'Updated at', + noWrap: true, + width: '0%', + sortable: true, + render: (lang) => formatDate(lang.updated_at), + }, + { + accessor: 'actions', + title: 'Actions', + textAlign: 'right', + width: '0%', + render: (lang: Lang) => ( + openModal(lang)} + onDelete={lang.defended ? undefined : () => openDeleteModal(lang)} + disabledEdit={lang.defended} + /> + ), + }, + ], + [editingCells] + ); + + return ( + + openModal()}> + Create language + + } + /> + + + + + + ); +} diff --git a/front/src/pages/dashboard/localization/wordbooks/edit/index.tsx b/front/src/pages/dashboard/localization/wordbooks/edit/index.tsx new file mode 100644 index 0000000..087564c --- /dev/null +++ b/front/src/pages/dashboard/localization/wordbooks/edit/index.tsx @@ -0,0 +1,314 @@ +import { Lang, LoadTo } from '@/api/entities'; +import { Page } from '@/components/page'; +import { PageHeader } from '@/components/page-header'; +import { useGetAllLangs, useOneWordbook, useUpdateWordbook } from '@/hooks'; +import { paths } from '@/routes'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { ActionIcon, Box, Button, Grid, Group, Radio, Select, Stack, Table, Tabs, Text, TextInput } from '@mantine/core'; +import { useDebouncedCallback } from '@mantine/hooks'; +import { IconArrowLeft, IconDeviceFloppy, IconPlus, IconTrash, IconX } from '@tabler/icons-react'; +import { useEffect, useMemo, useState } from 'react'; +import { Controller, useFieldArray, useForm } from 'react-hook-form'; +import { useNavigate, useParams } from 'react-router-dom'; +import { z } from 'zod'; + +const breadcrumbs = [ + { label: 'Dashboard', href: paths.dashboard.root }, + { label: 'Localization', href: paths.dashboard.localization.root }, + { label: 'Wordbooks', href: paths.dashboard.localization.wordbooks.list }, + { label: 'Edit' }, +]; + +export default function EditWordbookPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const wordbookId = id ? parseInt(id, 10) : undefined; + const { data: wordbookData, isLoading } = useOneWordbook({ route: { id: wordbookId } }); + const { data: langsData } = useGetAllLangs(); + const { mutate: update, isPending: isUpdateLoading } = useUpdateWordbook(); + const [selectedLang, setSelectedLang] = useState(''); + const [activeTab, setActiveTab] = useState('words'); + + const langs = langsData?.data || []; + const wordbook = wordbookData?.data; + + useEffect(() => { + if (langs.length > 0 && !selectedLang) { + setSelectedLang(langs[0].slug || ''); + } + }, [langs, selectedLang]); + + const wordsWithTranslations = useMemo(() => { + if (!wordbook?.words) return []; + return wordbook.words.map((word: any) => { + const translations: Record = {}; + (word.translations || []).forEach((trans: any) => { + if (trans.lang?.slug) { + translations[trans.lang.slug] = trans.text || ''; + } + }); + return { + id: word.id, + mark: word.mark || '', + translations, + }; + }); + }, [wordbook]); + + type WordbookFormData = { + name?: string | null; + load_to?: LoadTo; + words: Array<{ id?: number; mark: string; translations: Record }>; + }; + + const methods = useForm({ + resolver: zodResolver( + z.object({ + name: z.string().nullable().optional(), + load_to: z.nativeEnum(LoadTo).optional(), + words: z.array( + z.object({ + id: z.number().optional(), + mark: z.string(), + translations: z.record(z.string(), z.string()), + }) + ), + }) + ), + mode: 'onChange', + defaultValues: { + name: '', + load_to: LoadTo.All, + words: [], + }, + }); + + const { control, handleSubmit, reset, watch } = methods; + const { fields, append, remove } = useFieldArray({ + control, + name: 'words', + }); + + useEffect(() => { + if (wordbook) { + reset({ + name: wordbook.name || '', + load_to: wordbook.load_to || LoadTo.All, + words: wordsWithTranslations, + }); + } + }, [wordbook, wordsWithTranslations, reset]); + + const debouncedUpdateTranslation = useDebouncedCallback((wordIndex: number, langSlug: string, text: string) => { + const currentWords = watch('words'); + const word = currentWords[wordIndex]; + if (word && word.id) { + // TODO: Добавить API для обновления отдельного перевода + // Пока что обновляем через обновление всего словаря + } + }, 1000); + + const onSubmit = async (values: any) => { + // Преобразуем слова с переводами в формат для API + // Убеждаемся, что для каждого слова есть переводы для всех языков + const words = values.words.map((word: any) => { + const translations: Array<{ lang_slug: string; text: string | null }> = []; + + // Добавляем переводы для всех языков + langs.forEach((lang: Lang) => { + if (lang.slug) { + const text = word.translations?.[lang.slug]; + translations.push({ + lang_slug: lang.slug, + text: text && text.trim() !== '' ? text : null, + }); + } + }); + + return { + mark: word.mark, + translations, + }; + }); + + update({ + route: { id: wordbookId }, + variables: { + name: values.name, + load_to: values.load_to, + words, + id: wordbookId, + }, + }); + }; + + const addWord = () => { + const translations: Record = {}; + langs.forEach((lang: Lang) => { + if (lang.slug) { + translations[lang.slug] = ''; + } + }); + append({ mark: '', translations }); + }; + + if (isLoading) { + return
Loading...
; + } + + if (!wordbook) { + return
Wordbook not found
; + } + + return ( + + + + + + + + + + + + setActiveTab(value || 'words')}> + + Parameters + Words + + + + + ( + + )} + /> + ( +