Implement basic localisations with admin settings

This commit is contained in:
Денис 2026-02-17 19:07:41 +03:00
parent 307d7a9104
commit 49be6bb588
54 changed files with 2966 additions and 21 deletions

View File

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

View File

@ -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],

View File

@ -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],

View File

@ -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<string>('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<void> {
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<void> {
await this.loadWords();
this._logger.log('Words cache reloaded');
}
private async checkUserAuth(ctx: BotContext, next: () => Promise<void>) {
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,

View File

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

View File

@ -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,
]
}),
}),

View File

@ -0,0 +1,189 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class Init1771342888394 implements MigrationInterface {
name = 'Init1771342888394'
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"
`);
}
}

View File

@ -0,0 +1,94 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class SeedDefaultLocalizationData1771342888395 implements MigrationInterface {
name = 'SeedDefaultLocalizationData1771342888395';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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'
`);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

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

View File

@ -0,0 +1,7 @@
import { IsString, IsOptional } from 'class-validator';
export class CreateWordDto {
@IsString()
@IsOptional()
mark?: string;
}

View File

@ -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[];
}

View File

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

View File

@ -0,0 +1,10 @@
import { IsString, IsOptional } from 'class-validator';
export class UpdateWordTranslationDto {
@IsString()
lang_slug: string;
@IsString()
@IsOptional()
text?: string | null;
}

View File

@ -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[];
}

View File

@ -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[];
}

View File

@ -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[];
}

View File

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

View File

@ -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[];
}

View File

@ -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[];
}

View File

@ -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 {}

View File

@ -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 {}

View File

@ -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 {}

View File

