From 2a0c89be2ad74c58254dbb40f01a587bd4e6eff1 Mon Sep 17 00:00:00 2001 From: zamelane <zamelane@vk.com> Date: Wed, 4 Dec 2024 01:38:43 +0700 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D0=B3=D0=BE=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BC=D0=B0=D0=B3=D0=B0=D0=B7=D0=B8=D0=BD=D0=B0?= =?UTF-8?q?=20=D0=B8=20=D1=80=D0=B0=D0=B1=D0=BE=D1=87=D0=B8=D0=B5=20=D1=84?= =?UTF-8?q?=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/.gitignore | 2 + apps/api/package.json | 3 + .../src/helpers/syncDatabaseForDecorations.ts | 108 ++++++++++ apps/api/src/main.ts | 8 + apps/api/src/models/Avatar/index.ts | 1 + apps/api/src/models/Avatar/model.ts | 42 ++++ apps/api/src/models/AvatarTag/index.ts | 1 + apps/api/src/models/AvatarTag/model.ts | 40 ++++ apps/api/src/models/DiaryUser/model.ts | 9 + apps/api/src/models/Tag/index.ts | 1 + apps/api/src/models/Tag/model.ts | 33 +++ apps/api/src/models/UserAvatar/index.ts | 1 + apps/api/src/models/UserAvatar/model.ts | 38 ++++ apps/api/src/models/relations.ts | 23 ++- apps/api/src/routes/index.ts | 2 + apps/api/src/routes/marketAvatars/handler.ts | 45 +++++ apps/api/src/routes/marketAvatars/index.ts | 17 ++ apps/api/src/uploads/decorations.json | 16 ++ apps/shared/src/api/self/index.ts | 7 + apps/web/.env.development | 3 +- .../ModalRoot/modals/UserEditModal/index.tsx | 18 +- .../pages/Market/components/avatarsPanel.tsx | 149 ++++++++------ .../pages/Market/components/filtersPanel.tsx | 190 +++++++++--------- .../pages/Market/components/marketHeader.tsx | 7 +- .../web/src/pages/Market/components/types.tsx | 6 - apps/web/src/pages/Market/index.tsx | 138 ++++++------- apps/web/src/pages/Settings/Actions/index.tsx | 24 ++- apps/web/src/shared/config/constants.ts | 2 +- .../recent-marks/ui/LessonGrades/index.tsx | 1 + bun.lockb | Bin 158072 -> 159968 bytes 30 files changed, 675 insertions(+), 260 deletions(-) create mode 100644 apps/api/src/helpers/syncDatabaseForDecorations.ts create mode 100644 apps/api/src/models/Avatar/index.ts create mode 100644 apps/api/src/models/Avatar/model.ts create mode 100644 apps/api/src/models/AvatarTag/index.ts create mode 100644 apps/api/src/models/AvatarTag/model.ts create mode 100644 apps/api/src/models/Tag/index.ts create mode 100644 apps/api/src/models/Tag/model.ts create mode 100644 apps/api/src/models/UserAvatar/index.ts create mode 100644 apps/api/src/models/UserAvatar/model.ts create mode 100644 apps/api/src/routes/marketAvatars/handler.ts create mode 100644 apps/api/src/routes/marketAvatars/index.ts create mode 100644 apps/api/src/uploads/decorations.json delete mode 100644 apps/web/src/pages/Market/components/types.tsx diff --git a/apps/api/.gitignore b/apps/api/.gitignore index a983ad35..1ba22ba8 100755 --- a/apps/api/.gitignore +++ b/apps/api/.gitignore @@ -46,3 +46,5 @@ package-lock.json # IDE files /.idea +#uploads +src/uploads/avatars \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index 1a9f00d1..924b52c0 100755 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -17,8 +17,11 @@ "dependencies": { "@diary-spo/crypto": "workspace:*", "@elysiajs/cors": "^1.1.1", + "@elysiajs/static": "^1.1.1", + "@types/cli-progress": "^3.11.6", "@types/node-telegram-bot-api": "^0.64.7", "cache-manager": "^5.7.6", + "cli-progress": "^3.12.0", "elysia": "1.1.21", "elysia-compression": "^0.0.7", "elysia-helmet": "^2.0.0", diff --git a/apps/api/src/helpers/syncDatabaseForDecorations.ts b/apps/api/src/helpers/syncDatabaseForDecorations.ts new file mode 100644 index 00000000..a41ab7ae --- /dev/null +++ b/apps/api/src/helpers/syncDatabaseForDecorations.ts @@ -0,0 +1,108 @@ +import {AvatarModel} from "../models/Avatar"; +import {readdir} from "node:fs/promises"; +import cliProgress from "cli-progress" +import {AvatarTagModel} from "../models/AvatarTag"; +import {TagModel} from "../models/Tag"; + +const pathToDecorationsFile = 'src/uploads/decorations.json' +const pathToAvatars = 'src/uploads/avatars' + +interface Content { + avatars: AvatarContent[] +} + +interface AvatarContent { + isAnimated: boolean + filename: string + tags: string[] + price: number +} + +export const syncDatabaseForDecorations = async () => { + console.log('Обновляю декорации в базе данных...') + + let progress = 0 + const progressBar = new cliProgress.SingleBar({ + format: ' {bar} | {filename} | {value}/{total}' + }, cliProgress.Presets.shades_classic) + + const file = Bun.file(pathToDecorationsFile) + + if (!await file.exists()) { + console.log('Файл декораций не найден. Пропускаю ...') + return + } + + const contents: Content = await file.json() + const avatarFiles = await readdir(pathToAvatars) + + progressBar.start(avatarFiles.length, progress) + + for (const avatarFile of avatarFiles) { + progressBar.increment(1, {filename: avatarFile}) + const avatarToSave = { + filename: avatarFile, + isAnimated: avatarFile.endsWith('.gif'), + price: 1000 + } + + let tags: string[] = [] + + // Пробуем найти, переопределена ли цена и статус анимации + for (const avatarInfo of contents.avatars) { + if (avatarInfo.filename !== avatarFile) + continue + avatarToSave.isAnimated = avatarInfo.isAnimated + avatarToSave.price = avatarInfo.price + tags = avatarInfo.tags + break + } + + // Находим или создаём модель + const avatarModel = await AvatarModel.findOrCreate({ + where: { + filename: avatarFile, + }, + defaults: avatarToSave + }) + + // Проверяем наличие тегов + if (!tags) + await AvatarTagModel.destroy({ + where: { + avatarId: avatarModel[0].id + } + }) + else + for (const tag of tags) { + const tagModel = await TagModel.findOrCreate({ + where: { + value: tag + }, + defaults: { + value: tag + } + }) + await AvatarTagModel.findOrCreate({ + where: { + avatarId: avatarModel[0].id, + tagId: tagModel[0].id + }, + defaults: { + avatarId: avatarModel[0].id, + tagId: tagModel[0].id + } + }) + } + + // Если создана - то завершаем цикл + if (avatarModel[1]) + continue + + await avatarModel[0].update(avatarToSave) + } + + progressBar.stop() + + console.log('Обновил все декорации') +} \ No newline at end of file diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index f5dabae0..f8b40751 100755 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -11,6 +11,8 @@ import { getTimezone } from './config/getTimeZone' import { routes } from './routes' import './models/relations' +import staticPlugin from "@elysiajs/static"; +import {syncDatabaseForDecorations} from "./helpers/syncDatabaseForDecorations"; // настраиваем сервер... const port = Bun.env.PORT ?? 3003 @@ -53,6 +55,10 @@ const app = new Elysia() contentSecurityPolicy: false }) ) + .use(staticPlugin({ + assets: 'src/uploads', + prefix: 'uploads' + })) .use(routes) .listen(port) @@ -69,6 +75,8 @@ console.log( }.` ) +await syncDatabaseForDecorations() + export type App = typeof app const workerURL = new URL('worker', import.meta.url).href new Worker(workerURL) diff --git a/apps/api/src/models/Avatar/index.ts b/apps/api/src/models/Avatar/index.ts new file mode 100644 index 00000000..116e6686 --- /dev/null +++ b/apps/api/src/models/Avatar/index.ts @@ -0,0 +1 @@ +export * from './model' diff --git a/apps/api/src/models/Avatar/model.ts b/apps/api/src/models/Avatar/model.ts new file mode 100644 index 00000000..44e4db23 --- /dev/null +++ b/apps/api/src/models/Avatar/model.ts @@ -0,0 +1,42 @@ +import { DataTypes } from 'sequelize' + +import { cache, enableCache, sequelize } from '@db' + +import { SPOModel } from '../SPO' +import type { IModelPrototype } from '../types' + +// REMOVE IT +// ? +export type AvatarModelType = { + id: bigint + filename: string + price: number + isAnimated: boolean +} + +export type IAvatarModelType = IModelPrototype<AvatarModelType, 'id'> + +const avatarModel = sequelize.define<IAvatarModelType>('avatar', { + id: { + type: DataTypes.BIGINT, + primaryKey: true, + autoIncrement: true + }, + filename: { + type: DataTypes.STRING(35), + allowNull: false + }, + price: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 1000 + }, + isAnimated: { + type: DataTypes.BOOLEAN, + allowNull: false + } +}) + +export const AvatarModel = enableCache + ? cache.init<IAvatarModelType>(avatarModel) + : avatarModel diff --git a/apps/api/src/models/AvatarTag/index.ts b/apps/api/src/models/AvatarTag/index.ts new file mode 100644 index 00000000..116e6686 --- /dev/null +++ b/apps/api/src/models/AvatarTag/index.ts @@ -0,0 +1 @@ +export * from './model' diff --git a/apps/api/src/models/AvatarTag/model.ts b/apps/api/src/models/AvatarTag/model.ts new file mode 100644 index 00000000..d1047b7f --- /dev/null +++ b/apps/api/src/models/AvatarTag/model.ts @@ -0,0 +1,40 @@ +import { DataTypes } from 'sequelize' + +import { cache, enableCache, sequelize } from '@db' + +import { SPOModel } from '../SPO' +import type {IModelPrototype, IModelPrototypeNoId} from '../types' +import {AvatarModel} from "../Avatar"; +import {TagModel} from "../Tag"; + +// REMOVE IT +// ? +export type AvatarTagModelType = { + avatarId: bigint + tagId: bigint +} + +export type IAvatarTagModelType = IModelPrototypeNoId<AvatarTagModelType> + +const avatarTagModel = sequelize.define<IAvatarTagModelType>('avatarTag', { + avatarId: { + type: DataTypes.BIGINT, + primaryKey: true, + allowNull: false, + references: { + model: AvatarModel + } + }, + tagId: { + type: DataTypes.BIGINT, + primaryKey: true, + allowNull: false, + references: { + model: TagModel + } + } +}) + +export const AvatarTagModel = enableCache + ? cache.init<IAvatarTagModelType>(avatarTagModel) + : avatarTagModel diff --git a/apps/api/src/models/DiaryUser/model.ts b/apps/api/src/models/DiaryUser/model.ts index a00012a8..f0200c4b 100755 --- a/apps/api/src/models/DiaryUser/model.ts +++ b/apps/api/src/models/DiaryUser/model.ts @@ -7,6 +7,7 @@ import { formatDate } from '@utils' import { GroupModel } from '../Group' import type { IModelPrototype } from '../types' +import {AvatarModel} from "../Avatar"; // REMOVE IT // ? @@ -26,6 +27,7 @@ export type DiaryUserModelType = { termStartDate?: Nullable<string> isAdmin: boolean idFromDiary: number + avatarId: bigint } export type IDiaryUserModel = IModelPrototype<DiaryUserModelType, 'id'> @@ -125,6 +127,13 @@ export const DiaryUserModel = sequelize.define<IDiaryUserModel>('diaryUser', { defaultValue: false, comment: 'Признак администратора' }, + avatarId: { + type: DataTypes.BIGINT, + allowNull: true, + references: { + model: AvatarModel + } + }, idFromDiary: { type: DataTypes.INTEGER, allowNull: false, diff --git a/apps/api/src/models/Tag/index.ts b/apps/api/src/models/Tag/index.ts new file mode 100644 index 00000000..116e6686 --- /dev/null +++ b/apps/api/src/models/Tag/index.ts @@ -0,0 +1 @@ +export * from './model' diff --git a/apps/api/src/models/Tag/model.ts b/apps/api/src/models/Tag/model.ts new file mode 100644 index 00000000..80cecc0f --- /dev/null +++ b/apps/api/src/models/Tag/model.ts @@ -0,0 +1,33 @@ +import { DataTypes } from 'sequelize' + +import { cache, enableCache, sequelize } from '@db' + +import { SPOModel } from '../SPO' +import type { IModelPrototype } from '../types' +import {AvatarModel} from "../Avatar"; + +// REMOVE IT +// ? +export type TagModelType = { + id: bigint + value: string +} + +export type ITagModelType = IModelPrototype<TagModelType, 'id'> + +const tagModel = sequelize.define<ITagModelType>('tag', { + id: { + type: DataTypes.BIGINT, + primaryKey: true, + autoIncrement: true, + allowNull: false + }, + value: { + type: DataTypes.STRING, + allowNull: false + } +}) + +export const TagModel = enableCache + ? cache.init<ITagModelType>(tagModel) + : tagModel diff --git a/apps/api/src/models/UserAvatar/index.ts b/apps/api/src/models/UserAvatar/index.ts new file mode 100644 index 00000000..116e6686 --- /dev/null +++ b/apps/api/src/models/UserAvatar/index.ts @@ -0,0 +1 @@ +export * from './model' diff --git a/apps/api/src/models/UserAvatar/model.ts b/apps/api/src/models/UserAvatar/model.ts new file mode 100644 index 00000000..7db748b7 --- /dev/null +++ b/apps/api/src/models/UserAvatar/model.ts @@ -0,0 +1,38 @@ +import { DataTypes } from 'sequelize' + +import { cache, enableCache, sequelize } from '@db' + +import { SPOModel } from '../SPO' +import type {IModelPrototype, IModelPrototypeNoId} from '../types' +import {AvatarModel} from "../Avatar"; +import {DiaryUserModel} from "../DiaryUser"; + +// REMOVE IT +// ? +export type UserAvatarModelType = { + avatarId: bigint + diaryUserId: bigint +} + +export type IUserAvatarModelType = IModelPrototypeNoId<UserAvatarModelType> + +const userAvatarModel = sequelize.define<IUserAvatarModelType>('userAvatar', { + avatarId: { + type: DataTypes.BIGINT, + references: { + model: AvatarModel + }, + allowNull: false + }, + diaryUserId: { + type: DataTypes.BIGINT, + references: { + model: DiaryUserModel + }, + allowNull: false + } +}) + +export const UserAvatarModel = enableCache + ? cache.init<IUserAvatarModelType>(userAvatarModel) + : userAvatarModel diff --git a/apps/api/src/models/relations.ts b/apps/api/src/models/relations.ts index 1d2c3497..a4b5a82d 100755 --- a/apps/api/src/models/relations.ts +++ b/apps/api/src/models/relations.ts @@ -28,6 +28,10 @@ import { TermSubjectExaminationTypeModel } from './TermSubjectExaminationType' import { TermTypeModel } from './TermType' import { TermUserModel } from './TermUser' import { ThemeModel } from './Theme' +import {AvatarModel} from "./Avatar"; +import {UserAvatarModel} from "./UserAvatar"; +import {AvatarTagModel} from "./AvatarTag"; +import {TagModel} from "./Tag"; // SPO <--->> Group SPOModel.hasMany(GroupModel) @@ -193,10 +197,27 @@ TermSubjectModel.belongsTo(TeacherModel) DiaryUserModel.hasMany(TermSubjectModel) TermSubjectModel.belongsTo(DiaryUserModel) +// DiaryUser <-->> Subscribe DiaryUserModel.hasMany(SubscribeModel) SubscribeModel.belongsTo(DiaryUserModel) +// Avatar <-->> UserAvatar +AvatarModel.hasMany(UserAvatarModel) +UserAvatarModel.belongsTo(AvatarModel) + +// Avatar <-->> DiaryUser +AvatarModel.hasMany(DiaryUserModel) +DiaryUserModel.belongsTo(AvatarModel) + +// Avatar <-->> AvatarTag +AvatarModel.hasMany(AvatarTagModel) +AvatarTagModel.belongsTo(AvatarModel) + +// Tag <-->> AvatarTag +TagModel.hasMany(AvatarTagModel) +AvatarTagModel.belongsTo(TagModel) + if (forceSyncDatabase) { console.log('Syncing database...') - await sequelize.sync({ alter: true }) + await sequelize.sync() } diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index 4d730f8b..1b3f61b4 100755 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -10,6 +10,7 @@ import { HomeController } from './home' import { LessonsController } from './lessons' import { OrganizationController } from './organization' import { PerformanceCurrentController } from './performance.current' +import {MarketAvatars} from "./marketAvatars"; export const routes = new Elysia() .use(HomeController) @@ -20,6 +21,7 @@ export const routes = new Elysia() .use(AttestationController) .use(FinalMarksController) .use(PerformanceCurrentController) + .use(MarketAvatars) /** Обработка любых ошибок в кажом роуте **/ .onError(({ set, code, path, error }) => { if (Number(code)) { diff --git a/apps/api/src/routes/marketAvatars/handler.ts b/apps/api/src/routes/marketAvatars/handler.ts new file mode 100644 index 00000000..cb6812bb --- /dev/null +++ b/apps/api/src/routes/marketAvatars/handler.ts @@ -0,0 +1,45 @@ +import {AvatarModel, AvatarModelType, IAvatarModelType} from "../../models/Avatar"; +import {AvatarData} from "@diary-spo/shared"; +import {ITagModelType, TagModel, TagModelType} from "../../models/Tag"; +import {AvatarTagModel, AvatarTagModelType, IAvatarTagModelType} from "../../models/AvatarTag"; +import {sequelize} from "@db"; + +interface MarketAvatarsParams { + page: number +} + +type IAvatarsFromDB = IAvatarModelType & { + avatarTags: (IAvatarTagModelType & { + tag: ITagModelType + })[] +} + +const elementsForPage = 100 + +const getMarketAvatars = async ({page}: MarketAvatarsParams): Promise<AvatarData[]> => { + const avatars = await AvatarModel.findAll({ + limit: elementsForPage, + offset: elementsForPage * (page - 1), + include: [ + { + model: AvatarTagModel, + include: [TagModel] + } + ], + order: [[sequelize.literal('id'), 'ASC']] + }) as IAvatarsFromDB[] + + const formattedResult: AvatarData[] = [] + + for (const avatar of avatars) + formattedResult.push({ + filename: avatar.filename, + tags: avatar.avatarTags.map(avatarTag => avatarTag.tag.value), + isAnimated: avatar.isAnimated, + price: avatar.price + }) + + return formattedResult +} + +export default getMarketAvatars diff --git a/apps/api/src/routes/marketAvatars/index.ts b/apps/api/src/routes/marketAvatars/index.ts new file mode 100644 index 00000000..3776280a --- /dev/null +++ b/apps/api/src/routes/marketAvatars/index.ts @@ -0,0 +1,17 @@ +import { Elysia, t } from 'elysia' +import { AuthPlugin } from '../../services/AuthService' +import getLessons from './handler' +import getMarketAvatars from "./handler"; + +export const MarketAvatars = new Elysia() + .use(AuthPlugin) + .get( + '/marketAvatars/:page', + ({ params: { page }, Auth: { user } }) => + getMarketAvatars({page}), + { + params: t.Object({ + page: t.Numeric({minimum: 1, maximum: 999}) + }) + } + ) diff --git a/apps/api/src/uploads/decorations.json b/apps/api/src/uploads/decorations.json new file mode 100644 index 00000000..5f80e6ee --- /dev/null +++ b/apps/api/src/uploads/decorations.json @@ -0,0 +1,16 @@ +{ + "avatars": [ + { + "isAnimated": true, + "filename": "1.gif", + "tags": ["Девушка"], + "price": 2500 + }, + { + "isAnimated": false, + "filename": "2.jpg", + "tags": ["Парень", "Эпично"], + "price": 1500 + } + ] +} \ No newline at end of file diff --git a/apps/shared/src/api/self/index.ts b/apps/shared/src/api/self/index.ts index 8d0adb74..a86434c3 100755 --- a/apps/shared/src/api/self/index.ts +++ b/apps/shared/src/api/self/index.ts @@ -30,3 +30,10 @@ export type ResponseLogin = Person & { token: string tokenId: bigint } + +export interface AvatarData { + isAnimated: boolean + filename: string + tags: string[] + price: number +} \ No newline at end of file diff --git a/apps/web/.env.development b/apps/web/.env.development index 144984b3..6f1a9c3e 100755 --- a/apps/web/.env.development +++ b/apps/web/.env.development @@ -1,7 +1,6 @@ VITE_SERVER_URLS=http://localhost:3003,http://127.0.0.1:3003 VITE_SERVER_URL=http://localhost:3003 -# http://localhost:3003,http://127.0.0.1:3003, -VITE_TG_BOT_URL=https://t.me/diary_notifications_bot +VITE_TG_BOT_URL=IGNORE VITE_MODE=dev VITE_NODE_ENV=development NODE_ENV=development diff --git a/apps/web/src/app/AppWrapper/App/ModalRoot/modals/UserEditModal/index.tsx b/apps/web/src/app/AppWrapper/App/ModalRoot/modals/UserEditModal/index.tsx index b476d1bf..06894202 100644 --- a/apps/web/src/app/AppWrapper/App/ModalRoot/modals/UserEditModal/index.tsx +++ b/apps/web/src/app/AppWrapper/App/ModalRoot/modals/UserEditModal/index.tsx @@ -1,16 +1,22 @@ import { - Avatar, Div, + Avatar, + Div, Flex, Group, Header, ModalPage, - ModalPageHeader, Text + ModalPageHeader, + Text } from '@vkontakte/vkui' import './index.css' -import {Icon16DoneCircle, Icon28ShoppingCartOutline, Icon56MarketOutline} from '@vkontakte/icons' +import { + Icon16DoneCircle, + Icon28ShoppingCartOutline, + Icon56MarketOutline +} from '@vkontakte/icons' +import { useRouteNavigator } from '@vkontakte/vk-mini-apps-router' import { useState } from 'react' -import {useRouteNavigator} from "@vkontakte/vk-mini-apps-router"; -import {PAGE_MARKET} from "../../../../../routes"; +import { PAGE_MARKET } from '../../../../../routes' const urls = [ 'https://mangabuff.ru/img/avatars/x150/806.gif', @@ -106,7 +112,7 @@ const UserEditModal = ({ id }: { id: string }) => { ))} <Avatar size={110} onClick={openMarket}> <Flex direction='column' align='center'> - <Icon28ShoppingCartOutline height={50} width={50}/> + <Icon28ShoppingCartOutline height={50} width={50} /> <Text>Купить ещё</Text> </Flex> </Avatar> diff --git a/apps/web/src/pages/Market/components/avatarsPanel.tsx b/apps/web/src/pages/Market/components/avatarsPanel.tsx index 1beca268..1778fff0 100644 --- a/apps/web/src/pages/Market/components/avatarsPanel.tsx +++ b/apps/web/src/pages/Market/components/avatarsPanel.tsx @@ -1,74 +1,99 @@ -import {Avatar, Flex, Group, Header, Placeholder, ToolButton, Tooltip} from "@vkontakte/vkui"; -import {Icon16DiamondOutline, Icon16Gift, Icon56TagOutline} from "@vkontakte/icons"; -import {FC, useState} from "react"; -import {type AvatarData} from './types.tsx' +import type { AvatarData } from '@diary-spo/shared' +import { + Icon16DiamondOutline, + Icon16Gift, + Icon56TagOutline +} from '@vkontakte/icons' +import { + Avatar, + Flex, + Group, + Header, + Placeholder, + ToolButton, + Tooltip +} from '@vkontakte/vkui' +import { type FC, useState } from 'react' +import { API_URL } from '../../../shared/config' interface Props { - avatars: AvatarData[], - isStatic: boolean, - isAnimated: boolean, - selectedTags: string[] + avatars: AvatarData[] + isStatic: boolean + isAnimated: boolean + selectedTags: string[] } -const url = 'https://mangabuff.ru/img/avatars/x150' +const url = `${API_URL}/uploads/avatars/` -const avatarsPanel: FC<Props> = ({avatars, isStatic, isAnimated, selectedTags}) => { - const [isNotZeroElements, setIsNotZeroElements] = useState(true) +const avatarsPanel: FC<Props> = ({ + avatars, + isStatic, + isAnimated, + selectedTags +}) => { + const [isNotZeroElements, setIsNotZeroElements] = useState(true) - const getUrlPath = (avatarData: AvatarData) => { - return `${url}/${avatarData.filename}` - } + const getUrlPath = (avatarData: AvatarData) => { + return `${url}/${avatarData.filename}` + } - const filteredAvatars = () => { - const filtered = avatars.filter(avatar => { - const isType = (!isAnimated && !isStatic) || (avatar.isAnimated && isAnimated || !avatar.isAnimated && isStatic) - const isTags = !selectedTags.length || selectedTags.filter(tag => avatar.tags.includes(tag)).length == avatar.tags.length - return isType && isTags - }) + const filteredAvatars = () => { + const filtered = avatars.filter((avatar) => { + const isType = + (!isAnimated && !isStatic) || + (avatar.isAnimated && isAnimated) || + (!avatar.isAnimated && isStatic) + const isTags = + !selectedTags.length || + selectedTags.filter((tag) => avatar.tags.includes(tag)).length === + selectedTags.length + return isType && isTags + }) - const isNotZero = filtered.length > 0 + const isNotZero = filtered.length > 0 - if (isNotZeroElements != isNotZero) - setIsNotZeroElements(isNotZero) + if (isNotZeroElements !== isNotZero) setIsNotZeroElements(isNotZero) - return filtered - } - return ( - <Group header={<Header>Полка аватарок</Header>}> - <Flex margin='auto' gap='2xl' justify='center'> - {filteredAvatars().map((avatar, index) => ( - <Avatar key={index} size={110} src={getUrlPath(avatar)}> - <Avatar.Badge className='select-avatar_badge'> - <Tooltip title={avatar.price ? `${avatar.price} алмазов` : 'Бесплатно'}> - <ToolButton - IconCompact={avatar.price ? Icon16DiamondOutline : Icon16Gift} - IconRegular={avatar.price ? Icon16DiamondOutline : Icon16Gift} - > - {avatar.price || null} - </ToolButton> - </Tooltip> - </Avatar.Badge> - </Avatar> - ) - )} - { - !isNotZeroElements && ( - <Placeholder> - <Placeholder.Icon> - <Icon56TagOutline/> - </Placeholder.Icon> - <Placeholder.Title> - Ничего не найдено - </Placeholder.Title> - <Placeholder.Description> - Нет ни одной аватарки, удовлетворяющей указанным фильтрам - </Placeholder.Description> - </Placeholder> - ) - } - </Flex> - </Group> - ) + return filtered + } + return ( + <Group header={<Header>Полка аватарок</Header>}> + <Flex margin='auto' gap='2xl' justify='center'> + {filteredAvatars().map((avatar, index) => ( + <Avatar + key={index} + size={110} + src={getUrlPath(avatar)} + style={{ isolation: 'auto' }} + > + <Avatar.Badge className='select-avatar_badge'> + <Tooltip + title={avatar.price ? `${avatar.price} алмазов` : 'Бесплатно'} + > + <ToolButton + IconCompact={avatar.price ? Icon16DiamondOutline : Icon16Gift} + IconRegular={avatar.price ? Icon16DiamondOutline : Icon16Gift} + > + {avatar.price || null} + </ToolButton> + </Tooltip> + </Avatar.Badge> + </Avatar> + ))} + {!isNotZeroElements && ( + <Placeholder> + <Placeholder.Icon> + <Icon56TagOutline /> + </Placeholder.Icon> + <Placeholder.Title>Ничего не найдено</Placeholder.Title> + <Placeholder.Description> + Нет ни одной аватарки, удовлетворяющей указанным фильтрам + </Placeholder.Description> + </Placeholder> + )} + </Flex> + </Group> + ) } -export default avatarsPanel \ No newline at end of file +export default avatarsPanel diff --git a/apps/web/src/pages/Market/components/filtersPanel.tsx b/apps/web/src/pages/Market/components/filtersPanel.tsx index 2e4fa781..c95fa14a 100644 --- a/apps/web/src/pages/Market/components/filtersPanel.tsx +++ b/apps/web/src/pages/Market/components/filtersPanel.tsx @@ -1,115 +1,109 @@ -import {Icon24PhotosStackOutline, Icon24VideoCircleOutline} from '@vkontakte/icons' +import type { AvatarData } from '@diary-spo/shared' import { - Counter, - Group, - Header, Separator, - SubnavigationBar, - SubnavigationButton, - VisuallyHidden + Icon24PhotosStackOutline, + Icon24VideoCircleOutline +} from '@vkontakte/icons' +import { + Counter, + Group, + Header, + Separator, + SubnavigationBar, + SubnavigationButton, + VisuallyHidden } from '@vkontakte/vkui' -import {FC} from 'react' -import {AvatarData} from "./types.tsx"; +import type { FC } from 'react' interface Props { - avatars: AvatarData[], - isAnimated: boolean - isStatic: boolean - changeIsAnimated: () => void - changeIsStatic: () => void - setSelectedTags: (tags: string[]) => void - selectedTags: string[] - tags: string[] + avatars: AvatarData[] + isAnimated: boolean + isStatic: boolean + changeIsAnimated: () => void + changeIsStatic: () => void + setSelectedTags: (tags: string[]) => void + selectedTags: string[] + tags: string[] } -const filtersPanel: FC<Props> = ( - { - avatars, - isAnimated, - isStatic, - changeIsAnimated, - changeIsStatic, - setSelectedTags, - selectedTags, - tags - }) => { - - const changeSelectTag = (tag: string) => { - const indexInSelected = selectedTags.indexOf(tag) - - const selectedTagsCopy = selectedTags.slice(0) +const filtersPanel: FC<Props> = ({ + avatars, + isAnimated, + isStatic, + changeIsAnimated, + changeIsStatic, + setSelectedTags, + selectedTags, + tags +}) => { + const changeSelectTag = (tag: string) => { + const indexInSelected = selectedTags.indexOf(tag) - if (indexInSelected > -1) - selectedTagsCopy.splice(indexInSelected, 1) - else selectedTagsCopy.push(tag) + const selectedTagsCopy = selectedTags.slice(0) - setSelectedTags(selectedTagsCopy) + if (indexInSelected > -1) selectedTagsCopy.splice(indexInSelected, 1) + else selectedTagsCopy.push(tag) - console.log(selectedTags) - } + setSelectedTags(selectedTagsCopy) - return ( - <> - <Group header={<Header>Фильтры</Header>}> + console.log(selectedTags) + } - <SubnavigationBar> - <SubnavigationButton - before={<Icon24VideoCircleOutline/>} - selected={isAnimated} - onClick={changeIsAnimated} - mode='primary' - after={ - <Counter size='s'> - <VisuallyHidden>Применено: </VisuallyHidden> - {avatars.filter(avatar => avatar.isAnimated).length} - </Counter> - } - > - Анимированные - </SubnavigationButton> + return ( + <> + <Group header={<Header>Фильтры</Header>}> + <SubnavigationBar> + <SubnavigationButton + before={<Icon24VideoCircleOutline />} + selected={isAnimated} + onClick={changeIsAnimated} + mode='primary' + after={ + <Counter size='s'> + <VisuallyHidden>Применено: </VisuallyHidden> + {avatars.filter((avatar) => avatar.isAnimated).length} + </Counter> + } + > + Анимированные + </SubnavigationButton> - <SubnavigationButton - before={<Icon24PhotosStackOutline/>} - selected={isStatic} - onClick={changeIsStatic} - mode='primary' - after={ - <Counter size='s'> - <VisuallyHidden>Применено: </VisuallyHidden> - {avatars.filter(avatar => !avatar.isAnimated).length} - </Counter> - } - > - Статичные - </SubnavigationButton> + <SubnavigationButton + before={<Icon24PhotosStackOutline />} + selected={isStatic} + onClick={changeIsStatic} + mode='primary' + after={ + <Counter size='s'> + <VisuallyHidden>Применено: </VisuallyHidden> + {avatars.filter((avatar) => !avatar.isAnimated).length} + </Counter> + } + > + Статичные + </SubnavigationButton> - { - tags.length && ( - <Separator direction='vertical'/> - ) - } + {tags.length && <Separator direction='vertical' />} - { - tags.map(tag => ( - <SubnavigationButton - key={tag} - onClick={() => changeSelectTag(tag)} - selected={selectedTags.includes(tag)} - mode='primary' - after={ - <Counter size='s'> - <VisuallyHidden>Применено: </VisuallyHidden> - {avatars.filter(avatar => avatar.tags.includes(tag)).length} - </Counter> - } - > - {tag} - </SubnavigationButton> - )) - } - </SubnavigationBar> - </Group> - </> - ) + {tags.map((tag) => ( + <SubnavigationButton + key={tag} + onClick={() => changeSelectTag(tag)} + selected={selectedTags.includes(tag)} + mode='primary' + after={ + <Counter size='s'> + <VisuallyHidden>Применено: </VisuallyHidden> + {avatars.filter((avatar) => avatar.tags.includes(tag)).length} + </Counter> + } + > + {tag} + </SubnavigationButton> + ))} + </SubnavigationBar> + </Group> + </> + ) } export default filtersPanel diff --git a/apps/web/src/pages/Market/components/marketHeader.tsx b/apps/web/src/pages/Market/components/marketHeader.tsx index 405f1af3..f23e6632 100644 --- a/apps/web/src/pages/Market/components/marketHeader.tsx +++ b/apps/web/src/pages/Market/components/marketHeader.tsx @@ -1,4 +1,4 @@ -import {Icon28DiamondOutline} from '@vkontakte/icons' +import { Icon28DiamondOutline } from '@vkontakte/icons' import { Avatar, Button, @@ -51,7 +51,10 @@ const MarketHeader = () => { } actions={ <ButtonGroup mode='horizontal' gap='s' stretched> - <Tooltip placement='right' description='История списания и зачисления кредитов'> + <Tooltip + placement='right' + description='История списания и зачисления кредитов' + > <Button mode='secondary' size='s'> История операций </Button> diff --git a/apps/web/src/pages/Market/components/types.tsx b/apps/web/src/pages/Market/components/types.tsx deleted file mode 100644 index 7588c1e3..00000000 --- a/apps/web/src/pages/Market/components/types.tsx +++ /dev/null @@ -1,6 +0,0 @@ -export interface AvatarData { - isAnimated: boolean - filename: string - tags: string[] - price: number -} \ No newline at end of file diff --git a/apps/web/src/pages/Market/index.tsx b/apps/web/src/pages/Market/index.tsx index be88740c..753bc01a 100644 --- a/apps/web/src/pages/Market/index.tsx +++ b/apps/web/src/pages/Market/index.tsx @@ -1,84 +1,80 @@ -import {Panel, View} from '@vkontakte/vkui' -import {type FC, useState} from 'react' -import {PanelHeaderWithBack} from '../../shared' +import type { AvatarData } from '@diary-spo/shared' +import { Panel, View } from '@vkontakte/vkui' +import { type FC, useEffect, useState } from 'react' +import { PanelHeaderWithBack } from '../../shared' +import { client } from '../../shared/api/client.ts' +import AvatarsPanel from './components/avatarsPanel.tsx' import Filters from './components/filtersPanel.tsx' import MarketHeader from './components/marketHeader.tsx' -import AvatarsPanel from "./components/avatarsPanel.tsx"; -interface Props { -} +type Props = {} + +const Market: FC<Props> = () => { + const [activePanel] = useState('market') -const avatars = [ - { - isAnimated: true, - filename: '806.gif', - tags: ['Девушка'], - price: 0 - }, - { - isAnimated: false, - filename: '1209.gif', - tags: ['Парень'], - price: 250 - }, - { - isAnimated: false, - filename: '689.jpg', - tags: ['Девушка'], - price: 350 - }, - { - isAnimated: false, - filename: '688.jpg', - tags: ['Девушка'], - price: 1000 - } -] + // ФИЛЬТРЫ + const [isAnimated, setIsAnimated] = useState(true) + const [isStatic, setIsStatic] = useState(true) + const [selectedTags, setSelectedTags] = useState<string[]>([]) + const [tags, setTags] = useState<string[]>([]) + const [avatars, setAvatars] = useState<AvatarData[]>([]) -const tags: string[] = [] -avatars.forEach(avatar => { - avatar.tags.forEach((tag) => { - if (!tags.includes(tag)) - tags.push(tag) - }) -}) + const changeIsAnimated = () => { + setIsAnimated(!isAnimated) + } -const Market: FC<Props> = () => { - const [activePanel] = useState('market') + const changeIsStatic = () => { + setIsStatic(!isStatic) + } + + // Стучимся к серверу + const getAvatarsFromServer = async (): Promise<void> => { + const avatars = await client.marketAvatars({ page: 1 }).get() + + const data = avatars.data + + if (!data) return + + const tagsToSave: string[] = [] - // ФИЛЬТРЫ - const [isAnimated, setIsAnimated] = useState(true) - const [isStatic, setIsStatic] = useState(true) - const [selectedTags, setSelectedTags] = useState<string[]>([]) + avatars.data.forEach((avatar) => { + avatar.tags.forEach((tag) => { + if (!tagsToSave.includes(tag)) tagsToSave.push(tag) + }) + }) - const changeIsAnimated = () => { - setIsAnimated(!isAnimated) - } + setTags(tagsToSave) + setAvatars(data) + } - const changeIsStatic = () => { - setIsStatic(!isStatic) - } + useEffect(() => { + getAvatarsFromServer() + }, []) - return ( - <View activePanel={activePanel}> - <Panel id='market'> - <PanelHeaderWithBack title='Магазин'/> - <MarketHeader/> - <Filters isAnimated={isAnimated} - isStatic={isStatic} - changeIsAnimated={changeIsAnimated} - changeIsStatic={changeIsStatic} - avatars={avatars} - setSelectedTags={setSelectedTags} - selectedTags={selectedTags} - tags={tags}/> - <AvatarsPanel avatars={avatars} - isStatic={isStatic} - isAnimated={isAnimated} - selectedTags={selectedTags}/> - </Panel> - </View> - ) + return ( + <View activePanel={activePanel}> + <Panel id='market'> + <PanelHeaderWithBack title='Магазин' /> + <MarketHeader /> + <Filters + isAnimated={isAnimated} + isStatic={isStatic} + changeIsAnimated={changeIsAnimated} + changeIsStatic={changeIsStatic} + avatars={avatars} + setSelectedTags={setSelectedTags} + selectedTags={selectedTags} + tags={tags} + /> + <AvatarsPanel + avatars={avatars} + isStatic={isStatic} + isAnimated={isAnimated} + selectedTags={selectedTags} + /> + </Panel> + </View> + ) } export default Market diff --git a/apps/web/src/pages/Settings/Actions/index.tsx b/apps/web/src/pages/Settings/Actions/index.tsx index 325243b3..01408b6d 100755 --- a/apps/web/src/pages/Settings/Actions/index.tsx +++ b/apps/web/src/pages/Settings/Actions/index.tsx @@ -109,17 +109,19 @@ const Actions = () => { > Показывать тех. информацию </CellButton> - <CellButton - before={<Icon28Notifications />} - onClick={async () => - window.open( - `${TG_BOT_URL}?text=/subscribe ${await getSecureToken()}`, - '_blank' - ) - } - > - Подключить уведомления - </CellButton> + {TG_BOT_URL !== 'IGNORE' && ( + <CellButton + before={<Icon28Notifications />} + onClick={async () => + window.open( + `${TG_BOT_URL}?text=/subscribe ${await getSecureToken()}`, + '_blank' + ) + } + > + Подключить уведомления + </CellButton> + )} <CellButton before={<Icon28DoorArrowRightOutline />} onClick={() => routeNavigator.showPopout(logOutPopup)} diff --git a/apps/web/src/shared/config/constants.ts b/apps/web/src/shared/config/constants.ts index ddad21df..4d28568f 100755 --- a/apps/web/src/shared/config/constants.ts +++ b/apps/web/src/shared/config/constants.ts @@ -3,4 +3,4 @@ export const THIRD_SEC = 30 * SECOND /** Modal's ids */ export const MODAL_PAGE_LESSON = 'lesson' export const MODAL_PAGE_MARK = 'mark' -export const MODAL_PAGE_USER_EDIT = 'user-edit' \ No newline at end of file +export const MODAL_PAGE_USER_EDIT = 'user-edit' diff --git a/apps/web/src/widgets/recent-marks/ui/LessonGrades/index.tsx b/apps/web/src/widgets/recent-marks/ui/LessonGrades/index.tsx index 02fad56b..0c6e4888 100755 --- a/apps/web/src/widgets/recent-marks/ui/LessonGrades/index.tsx +++ b/apps/web/src/widgets/recent-marks/ui/LessonGrades/index.tsx @@ -40,6 +40,7 @@ export const LessonGrades: FC<LessonGradesProps> = ({ day, lessonGrades }) => { <div className='flex'> {lessonGrades.map(({ lessonName, task }) => ( <HorizontalCell + key={task.id} size='xl' onClick={() => handleMarkClick(task, lessonName)} className='markWrapper' diff --git a/bun.lockb b/bun.lockb index bb6f63098572327ddb4b085d8d431bcc9fa77668..7868480d3bc90ddc9ba71ad2fe24c58047077a24 100755 GIT binary patch delta 6868 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 delta 5891 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