Implement basic localisations with admin settings
This commit is contained in:
parent
307d7a9104
commit
49be6bb588
79
README.md
79
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
|
||||
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
]
|
||||
}),
|
||||
}),
|
||||
|
||||
189
back/src/database/migrations/1771342888394-init.ts
Normal file
189
back/src/database/migrations/1771342888394-init.ts
Normal 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"
|
||||
`);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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'
|
||||
`);
|
||||
}
|
||||
}
|
||||
62
back/src/localization/controllers/langs.controller.ts
Normal file
62
back/src/localization/controllers/langs.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
57
back/src/localization/controllers/wordbooks.controller.ts
Normal file
57
back/src/localization/controllers/wordbooks.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
28
back/src/localization/dto/create-lang.dto.ts
Normal file
28
back/src/localization/dto/create-lang.dto.ts
Normal 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;
|
||||
}
|
||||
7
back/src/localization/dto/create-word.dto.ts
Normal file
7
back/src/localization/dto/create-word.dto.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { IsString, IsOptional } from 'class-validator';
|
||||
|
||||
export class CreateWordDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
mark?: string;
|
||||
}
|
||||
20
back/src/localization/dto/create-wordbook.dto.ts
Normal file
20
back/src/localization/dto/create-wordbook.dto.ts
Normal 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[];
|
||||
}
|
||||
31
back/src/localization/dto/update-lang.dto.ts
Normal file
31
back/src/localization/dto/update-lang.dto.ts
Normal 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;
|
||||
}
|
||||
10
back/src/localization/dto/update-word-translation.dto.ts
Normal file
10
back/src/localization/dto/update-word-translation.dto.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { IsString, IsOptional } from 'class-validator';
|
||||
|
||||
export class UpdateWordTranslationDto {
|
||||
@IsString()
|
||||
lang_slug: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
text?: string | null;
|
||||
}
|
||||
@ -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[];
|
||||
}
|
||||
27
back/src/localization/dto/update-wordbook.dto.ts
Normal file
27
back/src/localization/dto/update-wordbook.dto.ts
Normal 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[];
|
||||
}
|
||||
37
back/src/localization/entities/lang.entity.ts
Normal file
37
back/src/localization/entities/lang.entity.ts
Normal 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[];
|
||||
}
|
||||
30
back/src/localization/entities/word-translation.entity.ts
Normal file
30
back/src/localization/entities/word-translation.entity.ts
Normal 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;
|
||||
}
|
||||
24
back/src/localization/entities/word.entity.ts
Normal file
24
back/src/localization/entities/word.entity.ts
Normal 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[];
|
||||
}
|
||||
28
back/src/localization/entities/wordbook.entity.ts
Normal file
28
back/src/localization/entities/wordbook.entity.ts
Normal 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[];
|
||||
}
|
||||
27
back/src/localization/modules/langs.module.ts
Normal file
27
back/src/localization/modules/langs.module.ts
Normal 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 {}
|
||||
28
back/src/localization/modules/wordbooks.module.ts
Normal file
28
back/src/localization/modules/wordbooks.module.ts
Normal 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 {}
|
||||
16
back/src/localization/modules/words.module.ts
Normal file
16
back/src/localization/modules/words.module.ts
Normal 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 {}
|
||||
222
back/src/localization/services/langs.service.ts
Normal file
222
back/src/localization/services/langs.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
294
back/src/localization/services/wordbooks.service.ts
Normal file
294
back/src/localization/services/wordbooks.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
68
back/src/localization/services/words.service.ts
Normal file
68
back/src/localization/services/words.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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:
|
||||
|
||||
@ -45,6 +45,7 @@ services:
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: ${FRONT_PORT}
|
||||
VITE_API_URL: ${API_URL}
|
||||
restart: always
|
||||
depends_on:
|
||||
- back
|
||||
|
||||
@ -1 +1,5 @@
|
||||
export * from './admin';
|
||||
export * from './lang';
|
||||
export * from './wordbook';
|
||||
export * from './word';
|
||||
export * from './word-translation';
|
||||
|
||||
27
front/src/api/entities/lang.ts
Normal file
27
front/src/api/entities/lang.ts
Normal 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>;
|
||||
12
front/src/api/entities/word-translation.ts
Normal file
12
front/src/api/entities/word-translation.ts
Normal 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>;
|
||||
18
front/src/api/entities/word.ts
Normal file
18
front/src/api/entities/word.ts
Normal 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>;
|
||||
35
front/src/api/entities/wordbook.ts
Normal file
35
front/src/api/entities/wordbook.ts
Normal 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>;
|
||||
@ -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' } }
|
||||
|
||||
@ -1 +1,3 @@
|
||||
export * from "./admin-form";
|
||||
export * from './admin-form';
|
||||
export * from './lang-form';
|
||||
export * from './wordbook-form';
|
||||
|
||||
184
front/src/components/widgets/forms/lang-form/index.tsx
Normal file
184
front/src/components/widgets/forms/lang-form/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
149
front/src/components/widgets/forms/wordbook-form/index.tsx
Normal file
149
front/src/components/widgets/forms/wordbook-form/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -1 +1,3 @@
|
||||
export * from "./admin-modal";
|
||||
export * from './admin-modal';
|
||||
export * from './lang-modal';
|
||||
export * from './wordbook-modal';
|
||||
|
||||
6
front/src/components/widgets/modals/lang-modal/index.tsx
Normal file
6
front/src/components/widgets/modals/lang-modal/index.tsx
Normal 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} />;
|
||||
}
|
||||
@ -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} />;
|
||||
}
|
||||
@ -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',
|
||||
};
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
export * from './account';
|
||||
export * from './auth';
|
||||
export * from './admins';
|
||||
export * from './langs';
|
||||
export * from './wordbooks';
|
||||
|
||||
89
front/src/hooks/api/langs.ts
Normal file
89
front/src/hooks/api/langs.ts
Normal 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' });
|
||||
},
|
||||
},
|
||||
});
|
||||
83
front/src/hooks/api/wordbooks.ts
Normal file
83
front/src/hooks/api/wordbooks.ts
Normal 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' });
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
25
front/src/pages/dashboard/localization/langs/list/index.tsx
Normal file
25
front/src/pages/dashboard/localization/langs/list/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
314
front/src/pages/dashboard/localization/wordbooks/edit/index.tsx
Normal file
314
front/src/pages/dashboard/localization/wordbooks/edit/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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) => {
|
||||
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@ -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')),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user