@ -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<Lang>,
@InjectRepository(Word) private wordRepository: Repository<Word>,
@InjectRepository(WordTranslation) private wordTranslationRepository: Repository<WordTranslation>,
private transactionRunner: DbTransactionFactory,
@Inject(forwardRef(() => BotService))
private botService?: BotService,
) {}
async findAll(): Promise<Lang[]> {
return this.langRepository.find();
}
async findAllActive(): Promise<Lang[]> {
return this.langRepository.find({
where: { active: true },
select: ['id', 'slug', 'title', 'dir', 'dateformat'],
});
}
async findChunk(
{ limit, offset }: IPagination,
sorting?: ISorting,
filtering?: IFiltering,
): Promise<PaginatedResource<Lang>> {
const [items, totalCount] = await this.langRepository.findAndCount({
take: limit,
skip: offset,
order: sorting,
where: filtering,
});
return { totalCount, items };
}
async findOne(id: number): Promise<Lang> {
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<Lang> {
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<Lang> {
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<void> {
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<void> {
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<void> {
// Получаем все слова
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);
}
}
}

View File

@ -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<Wordbook>,
@InjectRepository(Word) private wordRepository: Repository<Word>,
@InjectRepository(WordTranslation) private wordTranslationRepository: Repository<WordTranslation>,
private transactionRunner: DbTransactionFactory,
@Inject(forwardRef(() => BotService))
private botService?: BotService,
) {}
async findChunk(
{ limit, offset }: IPagination,
sorting?: ISorting,
filtering?: IFiltering,
): Promise<PaginatedResource<Wordbook>> {
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<Wordbook> {
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<Wordbook> {
const transactionalRunner = await this.transactionRunner.createTransaction();
try {
await transactionalRunner.startTransaction();
const wordbookData: Partial<Wordbook> = {
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<Wordbook> {
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<string, string | null>();
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<void> {
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<void> {
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();
}
}
}

View File

@ -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<Wordbook>,
@InjectRepository(Lang) private langRepository: Repository<Lang>,
@InjectRepository(Word) private wordRepository: Repository<Word>,
@InjectRepository(WordTranslation) private wordTranslationRepository: Repository<WordTranslation>,
) {}
/**
* Получить все словари с переводами для бота
* Загружает только словари где load_to IN ('all', 'bot')
* Загружает только активные языки
*/
async findAll(): Promise<IWords> {
// Загружаем активные языки
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;
}
}

View File

@ -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:

View File

@ -45,6 +45,7 @@ services:
environment:
NODE_ENV: production
PORT: ${FRONT_PORT}
VITE_API_URL: ${API_URL}
restart: always
depends_on:
- back

View File

@ -1 +1,5 @@
export * from './admin';
export * from './lang';
export * from './wordbook';
export * from './word';
export * from './word-translation';

View File

@ -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<typeof Lang>;
export type LangDto = z.infer<typeof LangDto>;

View File

@ -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<typeof WordTranslation>;

View File

@ -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<typeof Word>;
export type WordDto = z.infer<typeof WordDto>;

View File

@ -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<typeof Wordbook>;
export type WordbookDto = z.infer<typeof WordbookDto>;

View File

@ -120,7 +120,8 @@ function createUrl(
});
}
return `${url}?${query.toString()}`;
const queryString = query.toString();
return queryString ? `${url}?${queryString}` : url;
}
type QueryKey = [string] | [string, Record<string, string | number | undefined>];
@ -261,7 +262,6 @@ export function createPostMutationHook<
}: CreatePostMutationHookArgs<BodySchema, ResponseSchema>) {
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<BodySchema, ResponseSchema>) {
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' } }

View File

@ -1 +1,3 @@
export * from "./admin-form";
export * from './admin-form';
export * from './lang-form';
export * from './wordbook-form';

View File

@ -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<Props> = ({ 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<LangDto>({
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<LangDto> = 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 (
<FormProvider {...methods}>
<LoadingOverlay visible={isLoading} />
<Box component="form" onSubmit={handleSubmit(onSubmit)} pos="relative">
<Box>
<Stack pb={rem(30)}>
<Controller
name="slug"
render={({ field }) => {
return (
<Skeleton visible={isLoading}>
<TextInput
label="Slug"
placeholder="en"
error={errors.slug?.message}
{...field}
value={field?.value || ''}
/>
</Skeleton>
);
}}
/>
<Controller
name="title"
render={({ field }) => {
return (
<Skeleton visible={isLoading}>
<TextInput
label="Title"
placeholder="English"
error={errors.title?.message}
{...field}
value={field?.value || ''}
/>
</Skeleton>
);
}}
/>
<Controller
name="dir"
render={({ field }) => {
return (
<Skeleton visible={isLoading}>
<Select
label="Text Direction"
placeholder="Select direction"
error={errors.dir?.message}
data={[
{ value: 'ltr', label: 'Left to Right (LTR)' },
{ value: 'rtl', label: 'Right to Left (RTL)' },
]}
{...field}
value={field?.value || 'ltr'}
/>
</Skeleton>
);
}}
/>
<Controller
name="dateformat"
render={({ field }) => {
return (
<Skeleton visible={isLoading}>
<Select
label="Date Format"
placeholder="Select format"
error={errors.dateformat?.message}
data={[
{ value: DateFormat.En, label: 'American (MM/DD/YYYY)' },
{ value: DateFormat.Ru, label: 'European (DD.MM.YYYY)' },
]}
{...field}
value={field?.value || DateFormat.En}
/>
</Skeleton>
);
}}
/>
<Controller
name="active"
render={({ field }) => {
return (
<Skeleton visible={isLoading}>
<Select
label="Status"
placeholder="Status"
error={errors.active?.message}
data={[
{ value: 'true', label: 'Active' },
{ value: 'false', label: 'Inactive' },
]}
{...field}
value={watch('active') ? 'true' : 'false'}
onChange={(value) => setValue('active', value === 'true')}
/>
</Skeleton>
);
}}
/>
</Stack>
<Button loading={isCreateLoading || isUpdateLoading} w="100%" type="submit">
Save
</Button>
</Box>
</Box>
</FormProvider>
);
};

View File

@ -0,0 +1,149 @@
import { Controller, FormProvider, SubmitHandler, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { FC, useEffect, useMemo } from 'react';
import {
Box,
Button,
LoadingOverlay,
Select,
Skeleton,
Stack,
TextInput,
rem,
Text,
} from '@mantine/core';
import { useCreateWordbook, useOneWordbook, useUpdateWordbook } from '@/hooks';
import { LoadTo, WordbookDto } from '@/api/entities';
import { closeModal } from '@mantine/modals';
import { paths } from '@/routes';
interface Props {
modalId: string;
wordbookId: number | undefined;
}
export const WordbookForm: FC<Props> = ({ modalId, wordbookId }) => {
const { mutate: update, isPending: isUpdateLoading } = useUpdateWordbook();
const { mutate: create, isPending: isCreateLoading } = useCreateWordbook();
const { data: wordbook, isLoading } = useOneWordbook({ route: { id: wordbookId } });
const defaultValues = useMemo(() => {
if (wordbook?.data) {
const wb = wordbook.data;
return {
name: wb.name || '',
load_to: wb.load_to || LoadTo.All,
defended: wb.defended || false,
};
}
return {
name: '',
load_to: LoadTo.All,
defended: false,
};
}, [wordbook]);
const methods = useForm<WordbookDto>({
resolver: zodResolver(WordbookDto),
mode: 'onChange',
defaultValues,
});
const {
handleSubmit,
formState: { errors },
} = methods;
useEffect(() => {
if (wordbook?.data) {
methods.reset(defaultValues);
}
}, [wordbook, defaultValues]);
const onSubmit: SubmitHandler<WordbookDto> = async (values: any) => {
const data = {
variables: {
name: values.name,
load_to: values.load_to,
id: wordbookId,
},
route: { id: wordbookId },
};
if (wordbookId) {
update(data, {
onSuccess: () => {
closeModal(modalId);
},
});
} else {
create(data, {
onSuccess: (response: any) => {
closeModal(modalId);
// После создания переходим на страницу редактирования для добавления слов
if (response?.data?.id) {
window.location.href = paths.dashboard.localization.wordbooks.edit.replace(':id', String(response.data.id));
}
},
});
}
};
return (
<FormProvider {...methods}>
<LoadingOverlay visible={isLoading} />
<Box component="form" onSubmit={handleSubmit(onSubmit)} pos="relative">
<Box>
<Stack pb={rem(30)}>
<Controller
name="name"
render={({ field }) => {
return (
<Skeleton visible={isLoading}>
<TextInput
label="Name"
placeholder="common"
error={errors.name?.message}
{...field}
value={field?.value || ''}
/>
</Skeleton>
);
}}
/>
<Controller
name="load_to"
render={({ field }) => {
return (
<Skeleton visible={isLoading}>
<Select
label="Load To"
placeholder="Select target"
error={errors.load_to?.message}
data={[
{ value: LoadTo.All, label: 'All' },
{ value: LoadTo.Bot, label: 'Bot' },
]}
{...field}
value={field?.value || LoadTo.All}
/>
</Skeleton>
);
}}
/>
</Stack>
{!wordbookId && (
<Text size="sm" c="dimmed" mt="md">
After creating the wordbook, you will be redirected to the edit page where you can add words and translations.
</Text>
)}
<Button loading={isCreateLoading || isUpdateLoading} w="100%" type="submit" mt="md">
Save
</Button>
</Box>
</Box>
</FormProvider>
);
};

View File

@ -1 +1,3 @@
export * from "./admin-modal";
export * from './admin-modal';
export * from './lang-modal';
export * from './wordbook-modal';

View File

@ -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 <LangForm modalId={id} langId={innerProps.langId} />;
}

View File

@ -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 <WordbookForm modalId={id} wordbookId={innerProps.wordbookId} />;
}

View File

@ -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',
};

View File

@ -1,3 +1,5 @@
export * from './account';
export * from './auth';
export * from './admins';
export * from './langs';
export * from './wordbooks';

View File

@ -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<typeof Lang>({
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<typeof Lang>({
endpoint: `${BASE_ENDPOINT}/one/:id`,
responseSchema: Lang,
rQueryParams: { queryKey: [QUERY_KEY] },
});
export const useCreateLang = createPostMutationHook<typeof LangDto, typeof Lang>({
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<typeof LangDto, typeof Lang>({
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<typeof Lang>({
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<{ ids: number[] }>, z.ZodType<any>>({
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' });
},
},
});

View File

@ -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<typeof Wordbook>({
endpoint: `${BASE_ENDPOINT}/chunk`,
dataSchema: Wordbook,
rQueryParams: { queryKey: [QUERY_KEY] },
});
export const useOneWordbook = createGetQueryHook<typeof Wordbook>({
endpoint: `${BASE_ENDPOINT}/one/:id`,
responseSchema: Wordbook,
rQueryParams: { queryKey: [QUERY_KEY] },
});
export const useCreateWordbook = createPostMutationHook<typeof WordbookDto, typeof Wordbook>({
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<typeof WordbookDto, typeof Wordbook>({
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<typeof Wordbook>({
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<{ ids: number[] }>, z.ZodType<any>>({
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' });
},
},
});

View File

@ -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,
},
],
},
];

View File

@ -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 (
<Page title="Languages">
<PageHeader title="Languages list" breadcrumbs={breadcrumbs} />
<Grid>
<Grid.Col span={12}>
<LangsTable />
</Grid.Col>
</Grid>
</Page>
);
}

View File

@ -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<Lang, 'id' | 'slug' | 'title' | 'active' | 'created_at' | 'updated_at'>;
export function LangsTable() {
const { page, size, setSize, setPage } = usePagination();
const { filters, sort } = DataTable.useDataTable<SortableFields>({
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<Record<string, Partial<Lang>>>({});
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<Lang>) => {
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: <Text size="sm">{`Are you sure you want to delete language "${lang.title || lang.slug}"?`}</Text>,
labels: { confirm: 'Delete', cancel: 'Cancel' },
confirmProps: { color: 'red' },
onCancel: () => {},
onConfirm: () => deleteLang({ model: lang, route: { id: lang.id } }),
});
const columns: DataTableColumn<Lang>[] = 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 (
<TextInput
value={editingValue || ''}
onChange={(e) => {
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 (
<TextInput
value={editingValue || ''}
onChange={(e) => {
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) => (
<Checkbox
checked={lang.active}
onChange={(e) => handleActiveToggle(lang, e.currentTarget.checked)}
disabled={lang.defended}
/>
),
},
{
accessor: 'dir',
title: 'Direction',
render: (lang: Lang) => {
const editingValue = editingCells[lang.id]?.dir ?? lang.dir;
return (
<Select
value={editingValue || 'ltr'}
onChange={(value) => {
if (value) {
handleFieldChange(lang.id, 'dir', value);
debouncedUpdate(lang.id, { ...editingCells[lang.id], dir: value });
}
}}
data={[
{ value: 'ltr', label: 'LTR' },
{ value: 'rtl', label: 'RTL' },
]}
size="xs"
disabled={lang.defended}
styles={{ input: { border: 'none', padding: '4px 8px' } }}
/>
);
},
},
{
accessor: 'dateformat',
title: 'Date Format',
render: (lang: Lang) => {
const editingValue = editingCells[lang.id]?.dateformat ?? lang.dateformat;
return (
<Select
value={editingValue || DateFormat.En}
onChange={(value) => {
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) => (
<DataTable.Actions
onEdit={() => openModal(lang)}
onDelete={lang.defended ? undefined : () => openDeleteModal(lang)}
disabledEdit={lang.defended}
/>
),
},
],
[editingCells]
);
return (
<DataTable.Container>
<DataTable.Title
title="Languages"
description="Manage bot languages"
actions={
<AddButton variant="default" size="xs" onClick={() => openModal()}>
Create language
</AddButton>
}
/>
<DataTable.Filters filters={filters.filters} onClear={filters.clear} />
<DataTable.Content>
<DataTable.Table
minHeight={240}
noRecordsText={DataTable.noRecordsText('language')}
recordsPerPageLabel={DataTable.recordsPerPageLabel('languages')}
paginationText={DataTable.paginationText('languages')}
page={page}
records={langs?.data.items ?? []}
fetching={isLoading}
onPageChange={setPage}
recordsPerPage={size}
totalRecords={langs?.data.totalCount ?? 0}
onRecordsPerPageChange={setSize}
recordsPerPageOptions={[5, 15, 30]}
sortStatus={sort.status}
onSortStatusChange={sort.change}
columns={columns}
/>
</DataTable.Content>
</DataTable.Container>
);
}

View File

@ -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<string>('');
const [activeTab, setActiveTab] = useState<string>('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<string, string> = {};
(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<string, string> }>;
};
const methods = useForm<WordbookFormData>({
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<string, string> = {};
langs.forEach((lang: Lang) => {
if (lang.slug) {
translations[lang.slug] = '';
}
});
append({ mark: '', translations });
};
if (isLoading) {
return <div>Loading...</div>;
}
if (!wordbook) {
return <div>Wordbook not found</div>;
}
return (
<Page title={`Edit Wordbook - ${wordbook.name}`}>
<PageHeader
title={`Dictionaries - Edit`}
breadcrumbs={breadcrumbs}
>
<Group gap="xs">
<Button
variant="subtle"
leftSection={<IconArrowLeft size={16} />}
onClick={() => navigate(paths.dashboard.localization.wordbooks.list)}
>
Back
</Button>
<Button
variant="subtle"
color="red"
leftSection={<IconX size={16} />}
onClick={() => navigate(paths.dashboard.localization.wordbooks.list)}
>
Cancel
</Button>
<Button
leftSection={<IconDeviceFloppy size={16} />}
onClick={handleSubmit(onSubmit)}
loading={isUpdateLoading}
>
Save
</Button>
</Group>
</PageHeader>
<Grid>
<Grid.Col span={12}>
<Tabs value={activeTab} onChange={(value) => setActiveTab(value || 'words')}>
<Tabs.List>
<Tabs.Tab value="parameters">Parameters</Tabs.Tab>
<Tabs.Tab value="words">Words</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="parameters" pt="md">
<Stack gap="md">
<Controller
name="name"
control={control}
render={({ field }) => (
<TextInput label="Name" placeholder="common" {...field} value={field.value || ''} />
)}
/>
<Controller
name="load_to"
control={control}
render={({ field }) => (
<Select
label="Load To"
data={[
{ value: LoadTo.All, label: 'All' },
{ value: LoadTo.Bot, label: 'Bot' },
]}
{...field}
value={field.value || LoadTo.All}
/>
)}
/>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="words" pt="md">
<Stack gap="md">
<Group>
<Text fw={500}>Language:</Text>
<Radio.Group value={selectedLang} onChange={setSelectedLang}>
<Group>
{langs.map((lang: Lang) => (
<Radio key={lang.id} value={lang.slug || ''} label={lang.title || lang.slug} />
))}
</Group>
</Radio.Group>
</Group>
<Box>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Mark</Table.Th>
<Table.Th>Text ({selectedLang})</Table.Th>
<Table.Th style={{ width: '50px' }}></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{fields.map((field, index) => (
<Table.Tr key={field.id}>
<Table.Td>
<Controller
name={`words.${index}.mark`}
control={control}
render={({ field }) => (
<TextInput
{...field}
value={field.value || ''}
placeholder="word mark"
size="xs"
/>
)}
/>
</Table.Td>
<Table.Td>
{selectedLang && (
<Controller
name={`words.${index}.translations.${selectedLang}`}
control={control}
render={({ field }) => (
<TextInput
{...field}
value={field.value || ''}
placeholder={`Translation for ${selectedLang}`}
size="xs"
onChange={(e) => {
field.onChange(e);
debouncedUpdateTranslation(index, selectedLang, e.target.value);
}}
/>
)}
/>
)}
</Table.Td>
<Table.Td>
<ActionIcon color="red" variant="light" onClick={() => remove(index)}>
<IconTrash size={16} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<Button
leftSection={<IconPlus size={16} />}
variant="light"
onClick={addWord}
mt="md"
>
Add Word
</Button>
</Box>
</Stack>
</Tabs.Panel>
</Tabs>
</Grid.Col>
</Grid>
</Page>
);
}

View File

@ -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 { WordbooksTable } from './wordbooks-table';
const breadcrumbs = [
{ label: 'Dashboard', href: paths.dashboard.root },
{ label: 'Localization', href: paths.dashboard.localization.root },
{ label: 'Wordbooks' },
];
export default function ListWordbooksPage() {
return (
<Page title="Wordbooks">
<PageHeader title="Wordbooks list" breadcrumbs={breadcrumbs} />
<Grid>
<Grid.Col span={12}>
<WordbooksTable />
</Grid.Col>
</Grid>
</Page>
);
}

View File

@ -0,0 +1,154 @@
import { useMemo } from 'react';
import { DataTableColumn } from 'mantine-datatable';
import { Text, Badge } from '@mantine/core';
import { useNavigate } from 'react-router-dom';
import { Wordbook } from '@/api/entities';
import { usePagination } from '@/api/helpers';
import { AddButton } from '@/components/add-button';
import { DataTable } from '@/components/data-table';
import { useDeleteWordbook, useGetWordbooks } from '@/hooks';
import { formatDate } from '@/utilities/date';
import { modals, openContextModal } from '@mantine/modals';
import { MODAL_NAMES } from '@/providers/modals-provider';
import { paths } from '@/routes';
type SortableFields = Pick<Wordbook, 'id' | 'name' | 'load_to' | 'created_at' | 'updated_at'>;
export function WordbooksTable() {
const navigate = useNavigate();
const { page, size, setSize, setPage } = usePagination();
const { filters, sort } = DataTable.useDataTable<SortableFields>({
sortConfig: {
direction: 'desc',
column: 'id',
},
});
const { data: wordbooks, isLoading } = useGetWordbooks({
query: {
page,
size,
sort: sort.query,
},
});
const { mutate: deleteWordbook } = useDeleteWordbook();
function openModal(): void;
function openModal(wordbook: Wordbook): void;
function openModal(wordbook?: Wordbook): void {
if (wordbook?.id) {
navigate(paths.dashboard.localization.wordbooks.edit.replace(':id', String(wordbook.id)));
} else {
openContextModal({
modal: MODAL_NAMES.WORDBOOK,
title: 'Create wordbook',
innerProps: { wordbookId: undefined },
});
}
}
const openDeleteModal = (wordbook: Wordbook) =>
modals.openConfirmModal({
title: 'Delete wordbook',
children: <Text size="sm">{`Are you sure you want to delete wordbook "${wordbook.name}"?`}</Text>,
labels: { confirm: 'Delete', cancel: 'Cancel' },
confirmProps: { color: 'red' },
onCancel: () => {},
onConfirm: () => deleteWordbook({ model: wordbook, route: { id: wordbook.id } }),
});
const columns: DataTableColumn<Wordbook>[] = useMemo(
() => [
{
accessor: 'id',
title: 'ID',
width: '0%',
sortable: true,
},
{
accessor: 'name',
title: 'Name',
sortable: true,
},
{
accessor: 'load_to',
title: 'Load To',
render: (wordbook: Wordbook) => (
<Badge color={wordbook.load_to === 'all' ? 'blue' : 'green'} variant="light">
{wordbook.load_to.toUpperCase()}
</Badge>
),
},
{
accessor: 'words',
title: 'Words Count',
render: (wordbook: Wordbook) => wordbook.words?.length || 0,
},
{
accessor: 'created_at',
title: 'Created at',
noWrap: true,
width: '0%',
sortable: true,
render: (wordbook) => formatDate(wordbook.created_at),
},
{
accessor: 'updated_at',
title: 'Updated at',
noWrap: true,
width: '0%',
sortable: true,
render: (wordbook) => formatDate(wordbook.updated_at),
},
{
accessor: 'actions',
title: 'Actions',
textAlign: 'right',
width: '0%',
render: (wordbook: Wordbook) => (
<DataTable.Actions
onEdit={() => openModal(wordbook)}
onDelete={wordbook.defended ? undefined : () => openDeleteModal(wordbook)}
disabledEdit={wordbook.defended}
/>
),
},
],
[]
);
return (
<DataTable.Container>
<DataTable.Title
title="Wordbooks"
description="Manage translation dictionaries"
actions={
<AddButton variant="default" size="xs" onClick={() => openModal()}>
Create wordbook
</AddButton>
}
/>
<DataTable.Filters filters={filters.filters} onClear={filters.clear} />
<DataTable.Content>
<DataTable.Table
minHeight={240}
noRecordsText={DataTable.noRecordsText('wordbook')}
recordsPerPageLabel={DataTable.recordsPerPageLabel('wordbooks')}
paginationText={DataTable.paginationText('wordbooks')}
page={page}
records={wordbooks?.data.items ?? []}
fetching={isLoading}
onPageChange={setPage}
recordsPerPage={size}
totalRecords={wordbooks?.data.totalCount ?? 0}
onRecordsPerPageChange={setSize}
recordsPerPageOptions={[5, 15, 30]}
sortStatus={sort.status}
onSortStatusChange={sort.change}
columns={columns}
/>
</DataTable.Content>
</DataTable.Container>
);
}

View File

@ -1,6 +1,6 @@
import { FC, ReactNode } from "react";
import { AdminModal, LangModal, WordbookModal } from "@/components/widgets/modals";
import { ModalsProvider } from '@mantine/modals';
import { AdminModal } from "@/components/widgets/modals";
import { FC, ReactNode } from "react";
interface IProps {
children: ReactNode;
@ -8,6 +8,8 @@ interface IProps {
const modals = {
ADMIN: AdminModal,
LANG: LangModal,
WORDBOOK: WordbookModal,
};
export const MODAL_NAMES = Object.keys(modals).reduce((acc, key) => {

View File

@ -14,5 +14,17 @@ export const paths = {
list: '/dashboard/management/admins/list',
},
},
localization: {
root: '/dashboard/localization',
langs: {
root: '/dashboard/localization/langs',
list: '/dashboard/localization/langs/list',
},
wordbooks: {
root: '/dashboard/localization/wordbooks',
list: '/dashboard/localization/wordbooks/list',
edit: '/dashboard/localization/wordbooks/edit/:id',
},
},
},
};

View File

@ -72,6 +72,49 @@ const router = createBrowserRouter([
},
],
},
/* ------------------------------- LOCALIZATION ------------------------------- */
{
path: paths.dashboard.localization.root,
children: [
{
index: true,
path: paths.dashboard.localization.root,
element: <Navigate to={paths.dashboard.localization.langs.root} replace />,
},
{
path: paths.dashboard.localization.langs.root,
children: [
{
index: true,
path: paths.dashboard.localization.langs.root,
element: <Navigate to={paths.dashboard.localization.langs.list} replace />,
},
{
path: paths.dashboard.localization.langs.list,
element: LazyPage(() => import('@/pages/dashboard/localization/langs/list')),
},
],
},
{
path: paths.dashboard.localization.wordbooks.root,
children: [
{
index: true,
path: paths.dashboard.localization.wordbooks.root,
element: <Navigate to={paths.dashboard.localization.wordbooks.list} replace />,
},
{
path: paths.dashboard.localization.wordbooks.list,
element: LazyPage(() => import('@/pages/dashboard/localization/wordbooks/list')),
},
{
path: paths.dashboard.localization.wordbooks.edit,
element: LazyPage(() => import('@/pages/dashboard/localization/wordbooks/edit')),
},
],
},
],
},
],
},
]);