├── .editorconfig ├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── backend ├── .dockerignore ├── .env ├── Dockerfile ├── README.md ├── nest-cli.json ├── package-lock.json ├── package.json ├── src │ ├── app.config.ts │ ├── app.interceptor.ts │ ├── app.module.ts │ ├── articles │ │ ├── articles.module.ts │ │ ├── articles.resolver.ts │ │ ├── articles.service.ts │ │ ├── dto │ │ │ ├── archive-article.input.ts │ │ │ ├── article.input.ts │ │ │ ├── articles.input.ts │ │ │ ├── create-article.input.ts │ │ │ └── update-article.input.ts │ │ ├── entities │ │ │ └── article.entity.ts │ │ └── models │ │ │ └── article.model.ts │ ├── auth │ │ ├── auth.decorators.ts │ │ ├── auth.guard.ts │ │ ├── auth.module.ts │ │ ├── auth.resolver.ts │ │ ├── auth.service.ts │ │ ├── auth.utils.ts │ │ ├── dto │ │ │ └── authenticate.input.ts │ │ ├── entities │ │ │ └── user.entity.ts │ │ ├── models │ │ │ └── user.model.ts │ │ └── strategies │ │ │ └── jwt.strategy.ts │ ├── bot │ │ ├── bot.module.ts │ │ ├── bot.service.ts │ │ └── bot.update.ts │ ├── i18n │ │ ├── i18n.generated.ts │ │ ├── i18n.resolver.ts │ │ └── locales │ │ │ ├── en │ │ │ └── common.yml │ │ │ ├── ru │ │ │ └── common.yml │ │ │ └── uk │ │ │ └── common.yml │ ├── main.ts │ └── schema.gql ├── test │ ├── app.e2e-spec.ts │ └── jest-e2e.json ├── tsconfig.build.json └── tsconfig.json ├── docker-compose.yaml └── frontend ├── .dockerignore ├── .env.development ├── .env.production ├── .eslintrc.json ├── .gitignore ├── .prettierrc.json ├── @types ├── glodal.d.ts └── gql │ └── index.d.ts ├── Dockerfile ├── README.md ├── app └── [locale] │ ├── articles │ ├── layout.tsx │ └── page.tsx │ ├── auth │ └── [...slug] │ │ ├── layout.tsx │ │ └── page.tsx │ ├── edit │ └── [articleId] │ │ ├── layout.tsx │ │ └── page.tsx │ ├── layout.tsx │ ├── page.tsx │ ├── providers.tsx │ └── view │ └── [articleId] │ ├── layout.tsx │ └── page.tsx ├── codegen.ts ├── components ├── CustomLink.tsx ├── PublishModal.tsx ├── SDKLoader.tsx ├── SetLinkModal.tsx └── Tiptap.tsx ├── environment └── client.ts ├── i18n ├── client.ts ├── locales │ ├── en.ts │ ├── ru.ts │ └── uk.ts └── server.ts ├── middleware.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.js ├── services ├── graphql │ └── client.ts ├── keyFactory.ts ├── useArchiveArticleMutation.ts ├── useArticleQuery.ts ├── useArticlesQuery.ts ├── useAuthenticateMutation.ts ├── useCreateArticleMutation.ts ├── useUpdateArticleMutation.ts └── useUserQuery.ts ├── styles └── globals.scss ├── tailwind.config.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | insert_final_newline = false 15 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # testing 9 | coverage 10 | 11 | # next.js 12 | .next/ 13 | out/ 14 | 15 | # production 16 | build 17 | dist 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | /.env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch frontend", 9 | "type": "node", 10 | "request": "launch", 11 | "cwd": "${workspaceFolder}/frontend", 12 | "runtimeExecutable": "npm", 13 | "runtimeArgs": ["run", "dev"], 14 | "console": "integratedTerminal" 15 | }, 16 | { 17 | "name": "Launch backend & bot", 18 | "type": "node", 19 | "request": "launch", 20 | "cwd": "${workspaceFolder}/backend", 21 | "runtimeExecutable": "npm", 22 | "runtimeArgs": ["run", "start:dev"], 23 | "console": "integratedTerminal" 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.workingDirectories": [ 3 | "./frontend", 4 | "./backend" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Vitaliy Grusha 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # 📝 MiniContent 3 | 4 | MiniContent - a mini-app for Telegram, enabling community leaders to create exclusive articles for their audience with Telegram Premium subscriptions. 5 | 6 | ## 🎥 Demo 7 | 8 | 9 | 🤖 [Telegram Mini-app](https://t.me/MiniContentBot) 10 | 11 | 📢 [Telegram Channel with articles examples](https://t.me/MiniContent_Demo) 12 | 13 | ## ✨ Features 14 | 15 | MiniContent has tools to help you share and manage your articles on Telegram: 16 | 17 | - 📝 **Write for Everyone**: Make articles for both free and Premium Telegram users. 18 | 19 | - 🎨 **Rich Formatting**: Use easy tools to make your articles look nice. 20 | 21 | - 🔗 **Instant Links**: Add links in your articles that open instantly in Telegram. 22 | 23 | - 📊 **See Views**: Check how many people read each article. 24 | 25 | ## 🚀 Usability 26 | With MiniContent, you can make special articles just for people who pay for a Premium subscription on Telegram. What This Means: 27 | 28 | - More Boosts: Premium subscribers will want to read what you make and give more boots to your channel. 29 | - Support Telegram: When you offer special articles, more people might want to get a Premium subscription. 30 | 31 | ## Table of Contents 32 | 33 | - [📝 MiniContent](#-minicontent) 34 | * [🎥 Demo](#-demo) 35 | * [✨ Features](#-features) 36 | * [🚀 Usability](#-usability) 37 | * [💡 Motivation](#-motivation) 38 | * [🔧 How It Works](#-how-it-works) 39 | * [🛠 Tech Stack](#--tech-stack) 40 | * [Development environment](#development-environment) 41 | + [Setting Up Telegram Test Server](#setting-up-telegram-test-server) 42 | + [How to Access the Test Server:](#how-to-access-the-test-server) 43 | - [Telegram iOS:](#telegram-ios) 44 | - [Telegram Android:](#telegram-android) 45 | - [Telegram Desktop (Windows):](#telegram-desktop-windows) 46 | - [Telegram Desktop (Mac OS):](#telegram-desktop-mac-os) 47 | * [Create bot in Test Server](#create-bot-in-test-server) 48 | * [Run Locally](#run-locally) 49 | + [Prerequisite: Installing MongoDB](#prerequisite-installing-mongodb) 50 | + [Installing dependencies](#installing-dependencies) 51 | + [Settings environment variables](#settings-environment-variables) 52 | + [Run both frontend and backend](#run-both-frontend-and-backend) 53 | + [Developing GraphQL API](#developing-graphql-api) 54 | + [Using Next.js Rewrites to Avoid CORS Issues](#using-nextjs-rewrites-to-avoid-cors-issues) 55 | + [Testing with Ngrok Tunnel](#testing-with-ngrok-tunnel) 56 | * [📁 NestJS Backend Structure](#-nestjs-backend-structure) 57 | + [📁 articles Directory](#-articles-directory) 58 | + [📁 auth Directory](#-auth-directory) 59 | + [📁 bot Directory](#-bot-directory) 60 | * [📁 NextJS Frontend Structure](#-nextjs-frontend-structure) 61 | + [📁 `app` Directory](#-app--directory) 62 | + [📁 components Directory](#-components-directory) 63 | + [📁 environment Directory](#-environment-directory) 64 | + [📁 i18n Directory](#-i18n-directory) 65 | + [📁 services Directory](#-services-directory) 66 | * [Key libraries and tools used](#key-libraries-and-tools-used) 67 | + [Backend](#backend) 68 | + [Frontend](#frontend) 69 | * [Deployment](#deployment) 70 | * [License](#license) 71 | 72 | 73 | ## 💡 Motivation 74 | 75 | The motivation of this project is to make it easy for people to build mini-apps for their communities. You can use this repo like a starting step. From here, you can make many great projects. 76 | 77 | ## 🔧 How It Works 78 | 79 | MiniContent lets you make articles with two parts: 80 | 81 | - For Everyone: A part that everyone can read. 82 | - For Premium Users: A part that only people with a Telegram Premium subscription can see. 83 | 84 | Here's how you use it: 85 | 86 | - Open MiniContent mini-app. 87 | - Click on "Create New Article." 88 | - Write in two separate fields: one for everyone, one for premium users. 89 | - When you're done, click publish. You'll get a link. 90 | - Share this link anywhere on Telegram: in channels, groups, or private chats. 91 | 92 | When someone clicks on your article link: 93 | - MiniContent verify a [data recived from Telegram](https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app) and generate a JWT token with user data as a payload. 94 | - The server looks at [`user.is_premium`](https://core.telegram.org/bots/webapps#webappuser) field to see if they have a Premium Telegram subscription. 95 | - If they do, they get to see the premium part of your article. 96 | 97 | ## 🛠 Tech Stack 98 | This project is made using one programming language: **TypeScript**. 99 | 100 | - **Backend**: [NestJS](https://docs.nestjs.com/) - is a framework for building efficient and scalable server-side applications. It uses JavaScript, and it's easy to learn if you know JavaScript. 101 | - **Frontend**: [NextJS](https://nextjs.org/) - is a popular framework for building web applications. It makes it easy to build both static websites and server-rendered pages. This means your website can load and run very fast, giving visitors a smooth experience. 102 | - **Database**: MongoDB. 103 | 104 | ## Development environment 105 | 106 | ### Setting Up Telegram Test Server 107 | To effectively develop and test your mini-app, it's essential to use the Telegram test server. Here's why: 108 | 109 | - Local Host Testing: The Telegram test server allows you to run your mini-app on localhost, making development more manageable. 110 | 111 | - No HTTPS Requirement: Unlike the production server, the test server doesn't mandate the use of HTTPS. This simplifies the setup and testing process during development. 112 | 113 | ### How to Access the Test Server 114 | 115 | First you need to create account in Telegram test server using your **phone**. You can use your phone number. Learn more about [Telegram Test Server](https://core.telegram.org/bots/webapps#testing-mini-apps). 116 | 117 | #### Telegram iOS: 118 | 1. Fastly tap Settings section 10 times; 119 | 2. Tap Accounts button; 120 | 3. Tap Login to another account; 121 | 4. Tap Test button; 122 | 5. Create a new account. 123 | 124 | #### Telegram Android: 125 | 1. Install [Telegram Beta](https://install.appcenter.ms/users/drklo-2kb-ghpo/apps/telegram-beta-2/distribution_groups/all-users-of-telegram-beta-2); 126 | 2. Open Telegram Beta; 127 | 3. Check "Test server" and create a new account. 128 | 129 | Then open Telegram Desktop. Ensure you're using the official Telegram Desktop client. 130 | 131 | #### Telegram Desktop (Windows): 132 | 1. Open side menu; 133 | 2. Expand the menu item, where current username is specified; 134 | 3. Hold Shift + Alt and press right mouse button on Add Account button; 135 | 4. Select Test Server; 136 | 5. Scan the QR code using a phone with an account on the test server (open "Devices" setting in Telegram and click "Link Desktop Device"). 137 | 138 | #### Telegram Desktop (Mac OS): 139 | 1. click the Settings icon 10 times to open the Debug Menu 140 | 2. Hold ⌘ and click ‘Add Account’ 141 | 3. Scan the QR code using a phone with an account on the test server (open "Devices" setting in Telegram and click "Link Desktop Device"). 142 | 143 | ## Create bot in Test Server 144 | 145 | Open @BotFather in Test Server and create your bot as usual. 146 | 147 | Then create an app using `/newapp` command using settings like that: 148 | 149 | ``` 150 | Direct Link: https://t.me/MiniContentBot/view 151 | Description: View 152 | GIF: 🚫 has no GIF 153 | Web App URL: http://127.0.0.1:3000/auth/view/ 154 | ``` 155 | 156 | Than you need to set a domain to bot: 157 | 1. Run `/mybots` in BotFather and select your bot 158 | 2. Click Bot Settings -> Domain -> Set Domain 159 | 3. Send `127.0.0.1` to the BotFather. 160 | 161 | ## Run Locally 162 | 163 | ### Prerequisite: Installing MongoDB 164 | Before setting up MiniContent, you must have MongoDB installed on your machine. 165 | 166 | Install MongoDB from [MongoDB Download Center](https://www.mongodb.com/docs/manual/administration/install-community/). 167 | 168 | Also, you need to create a user in MongoDB: [instruction here](https://www.mongodb.com/docs/manual/tutorial/configure-scram-client-authentication/#std-label-create-user-admin). 169 | 170 | ### Installing dependencies 171 | 172 | Clone the project 173 | 174 | ```bash 175 | git clone https://github.com/tonusdev/mini-content.git 176 | ``` 177 | 178 | Go to the project directory 179 | 180 | ```bash 181 | cd mini-content 182 | ``` 183 | 184 | Install dependencies for backend 185 | 186 | ```bash 187 | cd backend 188 | npm install 189 | ``` 190 | 191 | Install dependencies for frontend 192 | 193 | ```bash 194 | cd frontend 195 | npm install 196 | ``` 197 | 198 | ### Settings environment variables 199 | 200 | To run this project, you will need to add the following environment variables to your `.env` file in `backend` folder: 201 | 202 | ```bash 203 | # Settings for the backend server 204 | APP_PORT=3001 205 | APP_ADDRESS=127.0.0.1 206 | 207 | # Settings for handling cross-origin requests 208 | CORS_ALLOWED_HEADERS=Content-Type,Authorization 209 | CORS_CREDENTIALS=true 210 | CORS_METHODS=GET,POST,OPTIONS 211 | CORS_ORIGIN=* 212 | 213 | # Telegram server setting 214 | # Use Telegram's test server instead of the production one 215 | TELEGRAM_TEST_SERVER=true 216 | 217 | # Settings for GraphQL 218 | # Enable GraphQL's Integrated Development Environment (IDE) 219 | GRAPHQL_ENABLE_IDE=true 220 | # Allow introspection of your GraphQL schema 221 | GRAPHQL_ENABLE_INTROSPECTION=true 222 | 223 | # MongoDB database connection string 224 | MONGODB_URI=mongodb://:@localhost:27017/minicontent?authSource=admin 225 | 226 | # Telegram bot token 227 | BOT_TOKEN=2200244087:** # The token for the Telegram bot 228 | 229 | # Settings for JSON Web Tokens (JWT) 230 | JWT_ALGORITHM=HS256 # The algorithm used for JWT 231 | JWT_EXPIRES_IN=1h # How long the JWT will remain valid 232 | JWT_SECRET=secret # The secret key for JWT 233 | 234 | # The domain for the backned 235 | DOMAIN=127.0.0.1 236 | # The environment the app is running in (development, production, etc.) 237 | NODE_ENV=development 238 | 239 | # The link to the mini-app for bot authentication (Port 3000 is set - this is the port of the NextJS ) 240 | BOT_MINIAPP_LINK=http://127.0.0.1:3000/auth/articles 241 | 242 | ``` 243 | 244 | Next, you need to update environment variables in `frontend` frolder in `.env.development` file: 245 | 246 | ```bash 247 | # Endpoint for graphql requests 248 | NEXT_PUBLIC_GRAPHQL_ENDPOINT=/graphql 249 | 250 | # Link to mini app created in BotFather 251 | NEXT_PUBLIC_MINI_APP_LINK=https://t.me/MiniContentBot/view 252 | 253 | # Endpoint for the GraphQL server where Next will proxy the requests (it is used in /frontend/next.config.mjs file) 254 | GRAPHQL_SERVER_ENDPOINT=http://localhost:3001/graphql 255 | ``` 256 | 257 | ### Run both frontend and backend 258 | 259 | To run both the frontend and backend services simultaneously, you can use Visual Studio Code's 'Run and Debug' feature or execute them in two separate terminal windows. 260 | 261 | Run backend: 262 | 263 | ```bash 264 | cd backend 265 | npm run start:dev 266 | ``` 267 | 268 | Run frontend: 269 | 270 | ```bash 271 | cd frontend 272 | npm run dev 273 | ``` 274 | 275 | After starting up, you can type `/start` in your bot chat. This will send you a welcome message with a button that launches the mini-app in Telegram. 276 | 277 | ### Developing GraphQL API 278 | 279 | It is necessary that the frontend and backend work simultaneously for developing api. 280 | 281 | 1. Run Your Bot: Start your bot to access the mini-app. 282 | 2. [Open Debug Mode](https://core.telegram.org/bots/webapps#debug-mode-for-mini-apps): This lets you see what's happening in the console. 283 | 3. Look for a Message: In the console, there's a message saying, Enter this into authenticate mutation in Apollo IDE for development:, followed by some data from Telegram. 284 | 4. Copy the Data: Highlight and copy the Telegram data. 285 | 5. [Open Apollo IDE](https://www.apollographql.com/docs/graphos/explorer): In your web browser, go to http://127.0.0.1:3000/graphql. This is where you can test and develop your GraphQL API. 286 | 6. Set Up the Mutation: Type in this code: 287 | 288 | ```graphql 289 | mutation Mutation($input: AuthenticateInput!) { 290 | authenticate(input: $input) 291 | } 292 | ``` 293 | 294 | 7. Add Your Data: On the side, there's a section for "variables." Enter this: 295 | 296 | ```json 297 | { 298 | "input": { 299 | "initDataRaw": "PASTE YOUR COPIED TELEGRAM DATA HERE" 300 | } 301 | } 302 | ``` 303 | 304 | 8. Run It: Click the "Run" or "Mutation" button. 305 | 306 | Doing this logs you in and lets you develop your GraphQL API in the Apollo IDE. 307 | 308 | ### Using Next.js Rewrites to Avoid CORS Issues 309 | 310 | With Next.js, you can use the [rewrites feature](https://nextjs.org/docs/pages/api-reference/next-config-js/rewrites) in the configuration. This lets you redirect frontend requests to your backend without running into CORS (Cross-Origin Resource Sharing) errors. Basically, it's a way to make sure your frontend and backend can talk to each other without any problems. You can see this configuration in `./frontend/next.config.mjs` file. 311 | 312 | ### Testing with Ngrok Tunnel 313 | Ngrok is a handy tool that creates a secure tunnel from a public endpoint (like a URL) to a local server on your machine. By using Ngrok's tunnel feature, you can test your mini-app in a production-like environment even when it's running locally. This is especially useful for testing features and integrations that require a live URL. 314 | 315 | In short, Ngrok ensures that you can test your mini-app thoroughly before officially deploying it to production. 316 | 317 | 1. Go to ngrok egde dashboard: https://dashboard.ngrok.com/cloud-edge/edges 318 | 2. Click on "New Edge" button 319 | 3. Get edge id, like that: `edge=edghts_` 320 | 4. Run on your local machine: `ngrok tunnel --label edge=edghts_****** http://127.0.0.1:3000` 321 | 5. Click "Refresh" in ngrok page and verify that tunnel works 322 | 6. Copy domain with https, like that `https://********.ngrok.app` 323 | 7. Update backend .env file with domain 324 | 8. Update bot configs in BotFather with this domain 325 | 326 | ## 📁 NestJS Backend Structure 327 | 328 | NestJS provides first-class support for GraphQL, allowing developers to create flexible and strongly-typed APIs quickly. Here's a basic outline of its organization: 329 | - Modules: NestJS promotes a modular architecture. Each feature of the application is split into its module, allowing for a clear separation of concerns. Modules are declared using the @Module() decorator. 330 | - Resolvers: In the GraphQL context, resolvers handle the incoming queries and mutations. They're classes adorned with the @Resolver() decorator. Inside, methods with @Query() handle GraphQL queries, and methods with @Mutation() handle mutations. 331 | - Schema: NestJS with GraphQL often uses a code-first approach, meaning the GraphQL schema is automatically generated from your TypeScript classes (DTOs and Resolvers). However, you can also use a schema-first approach where you define your schema using SDL (Schema Definition Language). 332 | - Providers: Providers can be services, repositories, factories, or any other classes that the system needs to function. They are decorated with @Injectable(), and are responsible for the business logic of your application. 333 | - Middleware: Middleware in NestJS works similarly to Express middleware, allowing you to run specific code before the request reaches the route handler. 334 | - Interceptors: Interceptors have a set of useful capabilities, like transforming the response returned from a method execution or manipulating request objects. 335 | - Guards: Guards determine if a request will be handled by the route handler. They are used mainly for authentication and authorization purposes. 336 | - Decorators: Custom decorators allow you to create annotations for your classes, methods, or method parameters. 337 | - DTOs (Data Transfer Objects): DTOs are used to define the data structure for both incoming and outgoing requests. They can help in validating data using class-validator. 338 | - Models: Represent the data models and their relationships, especially when using ORMs like Mongoose with databases. 339 | 340 | 341 | Below is an overview of the main components and their responsibilities in our NestJS backend: 342 | 343 | - app.config.ts: Contains the application's main configuration. 344 | - app.interceptor.ts: Defines the global interceptor for the app. 345 | - app.module.ts: The root module of the application. This is where core modules are imported. 346 | 347 | ### 📁 articles Directory 348 | Handles everything related to articles: 349 | 350 | - articles.module.ts: Module for articles-related components. 351 | - articles.resolver.ts: Resolves the GraphQL queries and mutations related to articles. 352 | - articles.service.ts: Service that contains business logic for articles. 353 | - dto: Contains data transfer objects for articles operations: 354 | - entities: Contains the main entity related to articles: 355 | - models: Contains the main model for articles: 356 | 357 | ### 📁 auth Directory 358 | Handles authentication: 359 | 360 | - auth.decorators.ts: Contains decorators related to authentication. 361 | - auth.guard.ts: Defines the guard for routes requiring authentication. 362 | - auth.module.ts: Module for authentication components. 363 | - auth.resolver.ts: Resolves the GraphQL queries and mutations related to authentication. 364 | - auth.service.ts: Service containing the business logic for authentication. 365 | - auth.utils.ts: Contains utility functions for authentication. 366 | - dto: Contains data transfer object for authentication: 367 | - entities: Contains the main entity related to users: 368 | - models: Contains the main model for users: 369 | - strategies: Contains strategies for authentication: 370 | 371 | ### 📁 bot Directory 372 | Handles the Telegram bot interactions: 373 | 374 | - bot.module.ts: Module for bot-related components. 375 | - bot.service.ts: Service containing the business logic for the bot. 376 | - bot.update.ts: Contains updates handler for the bot. 377 | 378 | 379 | ## 📁 NextJS Frontend Structure 380 | 381 | 382 | This section provides an overview of the main components and their responsibilities in our frontend setup: 383 | 384 | * **@types**: Contains custom type declarations. 385 | 386 | * **global.d.ts**: Global type definitions. 387 | * **gql**: GraphQL-related type declarations. 388 | * **index.d.ts**: Main GraphQL type definitions. 389 | * **Dockerfile**: Contains the instructions to containerize the frontend application using Docker. 390 | 391 | 392 | 393 | ### 📁 `app` Directory 394 | 395 | Handles the primary application logic, structured by localization: 396 | 397 | * **\[locale\]**: Locale-specific directory (e.g., 'en', 'ru', 'uk'). Contains localized routes. 398 | 399 | * **articles**: Handles article-related views. 400 | 401 | * **layout.tsx**: The main layout for the articles section. 402 | * **page.tsx**: The main page rendering articles. 403 | * **auth**: Handles authentication views. 404 | 405 | * **\[...slug\]**: Dynamic route handling various authentication paths. 406 | * **layout.tsx**: The main layout for the authentication section. 407 | * **page.tsx**: The main authentication page. 408 | * **edit**: Allows editing articles. 409 | 410 | * **\[articleId\]**: Dynamic route for individual article editing. 411 | * **layout.tsx**: The layout for the article editing section. 412 | * **page.tsx**: The main page to edit articles. 413 | * **view**: For viewing individual articles. 414 | 415 | * **\[articleId\]**: Dynamic route to view specific articles. 416 | * **layout.tsx**: The main layout for viewing an article. 417 | * **page.tsx**: The page rendering the article content. 418 | * **layout.tsx**: The primary layout for the application. 419 | 420 | * **page.tsx**: The primary page component. 421 | 422 | * **providers.tsx**: Providers setup for state and context management. 423 | 424 | * **codegen.ts**: Configuration for generating types and operations from GraphQL. 425 | 426 | ### 📁 components Directory 427 | Contains reusable UI components: 428 | 429 | - CustomLink.tsx: A custom link component for navigation. 430 | - PublishModal.tsx: Modal component for publishing actions. 431 | - SDKLoader.tsx: Component to load the Telegram SDK. 432 | - SetLinkModal.tsx: Modal component to set article links. 433 | - Tiptap.tsx: Component related to the Tiptap text editor. 434 | 435 | ### 📁 environment Directory 436 | Handles the environment-specific configurations: 437 | 438 | - client.ts: Client-side environment configuration. 439 | 440 | ### 📁 i18n Directory 441 | Handles internationalization: 442 | 443 | - client.ts: Client-side i18n configurations. 444 | - server.ts: Server-side i18n configurations. 445 | - locales: Contains language-specific translations. 446 | 447 | ### 📁 services Directory 448 | Contains service utilities, often relating to data fetching or business logic: 449 | 450 | - graphql: GraphQL client configuration and related utilities. 451 | - client.ts: GraphQL client setup. 452 | - keyFactory.ts: Utility for key generation for React Query. 453 | - useArchiveArticleMutation.ts: Hook for archiving articles. 454 | - useArticleQuery.ts: Hook to fetch a single article. 455 | - useArticlesQuery.ts: Hook to fetch multiple articles. 456 | - useAuthenticateMutation.ts: Hook for user authentication. 457 | - useCreateArticleMutation.ts: Hook to create articles. 458 | - useUpdateArticleMutation.ts: Hook to update articles. 459 | - useUserQuery.ts: Hook to fetch user data. 460 | 461 | ## Key libraries and tools used 462 | 463 | ### Backend 464 | 465 | - [NestJS](https://nestjs.com/) - is a progressive Node.js framework for building efficient, reliable, and scalable server-side applications. With its modular architecture and expressive API, it provides an out-of-the-box application architecture. 466 | 467 | - [Mongoose](https://mongoosejs.com/) - provides a straight-forward, schema-based solution createing models for MongoDB. It includes built-in type casting, validation, and more, out of the box. 468 | 469 | - [Telegraf](https://telegraf.js.org/) - is a modern Telegram bot framework for Node.js. It provides a simple and efficient way to handle updates and create Telegram bots. 470 | 471 | - [GraphQL](https://graphql.org/) - is a query language for your API, and a runtime for executing those queries with your existing data. Instead of multiple endpoints, GraphQL provides a more efficient, powerful, and flexible alternative to REST. 472 | 473 | - [nestjs-i18n-telegraf](https://github.com/vitaliy-grusha/nestjs-i18n-telegraf) - is a module that provides i18n support for NestJS applications with support of Telegraf 474 | 475 | ### Frontend 476 | 477 | - [NextJS](https://nextjs.org/) - is a React framework that enables features such as server-side rendering and static site generation. It's designed for building highly scalable and optimized React applications. 478 | 479 | - [nextui](https://nextui.org/) - offers a collection of high-quality React components, hooks, and themes. With a focus on simplicity and usability, it helps in crafting beautiful interfaces with ease. 480 | 481 | - [React Query](https://react-query.tanstack.com/) - is a data-fetching and state management library for React. It automates many repetitive tasks involved in fetching, caching, synchronizing, and updating server-state in your applications, leading to a more efficient data handling process. 482 | 483 | - [Tiptap](https://tiptap.dev/) - is a headless and framework-agnostic rich-text editor. It provides the tools to create rich-text editors with a clean JSON output and is highly extensible through its plugin-based architecture. 484 | 485 | - [@tma.js/sdk-react](https://docs.telegram-mini-apps.com/docs/libraries/tma-js-sdk-react) - React bindings for the Telegram mini-app client SDK. It includes hooks, components, and other useful tools that enable the use of React alongside the Web Apps client SDK. It automatically tracks changes to SDK components. 486 | 487 | - [Tailwind CSS](https://tailwindcss.com/) - is a utility-first CSS framework that allows developers to quickly build custom user interfaces. Instead of writing custom CSS classes for each design or component, Tailwind provides a set of utility classes to add directly to your HTML. 488 | 489 | - [next-international](https://next-international.vercel.app/docs) - is a library for internationalization in Next.js with type-safety. 490 | 491 | ## Deployment 492 | 493 | 494 | Deploying the application becomes simple with Docker Compose. It allows us to define and run multi-container Docker applications. 495 | 496 | Ensure you have both Docker and Docker Compose installed on your VPS. You can open `docker-compose.yaml` file to see deployment configuration. 497 | 498 | Steps: 499 | 1. Clone the repository: 500 | 501 | ```bash 502 | git clone https://github.com/tonusdev/mini-content.git 503 | cd mini-content 504 | ``` 505 | 506 | 2. Create one `.env` file with environment variables for backend and frontend both: 507 | 508 | ```bash 509 | MONGODB_USER=admin 510 | MONGODB_PASS=****** 511 | MONGODB_DATABASE=minicontent 512 | BOT_TOKEN=58117:****** 513 | DOMAIN=domain.dev 514 | JWT_SECRET=secret 515 | BOT_MINIAPP_LINK=https://domain.dev/auth/articles 516 | 517 | NEXT_PUBLIC_GRAPHQL_ENDPOINT=/graphql 518 | NEXT_PUBLIC_MINI_APP_LINK=https://t.me/MiniContentBot/view 519 | ``` 520 | 521 | 3. Build the Docker images: 522 | 523 | ```bash 524 | docker-compose build 525 | ``` 526 | 4. Run the application: 527 | ```bash 528 | docker-compose up 529 | ``` 530 | ## License 531 | 532 | [MIT](https://choosealicense.com/licenses/mit/) 533 | 534 | -------------------------------------------------------------------------------- /backend/.dockerignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /storybook-static 3 | /.next 4 | /out 5 | /build 6 | /dist 7 | /dev 8 | .env 9 | -------------------------------------------------------------------------------- /backend/.env: -------------------------------------------------------------------------------- 1 | APP_PORT=3001 2 | APP_ADDRESS=127.0.0.1 3 | 4 | CORS_ALLOWED_HEADERS=Content-Type,Authorization 5 | CORS_CREDENTIALS=true 6 | CORS_METHODS=GET,POST,OPTIONS 7 | CORS_ORIGIN=* 8 | 9 | TELEGRAM_TEST_SERVER=true 10 | 11 | GRAPHQL_ENABLE_IDE=true 12 | GRAPHQL_ENABLE_INTROSPECTION=true 13 | 14 | MONGODB_URI=mongodb://admin:***@localhost:27017/minicontent_dev?authSource=admin 15 | 16 | BOT_TOKEN=2200244087:** 17 | 18 | JWT_ALGORITHM=HS256 19 | JWT_EXPIRES_IN=1h 20 | JWT_SECRET=secret 21 | 22 | DOMAIN=127.0.0.1 23 | NODE_ENV=development 24 | 25 | BOT_MINIAPP_LINK=http://127.0.0.1/auth/articles 26 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20.5-alpine AS base 2 | 3 | FROM base AS dependencies 4 | WORKDIR /backend 5 | COPY package.json package-lock.json ./ 6 | RUN npm ci 7 | 8 | FROM base AS build 9 | WORKDIR /backend 10 | COPY . . 11 | COPY --from=dependencies /backend/node_modules ./node_modules 12 | ENV NODE_ENV production 13 | RUN npm run build 14 | RUN npm ci --only=production && npm cache clean --force 15 | 16 | USER node 17 | 18 | FROM base AS deploy 19 | WORKDIR /backend 20 | COPY --from=build /backend/dist/ ./dist/ 21 | COPY --from=build /backend/node_modules ./node_modules 22 | 23 | CMD [ "node", "dist/main.js" ] 24 | -------------------------------------------------------------------------------- /backend/README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 | [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 6 | [circleci-url]: https://circleci.com/gh/nestjs/nest 7 | 8 |

A progressive Node.js framework for building efficient and scalable server-side applications.

9 |

10 | NPM Version 11 | Package License 12 | NPM Downloads 13 | CircleCI 14 | Coverage 15 | Discord 16 | Backers on Open Collective 17 | Sponsors on Open Collective 18 | Donate us 19 | Support us 20 | Follow us on Twitter 21 |

22 | 24 | 25 | ## Description 26 | 27 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 28 | 29 | ## Installation 30 | 31 | ```bash 32 | $ npm install 33 | ``` 34 | 35 | ## Running the app 36 | 37 | ```bash 38 | # development 39 | $ npm run start 40 | 41 | # watch mode 42 | $ npm run start:dev 43 | 44 | # production mode 45 | $ npm run start:prod 46 | ``` 47 | 48 | ## Test 49 | 50 | ```bash 51 | # unit tests 52 | $ npm run test 53 | 54 | # e2e tests 55 | $ npm run test:e2e 56 | 57 | # test coverage 58 | $ npm run test:cov 59 | ``` 60 | 61 | ## Support 62 | 63 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). 64 | 65 | ## Stay in touch 66 | 67 | - Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec) 68 | - Website - [https://nestjs.com](https://nestjs.com/) 69 | - Twitter - [@nestframework](https://twitter.com/nestframework) 70 | 71 | ## License 72 | 73 | Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE). 74 | -------------------------------------------------------------------------------- /backend/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "builder": "swc", 7 | "typeCheck": true, 8 | "deleteOutDir": true, 9 | "watchAssets": true, 10 | "assets": [ 11 | { 12 | "include": "./i18n/locales/", 13 | "watchAssets": true 14 | } 15 | ] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mini-content-backend", 3 | "private": true, 4 | "version": "1.0.0", 5 | "license": "MIT", 6 | "scripts": { 7 | "build": "nest build", 8 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 9 | "start": "nest start", 10 | "start:dev": "nest start --watch", 11 | "start:debug": "nest start --debug --watch", 12 | "start:prod": "node dist/main", 13 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 14 | "test": "jest", 15 | "test:watch": "jest --watch", 16 | "test:cov": "jest --coverage", 17 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 18 | "test:e2e": "jest --config ./test/jest-e2e.json" 19 | }, 20 | "dependencies": { 21 | "@apollo/server": "^4.9.3", 22 | "@nestjs/apollo": "^12.0.9", 23 | "@nestjs/common": "^10.2.6", 24 | "@nestjs/config": "^3.1.1", 25 | "@nestjs/core": "^10.2.6", 26 | "@nestjs/graphql": "^12.0.9", 27 | "@nestjs/jwt": "^10.1.1", 28 | "@nestjs/mongoose": "^10.0.1", 29 | "@nestjs/passport": "^10.0.2", 30 | "@nestjs/platform-express": "^10.2.6", 31 | "class-transformer": "^0.5.1", 32 | "class-validator": "^0.14.0", 33 | "cookie-parser": "^1.4.6", 34 | "dayjs": "^1.11.10", 35 | "graphql": "^16.8.1", 36 | "graphql-scalars": "^1.22.2", 37 | "jsonwebtoken": "^9.0.2", 38 | "mongodb": "5.8.1", 39 | "mongoose": "^7.5.3", 40 | "mongoose-long": "^0.7.1", 41 | "ms": "^2.1.3", 42 | "nestjs-i18n": "10.2.6", 43 | "nestjs-i18n-telegraf": "^10.3.0", 44 | "nestjs-pino": "^3.5.0", 45 | "nestjs-telegraf": "^2.7.0", 46 | "passport-jwt": "^4.0.1", 47 | "pino-http": "^8.5.0", 48 | "reflect-metadata": "^0.1.13", 49 | "rxjs": "^7.8.1", 50 | "telegraf": "^4.14.0" 51 | }, 52 | "devDependencies": { 53 | "@nestjs/cli": "^10.1.18", 54 | "@nestjs/schematics": "^10.0.2", 55 | "@nestjs/testing": "^10.2.6", 56 | "@swc/cli": "^0.1.62", 57 | "@swc/core": "^1.3.90", 58 | "@types/cookie-parser": "^1.4.4", 59 | "@types/express": "^4.17.18", 60 | "@types/jest": "^29.5.5", 61 | "@types/node": "^20.8.0", 62 | "@types/supertest": "^2.0.13", 63 | "@typescript-eslint/eslint-plugin": "^5.62.0", 64 | "@typescript-eslint/parser": "^5.62.0", 65 | "eslint": "^8.50.0", 66 | "eslint-config-prettier": "^9.0.0", 67 | "eslint-plugin-prettier": "^5.0.0", 68 | "jest": "^29.7.0", 69 | "prettier": "^3.0.3", 70 | "source-map-support": "^0.5.21", 71 | "supertest": "^6.3.3", 72 | "ts-jest": "^29.1.1", 73 | "ts-loader": "^9.4.4", 74 | "ts-node": "^10.9.1", 75 | "tsconfig-paths": "^4.2.0", 76 | "typescript": "^5.2.2" 77 | }, 78 | "jest": { 79 | "moduleFileExtensions": [ 80 | "js", 81 | "json", 82 | "ts" 83 | ], 84 | "rootDir": "src", 85 | "testRegex": ".*\\.spec\\.ts$", 86 | "transform": { 87 | "^.+\\.(t|j)s$": "ts-jest" 88 | }, 89 | "collectCoverageFrom": [ 90 | "**/*.(t|j)s" 91 | ], 92 | "coverageDirectory": "../coverage", 93 | "testEnvironment": "node" 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /backend/src/app.config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsArray, 3 | IsBoolean, 4 | IsNumber, 5 | IsString, 6 | IsOptional, 7 | validateSync, 8 | } from "class-validator"; 9 | import { Transform, plainToInstance } from "class-transformer"; 10 | import { Algorithm } from "jsonwebtoken"; 11 | 12 | export class AppConfig { 13 | 14 | @IsString() 15 | readonly NODE_ENV: string; 16 | 17 | @IsString() 18 | readonly APP_ADDRESS: string; 19 | 20 | @IsNumber() 21 | readonly APP_PORT: number; 22 | 23 | @IsArray() 24 | @Transform(({ value }) => value.split(",")) 25 | readonly CORS_ALLOWED_HEADERS: string[]; 26 | 27 | @IsBoolean() 28 | readonly CORS_CREDENTIALS: boolean; 29 | 30 | @IsArray() 31 | @Transform(({ value }) => value.split(",")) 32 | readonly CORS_METHODS: string[]; 33 | 34 | @IsString() 35 | readonly CORS_ORIGIN: string; 36 | 37 | @IsBoolean() 38 | readonly TELEGRAM_TEST_SERVER: boolean; 39 | 40 | @IsBoolean() 41 | readonly GRAPHQL_ENABLE_IDE: boolean; 42 | 43 | @IsBoolean() 44 | readonly GRAPHQL_ENABLE_INTROSPECTION: boolean; 45 | 46 | @IsString() 47 | readonly MONGODB_URI: string; 48 | 49 | @IsString() 50 | readonly BOT_TOKEN: string; 51 | 52 | @IsString() 53 | @IsOptional() 54 | readonly BOT_WEBHOOK_DOMAIN?: string; 55 | 56 | @IsString() 57 | @IsOptional() 58 | readonly BOT_WEBHOOK_PATH?: string; 59 | 60 | @IsString() 61 | @IsOptional() 62 | readonly BOT_WEBHOOK_SECRET_TOKEN?: string; 63 | 64 | @IsString() 65 | readonly DOMAIN: string; 66 | 67 | @IsString() 68 | readonly JWT_ALGORITHM: Algorithm; 69 | 70 | @IsString() 71 | readonly JWT_EXPIRES_IN: string; 72 | 73 | @IsString() 74 | readonly JWT_SECRET: string; 75 | 76 | @IsString() 77 | readonly BOT_MINIAPP_LINK: string; 78 | } 79 | 80 | export function validateAppConfig(config: Record) { 81 | const validatedConfig = plainToInstance(AppConfig, config, { 82 | enableImplicitConversion: true, 83 | }); 84 | const errors = validateSync(validatedConfig, { 85 | skipMissingProperties: false, 86 | }); 87 | 88 | if (errors.length > 0) { 89 | throw new Error(errors.toString()); 90 | } 91 | return validatedConfig; 92 | } 93 | -------------------------------------------------------------------------------- /backend/src/app.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | ExecutionContext, 4 | NestInterceptor, 5 | CallHandler, 6 | } from '@nestjs/common'; 7 | import { Observable } from 'rxjs'; 8 | 9 | 10 | @Injectable() 11 | export class AppInterceptor implements NestInterceptor { 12 | intercept(context: ExecutionContext, next: CallHandler): Observable { 13 | const contextType = context.getType() as string; 14 | const httpContext = context.switchToHttp(); 15 | const request = httpContext.getRequest(); 16 | 17 | if (contextType === 'telegraf') { 18 | request.t = request.i18nContext.t.bind(request.i18nContext); 19 | } 20 | 21 | return next.handle(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /backend/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { ApolloDriver, ApolloDriverConfig } from "@nestjs/apollo"; 2 | import { ApolloServerPlugin } from "@apollo/server"; 3 | import { ApolloServerPluginLandingPageLocalDefault } from "@apollo/server/plugin/landingPage/default"; 4 | import { ConfigModule, ConfigService } from "@nestjs/config"; 5 | import { Context, Telegraf } from "telegraf"; 6 | import { 7 | DateTimeTypeDefinition, 8 | DateTimeResolver, 9 | JSONDefinition, 10 | JSONResolver, 11 | } from "graphql-scalars"; 12 | import { GraphQLModule } from "@nestjs/graphql"; 13 | import { I18nModule, I18nYamlLoader } from "nestjs-i18n-telegraf"; 14 | import { Logger, LoggerModule } from "nestjs-pino"; 15 | import { Module } from "@nestjs/common"; 16 | import { MongooseModule } from "@nestjs/mongoose"; 17 | import { NoSchemaIntrospectionCustomRule } from "graphql"; 18 | import { TelegrafModule } from "nestjs-telegraf"; 19 | import path, { join } from "path"; 20 | import { AppConfig, validateAppConfig } from "./app.config"; 21 | import { ArticlesModule } from "./articles/articles.module"; 22 | import { AuthModule } from "./auth/auth.module"; 23 | import { BotModule } from "./bot/bot.module"; 24 | import { TelegrafResolver } from "./i18n/i18n.resolver"; 25 | 26 | @Module({ 27 | imports: [ 28 | LoggerModule.forRoot(), 29 | ConfigModule.forRoot({ 30 | isGlobal: true, 31 | validate: validateAppConfig, 32 | }), 33 | GraphQLModule.forRootAsync({ 34 | driver: ApolloDriver, 35 | imports: [ConfigModule], 36 | useFactory: (configService: ConfigService) => { 37 | let plugins: ApolloServerPlugin[] = []; 38 | 39 | if ( 40 | configService.get( 41 | "GRAPHQL_ENABLE_IDE", 42 | ) 43 | ) { 44 | plugins.push(ApolloServerPluginLandingPageLocalDefault({})); 45 | } 46 | 47 | const validationRules: any[] = []; 48 | if ( 49 | !configService.get( 50 | "GRAPHQL_ENABLE_INTROSPECTION", 51 | ) 52 | ) { 53 | validationRules.push(NoSchemaIntrospectionCustomRule); 54 | } 55 | 56 | return { 57 | playground: false, 58 | autoSchemaFile: join(process.cwd(), "src/schema.gql"), 59 | sortSchema: true, 60 | plugins, 61 | validationRules, 62 | typeDefs: [DateTimeTypeDefinition, JSONDefinition], 63 | resolvers: { DateTime: DateTimeResolver, JSON: JSONResolver }, 64 | cors: { 65 | credentials: true, 66 | origin: "*", 67 | }, 68 | context: ({ req, res }) => ({ req, res }), 69 | }; 70 | }, 71 | inject: [ConfigService], 72 | }), 73 | MongooseModule.forRootAsync({ 74 | imports: [ConfigModule], 75 | useFactory: (configService: ConfigService) => { 76 | return { 77 | uri: configService.get("MONGODB_URI"), 78 | autoCreate: true, 79 | autoIndex: true, 80 | }; 81 | }, 82 | inject: [ConfigService], 83 | }), 84 | I18nModule.forRoot({ 85 | fallbackLanguage: "en", 86 | loaderOptions: { 87 | path: path.join(__dirname, "/i18n/locales/"), 88 | watch: true, 89 | }, 90 | logging: true, 91 | loader: I18nYamlLoader, 92 | resolvers: [TelegrafResolver], 93 | typesOutputPath: path.join(__dirname, "../src/i18n/i18n.generated.ts"), 94 | disableMiddleware: true, 95 | }), 96 | TelegrafModule.forRootAsync({ 97 | imports: [ConfigModule], 98 | useFactory: (configService: ConfigService, logger: Logger) => { 99 | const TELEGRAM_TEST_SERVER = configService.get< 100 | AppConfig["TELEGRAM_TEST_SERVER"] 101 | >("TELEGRAM_TEST_SERVER")!; 102 | const BOT_TOKEN = 103 | configService.get("BOT_TOKEN")!; 104 | const BOT_WEBHOOK_DOMAIN = 105 | configService.get( 106 | "BOT_WEBHOOK_DOMAIN", 107 | ); 108 | const BOT_WEBHOOK_PATH = 109 | configService.get("BOT_WEBHOOK_PATH"); 110 | const BOT_WEBHOOK_SECRET_TOKEN = configService.get< 111 | AppConfig["BOT_WEBHOOK_SECRET_TOKEN"] 112 | >("BOT_WEBHOOK_SECRET_TOKEN"); 113 | 114 | const launchOptions: Telegraf.LaunchOptions = { 115 | dropPendingUpdates: true, 116 | allowedUpdates: ["message"], 117 | }; 118 | 119 | if (BOT_WEBHOOK_DOMAIN && BOT_WEBHOOK_PATH) { 120 | launchOptions.webhook = { 121 | domain: BOT_WEBHOOK_DOMAIN, 122 | hookPath: BOT_WEBHOOK_PATH, 123 | secretToken: BOT_WEBHOOK_SECRET_TOKEN, 124 | }; 125 | } 126 | 127 | return { 128 | token: BOT_TOKEN + (TELEGRAM_TEST_SERVER ? "/test" : ""), 129 | middlewares: [ 130 | async (ctx: Context, next) => { 131 | if ( 132 | ctx.chat === undefined || 133 | ctx.chat.type === "group" || 134 | ctx.chat.type === "supergroup" 135 | ) { 136 | return; 137 | } 138 | 139 | await next(); 140 | }, 141 | async (ctx: Context, next) => { 142 | const now = () => { 143 | const ts = process.hrtime(); 144 | return ts[0] * 1e3 + ts[1] / 1e6; 145 | }; 146 | const start = now(); 147 | 148 | await next(); 149 | 150 | const end = now(); 151 | logger.log({ 152 | update: ctx.update, 153 | responseTime: end - start, 154 | }); 155 | }, 156 | ], 157 | launchOptions: launchOptions, 158 | }; 159 | }, 160 | inject: [ConfigService, Logger], 161 | }), 162 | BotModule, 163 | AuthModule, 164 | ArticlesModule, 165 | ], 166 | }) 167 | export class AppModule {} 168 | -------------------------------------------------------------------------------- /backend/src/articles/articles.module.ts: -------------------------------------------------------------------------------- 1 | import { ConfigModule } from "@nestjs/config"; 2 | import { Module } from "@nestjs/common"; 3 | import { MongooseModule } from "@nestjs/mongoose"; 4 | import { Article, ArticleSchema } from "./models/article.model"; 5 | import { ArticlesResolver } from "./articles.resolver"; 6 | import { ArticlesService } from "./articles.service"; 7 | 8 | @Module({ 9 | imports: [ 10 | ConfigModule, 11 | MongooseModule.forFeature([{ name: Article.name, schema: ArticleSchema }]), 12 | ], 13 | providers: [ArticlesResolver, ArticlesService], 14 | }) 15 | export class ArticlesModule {} 16 | -------------------------------------------------------------------------------- /backend/src/articles/articles.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Args, Mutation, Query, Resolver } from "@nestjs/graphql"; 2 | import { ConfigService } from "@nestjs/config"; 3 | import { ObjectId } from "mongodb"; 4 | import { UseGuards } from "@nestjs/common"; 5 | import { AppConfig } from "../app.config"; 6 | import { ArchiveArticleInput } from "./dto/archive-article.input"; 7 | import { Article } from "./entities/article.entity"; 8 | import { ArticleInput } from "./dto/article.input"; 9 | import { ArticlesInput } from "./dto/articles.input"; 10 | import { ArticlesService } from "./articles.service"; 11 | import { CreateArticleInput } from "./dto/create-article.input"; 12 | import { InjectJwtSubject } from "../auth/auth.decorators"; 13 | import { JwtAuthGuard } from "../auth/auth.guard"; 14 | import { JwtSubject } from "../auth/strategies/jwt.strategy"; 15 | import { UpdateArticleInput } from "./dto/update-article.input"; 16 | 17 | @Resolver() 18 | export class ArticlesResolver { 19 | constructor( 20 | private readonly articlesService: ArticlesService, 21 | private readonly configService: ConfigService, 22 | ) {} 23 | 24 | @UseGuards(JwtAuthGuard) 25 | @Mutation(() => Article) 26 | async createArticle( 27 | @Args("input") input: CreateArticleInput, 28 | @InjectJwtSubject() user: JwtSubject, 29 | ) { 30 | return await this.articlesService.createArticle({ 31 | content: input.content, 32 | premiumContent: input.premiumContent, 33 | title: input.title, 34 | author: user.id, 35 | }); 36 | } 37 | 38 | @UseGuards(JwtAuthGuard) 39 | @Mutation(() => Boolean) 40 | async updateArticle( 41 | @Args("input") input: UpdateArticleInput, 42 | @InjectJwtSubject() user: JwtSubject, 43 | ) { 44 | await this.articlesService.updateArticle({ 45 | _id: new ObjectId(input.id), 46 | title: input.title, 47 | content: input.content, 48 | premiumContent: input.premiumContent, 49 | author: user.id, 50 | }); 51 | return true 52 | } 53 | 54 | @UseGuards(JwtAuthGuard) 55 | @Mutation(() => Boolean) 56 | async archiveArticle( 57 | @Args("input") input: ArchiveArticleInput, 58 | @InjectJwtSubject() user: JwtSubject, 59 | ) { 60 | await this.articlesService.archiveArticle({ 61 | _id: new ObjectId(input.id), 62 | author: user.id, 63 | }); 64 | return true 65 | } 66 | 67 | @UseGuards(JwtAuthGuard) 68 | @Query(() => [Article], { name: "articles" }) 69 | async getArticles( 70 | @Args("input") input: ArticlesInput, 71 | @InjectJwtSubject() user: JwtSubject, 72 | ) { 73 | return await this.articlesService.getMyArticles(user.id, input.archived); 74 | } 75 | 76 | @UseGuards(JwtAuthGuard) 77 | @Query(() => Article, { name: "article", nullable: true }) 78 | async getArticle( 79 | @Args("input") input: ArticleInput, 80 | @InjectJwtSubject() user: JwtSubject, 81 | ) { 82 | if (input.view === true) { 83 | let isPremium = user.is_premium ?? false; 84 | 85 | if ( 86 | this.configService.get("NODE_ENV") === 87 | "development" 88 | ) { 89 | isPremium = true; 90 | } 91 | 92 | return await this.articlesService.getArticle( 93 | new ObjectId(input.id), 94 | isPremium, 95 | ); 96 | } 97 | 98 | return await this.articlesService.getMyArticle( 99 | user.id, 100 | new ObjectId(input.id), 101 | ); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /backend/src/articles/articles.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import { InjectModel } from "@nestjs/mongoose"; 3 | import { Model, ProjectionType, UpdateQuery } from "mongoose"; 4 | import { ObjectId } from "mongodb"; 5 | import { Article } from "./models/article.model"; 6 | 7 | interface CreateArticleParams { 8 | author: number; 9 | title: string; 10 | content?: object; 11 | premiumContent?: object; 12 | } 13 | 14 | interface UpdateArticleParams extends CreateArticleParams { 15 | _id: ObjectId; 16 | } 17 | 18 | interface ArchiveArticleParams { 19 | _id: ObjectId; 20 | author: number; 21 | } 22 | 23 | @Injectable() 24 | export class ArticlesService { 25 | constructor( 26 | @InjectModel(Article.name) private readonly articleModel: Model
, 27 | ) {} 28 | 29 | async createArticle(params: CreateArticleParams) { 30 | const article = await this.articleModel.create({ 31 | ...params, 32 | views: 0, 33 | premiumViews: 0, 34 | archived: false, 35 | createdAt: new Date(), 36 | updatedAt: new Date(), 37 | }); 38 | return article; 39 | } 40 | 41 | async updateArticle(params: UpdateArticleParams) { 42 | return await this.articleModel 43 | .updateOne( 44 | { _id: params._id, author: params.author }, 45 | { 46 | title: params.title, 47 | content: params.content, 48 | premiumContent: params.premiumContent, 49 | updatedAt: new Date(), 50 | }, 51 | ) 52 | .lean(); 53 | } 54 | 55 | async archiveArticle(params: ArchiveArticleParams) { 56 | return await this.articleModel 57 | .updateOne( 58 | { _id: params._id, author: params.author }, 59 | { 60 | archived: true, 61 | updatedAt: new Date(), 62 | }, 63 | ) 64 | .lean(); 65 | } 66 | 67 | async getMyArticles(author: number, archived: boolean) { 68 | return await this.articleModel.find({ author, archived }, {}, {sort: {_id: -1}}).lean(); 69 | } 70 | 71 | async getMyArticle(author: number, _id: ObjectId) { 72 | return await this.articleModel.findOne({ _id, author }).lean(); 73 | } 74 | 75 | async getArticle(_id: ObjectId, premium?: boolean) { 76 | const update: UpdateQuery
= { $inc: { views: 1 } }; 77 | let projection: ProjectionType
= {}; 78 | 79 | if (premium === true) { 80 | update.$inc = { premiumViews: 1 }; 81 | } else { 82 | projection.premiumContent = 0; 83 | } 84 | 85 | return await this.articleModel 86 | .findOneAndUpdate({ _id, archived: false }, update, { 87 | projection, 88 | }) 89 | .lean(); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /backend/src/articles/dto/archive-article.input.ts: -------------------------------------------------------------------------------- 1 | import { InputType, Field } from '@nestjs/graphql'; 2 | 3 | @InputType() 4 | export class ArchiveArticleInput { 5 | 6 | @Field(() => String) 7 | id: string; 8 | } 9 | -------------------------------------------------------------------------------- /backend/src/articles/dto/article.input.ts: -------------------------------------------------------------------------------- 1 | import { InputType, Field } from '@nestjs/graphql'; 2 | 3 | @InputType() 4 | export class ArticleInput { 5 | @Field(() => String) 6 | id: string; 7 | 8 | @Field(() => Boolean, { nullable: true }) 9 | view: boolean 10 | } 11 | -------------------------------------------------------------------------------- /backend/src/articles/dto/articles.input.ts: -------------------------------------------------------------------------------- 1 | import { InputType, Field } from '@nestjs/graphql'; 2 | 3 | @InputType() 4 | export class ArticlesInput { 5 | @Field(() => Boolean) 6 | archived: boolean; 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/articles/dto/create-article.input.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLJSON } from 'graphql-scalars'; 2 | import { InputType, Field } from '@nestjs/graphql'; 3 | 4 | @InputType() 5 | export class CreateArticleInput { 6 | @Field(() => String) 7 | title: string; 8 | 9 | @Field(() => GraphQLJSON, { nullable: true }) 10 | content?: object; 11 | 12 | @Field(() => GraphQLJSON, { nullable: true }) 13 | premiumContent?: object; 14 | } 15 | -------------------------------------------------------------------------------- /backend/src/articles/dto/update-article.input.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLJSON } from "graphql-scalars"; 2 | import { InputType, Field } from "@nestjs/graphql"; 3 | 4 | @InputType() 5 | export class UpdateArticleInput { 6 | @Field(() => String) 7 | id: string; 8 | 9 | @Field(() => String) 10 | title: string; 11 | 12 | @Field(() => GraphQLJSON, { nullable: true }) 13 | content?: object; 14 | 15 | @Field(() => GraphQLJSON, { nullable: true }) 16 | premiumContent?: object; 17 | } 18 | -------------------------------------------------------------------------------- /backend/src/articles/entities/article.entity.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLJSON } from 'graphql-scalars'; 2 | import { ObjectType, Field } from '@nestjs/graphql'; 3 | import { Schema } from 'mongoose'; 4 | 5 | @ObjectType() 6 | export class Article { 7 | @Field(() => String, { name: 'id' }) 8 | _id: Schema.Types.ObjectId; 9 | 10 | @Field() 11 | title: string; 12 | 13 | @Field(() => GraphQLJSON, {nullable: true}) 14 | content: object; 15 | 16 | @Field(() => GraphQLJSON, {nullable: true}) 17 | premiumContent: object; 18 | 19 | @Field() 20 | views: number; 21 | 22 | @Field() 23 | premiumViews: number; 24 | 25 | @Field() 26 | createdAt: Date; 27 | 28 | @Field() 29 | updatedAt: Date; 30 | } 31 | -------------------------------------------------------------------------------- /backend/src/articles/models/article.model.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; 2 | import mongoose from "mongoose"; 3 | import mongooseLong from "mongoose-long"; 4 | 5 | mongooseLong(mongoose); 6 | 7 | export type ArticleDocument = mongoose.HydratedDocument
; 8 | 9 | @Schema() 10 | export class Article { 11 | @Prop({ type: String, required: true }) 12 | title: string; 13 | 14 | @Prop({ type: Object }) 15 | content?: object; 16 | 17 | @Prop({ type: Object }) 18 | premiumContent?: object; 19 | 20 | @Prop({ type: mongoose.Types.Long, required: true }) 21 | author: number; 22 | 23 | @Prop({ type: Number }) 24 | views: number; 25 | 26 | @Prop({ type: Number }) 27 | premiumViews: number; 28 | 29 | @Prop({ type: Boolean, required: true, index: true }) 30 | archived: boolean; 31 | 32 | @Prop({ type: Date }) 33 | updatedAt: Date; 34 | 35 | @Prop({ type: Date }) 36 | createdAt: Date; 37 | } 38 | export const ArticleSchema = SchemaFactory.createForClass(Article); 39 | -------------------------------------------------------------------------------- /backend/src/auth/auth.decorators.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext, } from '@nestjs/common'; 2 | import { GqlExecutionContext } from '@nestjs/graphql'; 3 | 4 | 5 | export const InjectJwtSubject = createParamDecorator( 6 | (target: object, context: ExecutionContext) => { 7 | const ctx = GqlExecutionContext.create(context); 8 | return ctx.getContext().req?.user; 9 | }, 10 | ); 11 | -------------------------------------------------------------------------------- /backend/src/auth/auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { AuthGuard } from "@nestjs/passport"; 2 | import { ContextType, ExecutionContext, Injectable } from "@nestjs/common"; 3 | import { GqlExecutionContext } from "@nestjs/graphql"; 4 | 5 | @Injectable() 6 | export class JwtAuthGuard extends AuthGuard("jwt") { 7 | getRequest(context: ExecutionContext) { 8 | const contextType = context.getType() as ContextType | "graphql"; 9 | 10 | if (contextType === "graphql") { 11 | const ctx = GqlExecutionContext.create(context); 12 | return ctx.getContext().req; 13 | } 14 | 15 | return context.switchToHttp().getRequest(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /backend/src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { JwtModule } from "@nestjs/jwt"; 2 | import { Module } from "@nestjs/common"; 3 | import { MongooseModule } from "@nestjs/mongoose"; 4 | import { AppConfig } from "../app.config"; 5 | import { AuthResolver } from "./auth.resolver"; 6 | import { AuthService } from "./auth.service"; 7 | import { ConfigModule, ConfigService } from "@nestjs/config"; 8 | import { JwtStrategy } from "./strategies/jwt.strategy"; 9 | import { User, UserSchema } from "./models/user.model"; 10 | 11 | @Module({ 12 | imports: [ 13 | MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]), 14 | JwtModule.registerAsync({ 15 | imports: [ConfigModule], 16 | useFactory: (configService: ConfigService) => { 17 | const secret = configService.get("JWT_SECRET")!; 18 | const algorithm = 19 | configService.get("JWT_ALGORITHM")!; 20 | const expiresIn = 21 | configService.get("JWT_EXPIRES_IN")!; 22 | 23 | return { 24 | secret: secret, 25 | signOptions: { 26 | algorithm, 27 | expiresIn, 28 | }, 29 | verifyOptions: { 30 | algorithms: [algorithm], 31 | ignoreExpiration: false, 32 | }, 33 | }; 34 | }, 35 | inject: [ConfigService], 36 | }), 37 | ], 38 | providers: [JwtStrategy, AuthResolver, AuthService], 39 | }) 40 | export class AuthModule {} 41 | -------------------------------------------------------------------------------- /backend/src/auth/auth.resolver.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from "@nestjs/config"; 2 | import { Resolver, Mutation, Args, Context, Query, GraphQLExecutionContext, } from "@nestjs/graphql"; 3 | import { UseGuards } from "@nestjs/common"; 4 | import dayjs from "dayjs"; 5 | import { AppConfig } from "../app.config"; 6 | import { AuthenticateInput } from "./dto/authenticate.input"; 7 | import { AuthService } from "./auth.service"; 8 | import { InjectJwtSubject } from "./auth.decorators"; 9 | import { JwtAuthGuard } from "./auth.guard"; 10 | import { JwtSubject } from "./strategies/jwt.strategy"; 11 | import { User } from "./entities/user.entity"; 12 | 13 | 14 | @Resolver() 15 | export class AuthResolver { 16 | constructor( 17 | private readonly configService: ConfigService, 18 | private readonly authService: AuthService, 19 | ) {} 20 | 21 | @UseGuards(JwtAuthGuard) 22 | @Query(() => User, { name: "user" }) 23 | async getUser(@InjectJwtSubject() user: JwtSubject) { 24 | return this.authService.getOrCreateUser(user); 25 | } 26 | 27 | @Mutation(() => Boolean) 28 | async authenticate( 29 | @Args("input") input: AuthenticateInput, 30 | @Context() context: GraphQLExecutionContext, 31 | ) { 32 | try { 33 | const { user, auth_date } = this.authService.validateMiniAppInitData( 34 | input.initDataRaw, 35 | ); 36 | 37 | if ( 38 | (auth_date == null || 39 | dayjs.unix(auth_date).isAfter(dayjs().add(1, "minute"))) && 40 | this.configService.get("NODE_ENV") !== 41 | "development" 42 | ) { 43 | return false; 44 | } 45 | 46 | const jwt = await this.authService.createAccessToken({ 47 | id: user?.id, 48 | first_name: user?.first_name, 49 | username: user?.username, 50 | is_premium: user?.is_premium, 51 | language_code: user?.language_code, 52 | allows_write_to_pm: user?.allows_write_to_pm, 53 | start_param: user?.start_param, 54 | }); 55 | const domain = this.configService.get("DOMAIN")!; 56 | 57 | // @ts-expect-error Response injects to context using `context: ({ req, res }) => ({ req, res })` 58 | // in GraphQLModule.forRootAsync configuration 59 | context?.res?.cookie("miniapp_jwt", jwt, { 60 | httpOnly: true, 61 | secure: process.env.NODE_ENV === "production", 62 | maxAge: 1000 * 60 * 60 * 1, 63 | domain, 64 | }); 65 | return true; 66 | } catch { 67 | return false; 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /backend/src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from "@nestjs/config"; 2 | import { Injectable } from "@nestjs/common"; 3 | import { InjectModel } from "@nestjs/mongoose"; 4 | import { JwtService } from "@nestjs/jwt"; 5 | import { Model } from "mongoose"; 6 | import { AppConfig } from "../app.config"; 7 | import { InitData, createWebAppSecret, decodeInitData, verifyTelegramWebAppInitData, } from "./auth.utils"; 8 | import { JwtPayload, JwtSubject } from "./strategies/jwt.strategy"; 9 | import { User } from "./models/user.model"; 10 | 11 | @Injectable() 12 | export class AuthService { 13 | constructor( 14 | private readonly configService: ConfigService, 15 | private readonly jwtService: JwtService, 16 | @InjectModel(User.name) private readonly userModel: Model 17 | ) {} 18 | 19 | 20 | validateMiniAppInitData(raw: string): InitData { 21 | const token = this.configService.get("BOT_TOKEN")!; 22 | const initData = decodeInitData(raw); 23 | const secretKey = createWebAppSecret(token); 24 | 25 | if (!verifyTelegramWebAppInitData(initData, secretKey)) { 26 | throw new Error("Invalid init data"); 27 | } 28 | 29 | return initData; 30 | } 31 | 32 | async createAccessToken(user: JwtPayload["sub"]): Promise { 33 | const payload: JwtPayload = { sub: user }; 34 | return await this.jwtService.signAsync(payload); 35 | } 36 | 37 | async getOrCreateUser(user: JwtSubject) { 38 | return await this.userModel.findOneAndUpdate({_id: user.id}, { 39 | $set: { 40 | firstName: user.first_name, 41 | lastName: user.last_name, 42 | username: user.username, 43 | isPremium: user.is_premium, 44 | languageCode: user.language_code, 45 | allowsWriteToPm: user.allows_write_to_pm 46 | } 47 | }, { 48 | upsert: true, 49 | }).lean() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /backend/src/auth/auth.utils.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from "crypto"; 2 | 3 | export interface InitData { 4 | query_id: string | undefined; 5 | user: Record | undefined; 6 | auth_date: number | undefined; 7 | start_param: string | undefined; 8 | chat_instance: string | undefined; 9 | chat_type: string | undefined; 10 | hash: string | undefined; 11 | } 12 | 13 | export function createWebAppSecret(token: string): Buffer { 14 | return crypto.createHmac("sha256", "WebAppData").update(token).digest(); 15 | } 16 | 17 | export function decodeInitData(initDataRaw: string): InitData { 18 | const params = new URLSearchParams(initDataRaw); 19 | 20 | const userParam = params.get("user"); 21 | let userObj; 22 | if (userParam) { 23 | userObj = JSON.parse(userParam); 24 | } 25 | 26 | const queryId = params.get("query_id"); 27 | const authDate = parseInt(params.get("auth_date")!); 28 | const startParam = params.get("start_param"); 29 | const chatInstance = params.get("chat_instance"); 30 | const chatType = params.get("chat_type"); 31 | const hash = params.get("hash"); 32 | 33 | return { 34 | query_id: queryId ?? undefined, 35 | user: userObj, 36 | start_param: startParam ?? undefined, 37 | chat_instance: chatInstance ?? undefined, 38 | chat_type: chatType ?? undefined, 39 | auth_date: authDate ?? undefined, 40 | hash: hash ?? undefined, 41 | }; 42 | } 43 | 44 | export function verifyTelegramWebAppInitData( 45 | initData: InitData, 46 | secretKey: Buffer, 47 | ): boolean { 48 | const expectedHash = initData.hash; 49 | const checkList: string[] = []; 50 | for (const [k, v] of Object.entries({ 51 | query_id: initData.query_id, 52 | user: JSON.stringify(initData.user), 53 | auth_date: initData.auth_date, 54 | start_param: initData.start_param, 55 | chat_instance: initData.chat_instance, 56 | chat_type: initData.chat_type, 57 | })) { 58 | if (v) { 59 | checkList.push(`${k}=${v}`); 60 | } 61 | } 62 | 63 | const checkString: string = checkList.sort().join("\n"); 64 | 65 | const hmacHash: string = crypto 66 | .createHmac("sha256", secretKey) 67 | .update(checkString, "utf-8") 68 | .digest("hex"); 69 | 70 | return hmacHash === expectedHash; 71 | } 72 | -------------------------------------------------------------------------------- /backend/src/auth/dto/authenticate.input.ts: -------------------------------------------------------------------------------- 1 | import { InputType, Field } from '@nestjs/graphql'; 2 | 3 | @InputType() 4 | export class AuthenticateInput { 5 | @Field(() => String) 6 | initDataRaw: string; 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/auth/entities/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field } from '@nestjs/graphql'; 2 | 3 | @ObjectType() 4 | export class User { 5 | @Field(() => Number, { name: 'id' }) 6 | _id: number; 7 | 8 | @Field() 9 | firstName: string; 10 | } 11 | -------------------------------------------------------------------------------- /backend/src/auth/models/user.model.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; 2 | import mongoose from "mongoose"; 3 | import mongooseLong from "mongoose-long"; 4 | 5 | mongooseLong(mongoose); 6 | 7 | export type UserDocument = mongoose.HydratedDocument; 8 | 9 | @Schema() 10 | export class User { 11 | @Prop({ type: mongoose.Schema.Types.Long, required: true }) 12 | _id: number; 13 | 14 | @Prop({ type: String, required: true }) 15 | firstName: string; 16 | 17 | @Prop({ type: String }) 18 | lastName?: string; 19 | 20 | @Prop({ type: String }) 21 | username?: string; 22 | 23 | @Prop({ type: Boolean }) 24 | isPremium?: boolean; 25 | 26 | @Prop({ type: String, required: true }) 27 | languageCode: string; 28 | 29 | @Prop({ type: Boolean, required: true }) 30 | allowsWriteToPm: boolean; 31 | } 32 | export const UserSchema = SchemaFactory.createForClass(User); 33 | -------------------------------------------------------------------------------- /backend/src/auth/strategies/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@nestjs/config'; 2 | import { ExtractJwt, Strategy } from 'passport-jwt'; 3 | import { Injectable, Inject } from '@nestjs/common'; 4 | import { JsonWebTokenError } from 'jsonwebtoken'; 5 | import { PassportStrategy } from '@nestjs/passport'; 6 | import { AppConfig } from '../../app.config'; 7 | 8 | export interface JwtSubject { 9 | id: number; 10 | first_name: string 11 | last_name?: string 12 | username?: string 13 | is_premium?: boolean 14 | start_param?: string 15 | language_code: string 16 | allows_write_to_pm: boolean 17 | } 18 | 19 | export type JwtPayload = { 20 | sub?: JwtSubject; 21 | exp?: number; 22 | iat?: number; 23 | }; 24 | 25 | export function buildExtractorFromCookies(cookie_name: string) { 26 | return (request): string | null => { 27 | return request?.cookies[cookie_name] || null; 28 | }; 29 | } 30 | 31 | @Injectable() 32 | export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { 33 | constructor( 34 | @Inject(ConfigService) private readonly configService: ConfigService, 35 | ) { 36 | const secret = configService.get("JWT_SECRET")!; 37 | super({ 38 | secretOrKey: secret, 39 | jwtFromRequest: ExtractJwt.fromExtractors([ 40 | buildExtractorFromCookies('miniapp_jwt'), 41 | ]), 42 | }); 43 | } 44 | 45 | validate(payload: JwtPayload) { 46 | const { sub } = payload; 47 | 48 | if (sub === undefined) { 49 | throw new JsonWebTokenError('JsonWebTokenError: subject is undefined.'); 50 | } 51 | 52 | return sub; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /backend/src/bot/bot.module.ts: -------------------------------------------------------------------------------- 1 | import { BotUpdate } from './bot.update'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { Module } from '@nestjs/common'; 4 | 5 | @Module({ 6 | imports: [ConfigModule], 7 | providers: [BotUpdate], 8 | }) 9 | export class BotModule {} 10 | -------------------------------------------------------------------------------- /backend/src/bot/bot.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class BotService {} 5 | -------------------------------------------------------------------------------- /backend/src/bot/bot.update.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from "@nestjs/config"; 2 | import { Context } from "telegraf"; 3 | import { I18nContext } from "nestjs-i18n-telegraf"; 4 | import { Injectable } from "@nestjs/common"; 5 | import { Start, Update } from "nestjs-telegraf"; 6 | import { AppConfig } from "../app.config"; 7 | 8 | @Update() 9 | @Injectable() 10 | export class BotUpdate { 11 | constructor(private readonly configService: ConfigService) {} 12 | 13 | @Start() 14 | async startCommand(ctx: Context & {t: I18nContext['t']}) { 15 | const message = ctx.t('common.message', {lang: ctx.message?.from.language_code}) 16 | const button = ctx.t('common.button', {lang: ctx.message?.from.language_code}) 17 | await ctx.reply(message, { 18 | reply_markup: { 19 | inline_keyboard: [ 20 | [ 21 | { 22 | text: button, 23 | web_app: { 24 | url: this.configService.get( 25 | "BOT_MINIAPP_LINK", 26 | )!, 27 | }, 28 | }, 29 | ], 30 | ], 31 | }, 32 | }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /backend/src/i18n/i18n.generated.ts: -------------------------------------------------------------------------------- 1 | /* DO NOT EDIT, file generated by nestjs-i18n */ 2 | 3 | import { Path } from "nestjs-i18n-telegraf"; 4 | export type I18nTranslations = { 5 | "common": { 6 | "message": string; 7 | "button": string; 8 | }; 9 | }; 10 | export type I18nPath = Path; 11 | -------------------------------------------------------------------------------- /backend/src/i18n/i18n.resolver.ts: -------------------------------------------------------------------------------- 1 | import { I18nResolver } from 'nestjs-i18n-telegraf'; 2 | import { Injectable, ExecutionContext } from '@nestjs/common'; 3 | import { TelegrafExecutionContext } from 'nestjs-telegraf'; 4 | 5 | 6 | @Injectable() 7 | export class TelegrafResolver implements I18nResolver { 8 | resolve(context: ExecutionContext) { 9 | const contextType = context.getType() as string; 10 | 11 | if (contextType !== 'telegraf') { 12 | return; 13 | } 14 | 15 | const telegrafContext = TelegrafExecutionContext.create(context); 16 | const ctx = 17 | telegrafContext.getContext(); 18 | 19 | return ctx.from?.language_code; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /backend/src/i18n/locales/en/common.yml: -------------------------------------------------------------------------------- 1 | message: | 2 | 👋 Hello! Want to provide exclusive content for your premium audience on Telegram? 3 | 4 | 🚀 MiniContent will help you: 5 | 1️⃣ Create an article 6 | 2️⃣ Get a link for sharing 7 | 3️⃣ Share the link with your community! 8 | button: Open MiniContent 9 | -------------------------------------------------------------------------------- /backend/src/i18n/locales/ru/common.yml: -------------------------------------------------------------------------------- 1 | message: | 2 | 👋 Привет! Хотите предоставлять эксклюзивный контент для своей премиум аудитории в Telegram? 3 | 4 | 🚀 MiniContent поможет вам: 5 | 1️⃣ Создайте статью 6 | 2️⃣ Получить ссылку для распространения 7 | 3️⃣ Поделитесь ссылкой со своим сообществом! 8 | button: Запустить MiniContent 9 | -------------------------------------------------------------------------------- /backend/src/i18n/locales/uk/common.yml: -------------------------------------------------------------------------------- 1 | message: | 2 | 👋 Привіт! Хочете надавати ексклюзивний контент для своєї преміум аудиторії в Telegram? 3 | 4 | 🚀 MiniContent допоможе вам: 5 | 1️⃣ Створіть статтю 6 | 2️⃣ Отримати посилання для поширення 7 | 3️⃣ Поділіться посиланням зі своєю спільнотою! 8 | 9 | button: Відкрити MiniContent 10 | -------------------------------------------------------------------------------- /backend/src/main.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from "@nestjs/config"; 2 | import { getBotToken } from "nestjs-telegraf"; 3 | import { Logger } from "nestjs-pino"; 4 | import { NestFactory } from "@nestjs/core"; 5 | import { Telegraf } from "telegraf"; 6 | import cookieParser from 'cookie-parser'; 7 | import { AppConfig } from "./app.config"; 8 | import { AppInterceptor } from "./app.interceptor"; 9 | import { AppModule } from "./app.module"; 10 | 11 | async function bootstrap() { 12 | const app = await NestFactory.create(AppModule, { bufferLogs: true }); 13 | const configService = app.get(ConfigService); 14 | 15 | const address = configService.get("APP_ADDRESS")!; 16 | const port = configService.get("APP_PORT")!; 17 | 18 | app.useLogger(app.get(Logger)); 19 | app.use(cookieParser()); 20 | 21 | const allowedHeaders = configService.get( 22 | "CORS_ALLOWED_HEADERS", 23 | ); 24 | const credentials = 25 | configService.get("CORS_CREDENTIALS"); 26 | const methods = configService.get("CORS_METHODS"); 27 | const origin = configService.get("CORS_ORIGIN"); 28 | app.enableCors({ 29 | allowedHeaders, 30 | credentials, 31 | methods, 32 | origin, 33 | }); 34 | 35 | app.useGlobalInterceptors(new AppInterceptor()); 36 | 37 | const webhookPath = 38 | configService.get("BOT_WEBHOOK_PATH"); 39 | if (webhookPath) { 40 | const bot: Telegraf = app.get(getBotToken()); 41 | app.use(bot.webhookCallback(webhookPath)); 42 | } 43 | 44 | await app.listen(port, address); 45 | } 46 | bootstrap(); 47 | -------------------------------------------------------------------------------- /backend/src/schema.gql: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------ 2 | # THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY) 3 | # ------------------------------------------------------ 4 | 5 | input ArchiveArticleInput { 6 | id: String! 7 | } 8 | 9 | type Article { 10 | content: JSON 11 | createdAt: DateTime! 12 | id: String! 13 | premiumContent: JSON 14 | premiumViews: Float! 15 | title: String! 16 | updatedAt: DateTime! 17 | views: Float! 18 | } 19 | 20 | input ArticleInput { 21 | id: String! 22 | view: Boolean 23 | } 24 | 25 | input ArticlesInput { 26 | archived: Boolean! 27 | } 28 | 29 | input AuthenticateInput { 30 | initDataRaw: String! 31 | } 32 | 33 | input CreateArticleInput { 34 | content: JSON 35 | premiumContent: JSON 36 | title: String! 37 | } 38 | 39 | """ 40 | A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. 41 | """ 42 | scalar DateTime 43 | 44 | """ 45 | The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). 46 | """ 47 | scalar JSON @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf") 48 | 49 | type Mutation { 50 | archiveArticle(input: ArchiveArticleInput!): Boolean! 51 | authenticate(input: AuthenticateInput!): Boolean! 52 | createArticle(input: CreateArticleInput!): Article! 53 | updateArticle(input: UpdateArticleInput!): Boolean! 54 | } 55 | 56 | type Query { 57 | article(input: ArticleInput!): Article 58 | articles(input: ArticlesInput!): [Article!]! 59 | user: User! 60 | } 61 | 62 | input UpdateArticleInput { 63 | content: JSON 64 | id: String! 65 | premiumContent: JSON 66 | title: String! 67 | } 68 | 69 | type User { 70 | firstName: String! 71 | id: Float! 72 | } -------------------------------------------------------------------------------- /backend/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import * as request from 'supertest'; 2 | import { Test } from '@nestjs/testing'; 3 | import { AppModule } from './../src/app.module'; 4 | import { INestApplication } from '@nestjs/common'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeAll(async () => { 10 | const moduleFixture = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | afterAll(async () => { 19 | await app.close(); 20 | }); 21 | 22 | it('/ (GET)', () => { 23 | return request(app.getHttpServer()) 24 | .get('/') 25 | .expect(200) 26 | .expect('Hello World!'); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /backend/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /backend/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "esModuleInterop": true, 10 | "target": "ES2019", 11 | "sourceMap": true, 12 | "outDir": "./dist", 13 | "baseUrl": "./", 14 | "incremental": true, 15 | "skipLibCheck": true, 16 | "moduleResolution": "node", 17 | "strictNullChecks": true, 18 | "noImplicitAny": false, 19 | "strictBindCallApply": false, 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | backend: 5 | build: 6 | context: ./backend 7 | dockerfile: Dockerfile 8 | image: "backend" 9 | ports: 10 | - "127.0.0.1:3001:3001" 11 | restart: always 12 | tty: true 13 | environment: 14 | - APP_PORT=3001 15 | - APP_ADDRESS=0.0.0.0 16 | - CORS_ALLOWED_HEADERS=Content-Type,Authorization 17 | - CORS_CREDENTIALS=true 18 | - CORS_METHODS=GET,POST,OPTIONS 19 | - CORS_ORIGIN=* 20 | - TELEGRAM_TEST_SERVER= 21 | - GRAPHQL_ENABLE_IDE= 22 | - GRAPHQL_ENABLE_INTROSPECTION= 23 | - MONGODB_URI=mongodb://${MONGODB_USER}:${MONGODB_PASS}@mongodb:27017/${MONGODB_DATABASE}?authSource=admin 24 | - BOT_TOKEN=${BOT_TOKEN} 25 | - JWT_ALGORITHM=HS256 26 | - JWT_EXPIRES_IN=1h 27 | - JWT_SECRET=${JWT_SECRET} 28 | - DOMAIN=${DOMAIN} 29 | - NODE_ENV=production 30 | - BOT_MINIAPP_LINK=${BOT_MINIAPP_LINK} 31 | - BOT_WEBHOOK_DOMAIN=${BOT_WEBHOOK_DOMAIN} 32 | - BOT_WEBHOOK_PATH=${BOT_WEBHOOK_PATH} 33 | - BOT_WEBHOOK_SECRET_TOKEN=${BOT_WEBHOOK_SECRET_TOKEN} 34 | 35 | 36 | frontend: 37 | build: 38 | context: ./frontend 39 | dockerfile: Dockerfile 40 | args: 41 | NEXT_PUBLIC_GRAPHQL_ENDPOINT: ${NEXT_PUBLIC_GRAPHQL_ENDPOINT} 42 | NEXT_PUBLIC_MINI_APP_LINK: ${NEXT_PUBLIC_MINI_APP_LINK} 43 | GRAPHQL_SERVER_ENDPOINT: http://backend:3001/graphql 44 | image: "frontend" 45 | ports: 46 | - "127.0.0.1:3000:3000" 47 | restart: always 48 | tty: true 49 | 50 | mongodb: 51 | image: mongo:5.0 52 | ports: 53 | - 127.0.0.1:27077:27017 54 | volumes: 55 | - mongodb-data:/data/db 56 | environment: 57 | - MONGO_INITDB_ROOT_USERNAME=${MONGODB_USER} 58 | - MONGO_INITDB_ROOT_PASSWORD=${MONGODB_PASS} 59 | tty: true 60 | 61 | volumes: 62 | mongodb-data: 63 | -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /storybook-static 3 | /.next 4 | /out 5 | /build 6 | /dev 7 | .env.development 8 | .env.production 9 | -------------------------------------------------------------------------------- /frontend/.env.development: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_GRAPHQL_ENDPOINT=/graphql 2 | NEXT_PUBLIC_MINI_APP_LINK=https://t.me/MiniContentBot/view 3 | GRAPHQL_SERVER_ENDPOINT=http://localhost:3001/graphql 4 | -------------------------------------------------------------------------------- /frontend/.env.production: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_GRAPHQL_ENDPOINT=/graphql 2 | NEXT_PUBLIC_MINI_APP_LINK=https://t.me/MiniContentBot/view 3 | GRAPHQL_SERVER_ENDPOINT=http://localhost:3001/graphql 4 | -------------------------------------------------------------------------------- /frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "next", 4 | "next/core-web-vitals", 5 | "prettier", 6 | "plugin:@tanstack/eslint-plugin-query/recommended" 7 | ], 8 | "plugins": ["@tanstack/query"] 9 | } 10 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | -------------------------------------------------------------------------------- /frontend/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true, 6 | "bracketSameLine": false, 7 | "jsxBracketSameLine": false, 8 | "bracketSpacing": true, 9 | "printWidth": 80, 10 | "plugins": ["prettier-plugin-tailwindcss"], 11 | "overrides": [ 12 | { 13 | "files": "*.json", 14 | "options": { 15 | "singleQuote": false 16 | } 17 | }, 18 | { 19 | "files": ".*rc", 20 | "options": { 21 | "singleQuote": false, 22 | "parser": "json" 23 | } 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /frontend/@types/glodal.d.ts: -------------------------------------------------------------------------------- 1 | import { WebApp } from "@tma.js/sdk-react"; 2 | 3 | export {} 4 | 5 | declare global { 6 | 7 | interface Window { 8 | Telegram: { 9 | WebApp: WebApp 10 | }; 11 | } 12 | 13 | namespace NodeJS { 14 | interface ProcessEnv { 15 | NODE_ENV: 'development' | 'production' 16 | 17 | NEXT_PUBLIC_GRAPHQL_ENDPOINT: string 18 | NEXT_PUBLIC_MINI_APP_LINK: string 19 | GRAPHQL_SERVER_ENDPOINT: string 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /frontend/@types/gql/index.d.ts: -------------------------------------------------------------------------------- 1 | export type Maybe = T | null; 2 | export type InputMaybe = Maybe; 3 | export type Exact = { [K in keyof T]: T[K] }; 4 | export type MakeOptional = Omit & { [SubKey in K]?: Maybe }; 5 | export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; 6 | export type MakeEmpty = { [_ in K]?: never }; 7 | export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; 8 | /** All built-in and custom scalars, mapped to their actual values */ 9 | export type Scalars = { 10 | ID: { input: string; output: string; } 11 | String: { input: string; output: string; } 12 | Boolean: { input: boolean; output: boolean; } 13 | Int: { input: number; output: number; } 14 | Float: { input: number; output: number; } 15 | DateTime: { input: any; output: any; } 16 | JSON: { input: any; output: any; } 17 | }; 18 | 19 | export type ArchiveArticleInput = { 20 | id: Scalars['String']['input']; 21 | }; 22 | 23 | export type Article = { 24 | __typename?: 'Article'; 25 | content?: Maybe; 26 | createdAt: Scalars['DateTime']['output']; 27 | id: Scalars['String']['output']; 28 | premiumContent?: Maybe; 29 | premiumViews: Scalars['Float']['output']; 30 | title: Scalars['String']['output']; 31 | updatedAt: Scalars['DateTime']['output']; 32 | views: Scalars['Float']['output']; 33 | }; 34 | 35 | export type ArticleInput = { 36 | id: Scalars['String']['input']; 37 | view?: InputMaybe; 38 | }; 39 | 40 | export type ArticlesInput = { 41 | archived: Scalars['Boolean']['input']; 42 | }; 43 | 44 | export type AuthenticateInput = { 45 | initDataRaw: Scalars['String']['input']; 46 | }; 47 | 48 | export type CreateArticleInput = { 49 | content?: InputMaybe; 50 | premiumContent?: InputMaybe; 51 | title: Scalars['String']['input']; 52 | }; 53 | 54 | export type Mutation = { 55 | __typename?: 'Mutation'; 56 | archiveArticle: Scalars['Boolean']['output']; 57 | authenticate: Scalars['Boolean']['output']; 58 | createArticle: Article; 59 | updateArticle: Scalars['Boolean']['output']; 60 | }; 61 | 62 | 63 | export type MutationArchiveArticleArgs = { 64 | input: ArchiveArticleInput; 65 | }; 66 | 67 | 68 | export type MutationAuthenticateArgs = { 69 | input: AuthenticateInput; 70 | }; 71 | 72 | 73 | export type MutationCreateArticleArgs = { 74 | input: CreateArticleInput; 75 | }; 76 | 77 | 78 | export type MutationUpdateArticleArgs = { 79 | input: UpdateArticleInput; 80 | }; 81 | 82 | export type Query = { 83 | __typename?: 'Query'; 84 | article?: Maybe
; 85 | articles: Array
; 86 | user: User; 87 | }; 88 | 89 | 90 | export type QueryArticleArgs = { 91 | input: ArticleInput; 92 | }; 93 | 94 | 95 | export type QueryArticlesArgs = { 96 | input: ArticlesInput; 97 | }; 98 | 99 | export type UpdateArticleInput = { 100 | content?: InputMaybe; 101 | id: Scalars['String']['input']; 102 | premiumContent?: InputMaybe; 103 | title: Scalars['String']['input']; 104 | }; 105 | 106 | export type User = { 107 | __typename?: 'User'; 108 | firstName: Scalars['String']['output']; 109 | id: Scalars['Float']['output']; 110 | }; 111 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20.5-alpine AS base 2 | 3 | FROM base AS builder 4 | WORKDIR /app 5 | COPY package.json package-lock.json* ./ 6 | RUN npm ci 7 | COPY . ./ 8 | ENV NEXT_TELEMETRY_DISABLED 1 9 | ENV NODE_ENV production 10 | ARG NEXT_PUBLIC_GRAPHQL_ENDPOINT 11 | ENV NEXT_PUBLIC_GRAPHQL_ENDPOINT=${NEXT_PUBLIC_GRAPHQL_ENDPOINT} 12 | ARG NEXT_PUBLIC_MINI_APP_LINK 13 | ENV NEXT_PUBLIC_MINI_APP_LINK=${NEXT_PUBLIC_MINI_APP_LINK} 14 | ARG GRAPHQL_SERVER_ENDPOINT 15 | ENV GRAPHQL_SERVER_ENDPOINT=${GRAPHQL_SERVER_ENDPOINT} 16 | RUN npm run build 17 | 18 | FROM base AS runner 19 | WORKDIR /app 20 | RUN addgroup --system --gid 1001 nodejs 21 | RUN adduser --system --uid 1001 nextjs 22 | USER nextjs 23 | COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ 24 | COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 25 | ENV NEXT_TELEMETRY_DISABLED 1 26 | USER nextjs 27 | CMD ["node", "server.js"] 28 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /frontend/app/[locale]/articles/layout.tsx: -------------------------------------------------------------------------------- 1 | 2 | 3 | interface ArticlesLayoutProps { 4 | children: React.ReactNode 5 | } 6 | 7 | export default function ArticlesLayout({ 8 | children, 9 | }: ArticlesLayoutProps) { 10 | 11 | return <>{children} 12 | } 13 | -------------------------------------------------------------------------------- /frontend/app/[locale]/articles/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { 4 | Button, 5 | Dropdown, 6 | DropdownTrigger, 7 | DropdownMenu, 8 | DropdownItem, 9 | Spinner, 10 | } from '@nextui-org/react' 11 | import { useArchiveArticleMutation } from '@/services/useArchiveArticleMutation' 12 | import { useArticlesQuery } from '@/services/useArticlesQuery' 13 | import { useCreateArticleMutation } from '@/services/useCreateArticleMutation' 14 | import { useI18n } from '@/i18n/client' 15 | import { useRouter } from 'next/navigation' 16 | import { useUserQuery } from '@/services/useUserQuery' 17 | import { useMainButton } from '@tma.js/sdk-react' 18 | import { useEffect } from 'react' 19 | 20 | export default function UserPage() { 21 | const t = useI18n() 22 | const mainButton = useMainButton() 23 | const { isSuccess } = useUserQuery() 24 | const { data: articlesQuery, isPending } = useArticlesQuery({ 25 | input: { 26 | archived: false, 27 | }, 28 | }) 29 | const { mutateAsync: archiveArticle } = useArchiveArticleMutation() 30 | const { mutateAsync: createArticle } = useCreateArticleMutation() 31 | const router = useRouter() 32 | 33 | const handleCreateArticle = async () => { 34 | const response = await createArticle({ 35 | input: { 36 | title: t('new_article'), 37 | }, 38 | }) 39 | router.push('/edit/' + response.createArticle.id) 40 | mainButton.hide() 41 | mainButton.off('click', handleCreateArticle) 42 | } 43 | 44 | useEffect(() => { 45 | if (mainButton) { 46 | mainButton.setText(t('create_new_article')) 47 | mainButton.enable().show() 48 | mainButton.on('click', handleCreateArticle) 49 | } 50 | }, []) 51 | 52 | if (!isSuccess || isPending) { 53 | return ( 54 |
55 | 56 |
57 | ) 58 | } 59 | 60 | const openArticle = (articleId: string) => { 61 | router.push('/edit/' + articleId) 62 | mainButton.hide() 63 | mainButton.off('click', handleCreateArticle) 64 | } 65 | 66 | const archive = async (articleId: string) => { 67 | await archiveArticle({ 68 | input: { 69 | id: articleId, 70 | }, 71 | }) 72 | } 73 | 74 | return ( 75 |
76 | {articlesQuery?.articles.length !== 0 || ( 77 | <> 78 |
{t('no_articles')}
79 | 86 | 87 | )} 88 | {articlesQuery?.articles.map((article) => ( 89 |
90 |

openArticle(article.id)} 92 | className="mb-2 text-xl font-bold" 93 | > 94 | {article.title} 95 |

96 |
97 |

98 | {t('views')}: {article.views + article.premiumViews}/ 99 | {article.premiumViews} 100 |

101 | 102 | 103 | 106 | 107 | 108 | openArticle(article.id)} 111 | > 112 | {t('edit')} 113 | 114 | archive(article.id)} 119 | > 120 | {t('archive')} 121 | 122 | 123 | 124 |
125 |
126 | ))} 127 |
128 | ) 129 | } 130 | -------------------------------------------------------------------------------- /frontend/app/[locale]/auth/[...slug]/layout.tsx: -------------------------------------------------------------------------------- 1 | 2 | 3 | interface AuthLayoutProps { 4 | children: React.ReactNode 5 | } 6 | 7 | export default function AuthLayout({ 8 | children, 9 | }: AuthLayoutProps) { 10 | 11 | return <>{children} 12 | } 13 | -------------------------------------------------------------------------------- /frontend/app/[locale]/auth/[...slug]/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useAuthenticateMutation } from '@/services/useAuthenticateMutation' 4 | import { useWebApp, useLaunchParams, useViewport } from '@tma.js/sdk-react' 5 | import { usePathname } from 'next/navigation' 6 | import { useRouter } from 'next/navigation' 7 | import { useEffect } from 'react' 8 | 9 | export default function AuthPage() { 10 | const webApp = useWebApp() 11 | const viewport = useViewport() 12 | const pathname = usePathname() 13 | const router = useRouter() 14 | const launchParams = useLaunchParams() 15 | const { mutate: authenticate, data, isSuccess } = useAuthenticateMutation() 16 | 17 | useEffect(() => { 18 | const { initDataRaw } = launchParams 19 | if (initDataRaw) { 20 | console.log( 21 | 'Enter this into authenticate mutation in Apollo IDE for development: ' 22 | ) 23 | authenticate({ input: { initDataRaw } }) 24 | } 25 | }, [launchParams, authenticate]) 26 | 27 | useEffect(() => { 28 | if (isSuccess && data.authenticate === true && webApp) { 29 | const routeTo = pathname.substring(pathname.indexOf('/auth') + 5) 30 | const { initData } = launchParams 31 | let locale = initData?.user?.languageCode ?? '' 32 | if (!['en', 'uk', 'ru'].includes(locale)) { 33 | locale = 'en' 34 | } 35 | router.replace(locale + '/' + routeTo + '/' + (initData?.startParam ? initData.startParam : '')) 36 | webApp.ready() 37 | viewport.expand() 38 | window.Telegram = { 39 | WebApp: webApp, 40 | } 41 | } 42 | }, [isSuccess, data, webApp]) 43 | 44 | return
45 | } 46 | -------------------------------------------------------------------------------- /frontend/app/[locale]/edit/[articleId]/layout.tsx: -------------------------------------------------------------------------------- 1 | 2 | 3 | interface EditLayoutProps { 4 | children: React.ReactNode 5 | } 6 | 7 | export default function EditLayout({ 8 | children, 9 | }: EditLayoutProps) { 10 | 11 | return <>{children} 12 | } 13 | -------------------------------------------------------------------------------- /frontend/app/[locale]/edit/[articleId]/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Button, Input, Spinner, useDisclosure } from '@nextui-org/react' 4 | import { Editor } from '@tiptap/react' 5 | import { useArticleQuery } from '@/services/useArticleQuery' 6 | import { useBackButton } from '@tma.js/sdk-react' 7 | import { useEffect, useState } from 'react' 8 | import { useI18n } from '@/i18n/client' 9 | import { useRouter } from 'next/navigation' 10 | import { useUpdateArticleMutation } from '@/services/useUpdateArticleMutation' 11 | import PublishModal from '@/components/PublishModal' 12 | import Tiptap from '@/components/Tiptap' 13 | 14 | export default function EditPage({ 15 | params, 16 | }: { 17 | params: { articleId: string } 18 | }) { 19 | const t = useI18n() 20 | const { isOpen, onOpen: openPublishModal, onOpenChange } = useDisclosure() 21 | const router = useRouter() 22 | const backButton = useBackButton() 23 | backButton.show() 24 | backButton.on('click', () => { 25 | router.back() 26 | setTimeout(() => { 27 | backButton.hide() 28 | }, 100) 29 | }) 30 | const { data, isPending } = useArticleQuery({ 31 | input: { 32 | id: params.articleId, 33 | view: false, 34 | }, 35 | }) 36 | 37 | const [title, setTitle] = useState('') 38 | const [editor, setEditor] = useState() 39 | const [editorPremium, setEditorPremium] = useState() 40 | 41 | useEffect(() => { 42 | if (isPending === false && data?.article) { 43 | setTitle(data.article.title) 44 | } 45 | }, [isPending, data]) 46 | 47 | const { mutateAsync: updateArticle } = useUpdateArticleMutation() 48 | const publish = () => { 49 | updateArticle({ 50 | input: { 51 | id: params.articleId, 52 | title, 53 | content: editor?.getJSON() ?? {}, 54 | premiumContent: editorPremium?.getJSON() ?? {}, 55 | }, 56 | }) 57 | openPublishModal() 58 | } 59 | 60 | const initEditor = (editor: Editor) => { 61 | setEditor(editor) 62 | } 63 | 64 | const initEditorPremium = (editor: Editor) => { 65 | setEditorPremium(editor) 66 | } 67 | 68 | if (isPending || !data?.article) { 69 | return ( 70 |
71 | 72 |
73 | ) 74 | } 75 | 76 | return ( 77 |
78 |
79 | setTitle(e.target.value)} 86 | /> 87 |
88 | 91 | 97 | 100 | 106 | 113 | { 117 | onOpenChange() 118 | if (value === false) { 119 | router.back() 120 | setTimeout(() => { 121 | backButton.hide() 122 | }, 100) 123 | } 124 | }} 125 | /> 126 |
127 | ) 128 | } 129 | -------------------------------------------------------------------------------- /frontend/app/[locale]/layout.tsx: -------------------------------------------------------------------------------- 1 | import '@/styles/globals.scss' 2 | import type { Metadata } from 'next' 3 | import { Providers } from './providers' 4 | 5 | 6 | export const metadata: Metadata = { 7 | title: 'Create Next App', 8 | description: 'Generated by create next app', 9 | viewport: 'width=device-width, initial-scale=1, maximum-scale=1', 10 | } 11 | 12 | export default function RootLayout({ 13 | children, 14 | }: { 15 | children: React.ReactNode 16 | }) { 17 | return ( 18 | 19 | 20 | 21 | {children} 22 | 23 | 24 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /frontend/app/[locale]/page.tsx: -------------------------------------------------------------------------------- 1 | import {Button} from '@nextui-org/button'; 2 | 3 | export default function Home() { 4 | return ( 5 |
6 | 7 |
8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /frontend/app/[locale]/providers.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import { SDKProvider, SDKInitOptions } from '@tma.js/sdk-react' 5 | import { NextUIProvider } from '@nextui-org/react' 6 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 7 | 8 | import { SDKLoader } from '@/components/SDKLoader' 9 | import { I18nProviderClient } from '@/i18n/client' 10 | 11 | 12 | export interface ProvidersProps { 13 | children: React.ReactNode 14 | } 15 | 16 | export function Providers({ children }: ProvidersProps) { 17 | const sdkInitOptions: SDKInitOptions = { 18 | acceptScrollbarStyle: true, 19 | checkCompat: true, 20 | cssVars: true, 21 | } 22 | 23 | const queryClient = new QueryClient() 24 | 25 | return ( 26 | <> 27 | 28 | 29 | 30 | 31 | {children} 32 | 33 | 34 | 35 | 36 | 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /frontend/app/[locale]/view/[articleId]/layout.tsx: -------------------------------------------------------------------------------- 1 | 2 | 3 | interface ViewLayoutProps { 4 | children: React.ReactNode 5 | } 6 | 7 | export default function ViewLayout({ 8 | children, 9 | }: ViewLayoutProps) { 10 | 11 | return <>{children} 12 | } 13 | -------------------------------------------------------------------------------- /frontend/app/[locale]/view/[articleId]/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Spinner } from '@nextui-org/react' 4 | import { useArticleQuery } from '@/services/useArticleQuery' 5 | import { useI18n } from '@/i18n/client' 6 | import Tiptap from '@/components/Tiptap' 7 | 8 | export default function ViewPage({ 9 | params, 10 | }: { 11 | params: { articleId: string } 12 | }) { 13 | const t = useI18n() 14 | const { data, isPending } = useArticleQuery({ 15 | input: { 16 | id: params.articleId, 17 | view: true, 18 | }, 19 | }) 20 | 21 | if (isPending === false && data?.article === null) { 22 | return ( 23 |
24 | {t('article_not_found')} 25 |
26 | ) 27 | } 28 | 29 | 30 | if (isPending || !data?.article) { 31 | return ( 32 |
33 | 34 |
35 | ) 36 | } 37 | 38 | 39 | return ( 40 |
41 |
42 |

{data.article.title}

43 |
44 | 45 | {data.article.premiumContent !== null ? ( 46 | 47 | ) : ( 48 |
49 | 🚫 {t('buy_premium')} 50 |
51 | )} 52 |
53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /frontend/codegen.ts: -------------------------------------------------------------------------------- 1 | import type { CodegenConfig } from '@graphql-codegen/cli' 2 | 3 | const config: CodegenConfig = { 4 | overwrite: true, 5 | schema: 'http://127.0.0.1:3001/graphql', 6 | generates: { 7 | './@types/gql/index.d.ts': { 8 | plugins: ['typescript'] 9 | }, 10 | }, 11 | } 12 | export default config 13 | -------------------------------------------------------------------------------- /frontend/components/CustomLink.tsx: -------------------------------------------------------------------------------- 1 | import Link from '@tiptap/extension-link' 2 | 3 | 4 | export const CustomLink = Link.extend({ 5 | priority: 1000, 6 | 7 | addOptions() { 8 | return { 9 | openOnClick: true, 10 | linkOnPaste: true, 11 | autolink: false, 12 | protocols: [], 13 | HTMLAttributes: { 14 | target: '_blank', 15 | rel: 'noopener noreferrer nofollow', 16 | onclick: null, 17 | class: null, 18 | }, 19 | validate: undefined, 20 | } 21 | }, 22 | 23 | addAttributes() { 24 | return { 25 | href: { 26 | default: null, 27 | }, 28 | target: { 29 | default: this.options.HTMLAttributes.target, 30 | }, 31 | rel: { 32 | default: this.options.HTMLAttributes.rel, 33 | }, 34 | class: { 35 | default: this.options.HTMLAttributes.class, 36 | }, 37 | onclick: { 38 | default: this.options.HTMLAttributes.onclick, 39 | }, 40 | } 41 | }, 42 | }) 43 | -------------------------------------------------------------------------------- /frontend/components/PublishModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useI18n } from '@/i18n/client' 3 | import { 4 | Modal, 5 | ModalContent, 6 | ModalHeader, 7 | ModalBody, 8 | ModalFooter, 9 | Button, 10 | Input, 11 | } from '@nextui-org/react' 12 | 13 | interface ModalProps { 14 | onOpenChange: (isOpen: boolean) => void 15 | isOpen: boolean 16 | articleId: string 17 | } 18 | 19 | export default function PublishModal(props: ModalProps) { 20 | const t = useI18n() 21 | const miniAppUrl = process.env.NEXT_PUBLIC_MINI_APP_LINK + '?startapp=' 22 | return ( 23 |
24 | 29 | 30 | {(onClose) => ( 31 | <> 32 | {t('publicated')} 33 | 34 | 41 | 42 | 43 | 52 | 53 | 54 | )} 55 | 56 | 57 |
58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /frontend/components/SDKLoader.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { PropsWithChildren} from 'react' 4 | import { useSDK } from '@tma.js/sdk-react' 5 | 6 | /** 7 | * This component is the layer controlling the application display. It displays 8 | * application in case, the SDK is initialized, displays an error if something 9 | * went wrong, and a loader if the SDK is warming up. 10 | */ 11 | export function SDKLoader({ children }: PropsWithChildren<{}>) { 12 | const { didInit, components, error } = useSDK() 13 | 14 | if (!didInit || components === null) { 15 | return <> 16 | } 17 | 18 | if (error !== null) { 19 | return
Something went wrong.
20 | } 21 | 22 | return <>{children} 23 | } 24 | -------------------------------------------------------------------------------- /frontend/components/SetLinkModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { 3 | Modal, 4 | ModalContent, 5 | ModalHeader, 6 | ModalBody, 7 | ModalFooter, 8 | Button, 9 | Input, 10 | Checkbox, 11 | } from '@nextui-org/react' 12 | import { useI18n } from '@/i18n/client' 13 | 14 | interface ModalProps { 15 | onOpenChange: (isOpen: boolean) => void 16 | isOpen: boolean 17 | onLinkSet: (url: string, instant: boolean) => void 18 | } 19 | 20 | export default function SetLinkModal(props: ModalProps) { 21 | const t = useI18n() 22 | const [link, setLink] = useState('') 23 | const [instant, setInstant] = useState(false) 24 | 25 | const onChange = (event: React.ChangeEvent) => { 26 | setLink(event.target.value) 27 | if (event.target.value.startsWith('https://telegra.ph/')) { 28 | setInstant(true) 29 | } 30 | } 31 | 32 | return ( 33 |
34 | { 39 | props.onLinkSet(link, instant) 40 | setLink('') 41 | setInstant(false) 42 | }} 43 | > 44 | 45 | {(onClose) => ( 46 | <> 47 | 48 | {t('create_link')} 49 | 50 | 51 | 52 | 53 | 54 | {t('instant_view')} 55 | 58 | 61 | 62 | 63 | )} 64 | 65 | 66 |
67 | ) 68 | } 69 | -------------------------------------------------------------------------------- /frontend/components/Tiptap.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { 4 | useEditor, 5 | EditorContent, 6 | FloatingMenu, 7 | BubbleMenu, 8 | Editor, 9 | } from '@tiptap/react' 10 | import { Button, useDisclosure } from '@nextui-org/react' 11 | import { CustomLink } from './CustomLink' 12 | import Placeholder from '@tiptap/extension-placeholder' 13 | import React, { useCallback, useEffect } from 'react' 14 | import SetLinkModal from './SetLinkModal' 15 | import StarterKit from '@tiptap/starter-kit' 16 | 17 | interface TiptapProps { 18 | className?: string 19 | isEditable?: boolean 20 | placeholder?: string 21 | content?: string 22 | onInit?: (editor: Editor) => void 23 | } 24 | 25 | const Tiptap = (props: TiptapProps) => { 26 | const { isOpen, onOpen: openSetLinkModal, onOpenChange } = useDisclosure() 27 | 28 | const editor = useEditor({ 29 | extensions: [ 30 | StarterKit, 31 | Placeholder.configure({ 32 | placeholder: props.placeholder ?? '', 33 | showOnlyWhenEditable: false, 34 | }), 35 | CustomLink.configure({ 36 | openOnClick: !props.isEditable, 37 | autolink: !props.isEditable, 38 | }), 39 | ], 40 | editorProps: { 41 | attributes: { 42 | class: 43 | 'rounded-md p-2 prose dark:prose-invert prose-sm sm:prose-base lg:prose-lg xl:prose-2xl focus:outline-none bg-default-100 data-[hover=true]:bg-default-200 group-data-[focus=true]:bg-default-100 !h-auto outline-none', 44 | }, 45 | }, 46 | content: props.content ?? '', 47 | }) 48 | 49 | const [isEditable] = React.useState(props.isEditable ?? false) 50 | 51 | useEffect(() => { 52 | if (editor) { 53 | editor.setEditable(isEditable) 54 | if (props.onInit) { 55 | props.onInit(editor) 56 | } 57 | } 58 | }, [isEditable, editor, props]) 59 | 60 | const setLink = useCallback( 61 | (url: string, instant: boolean) => { 62 | if (!editor) { 63 | return null 64 | } 65 | 66 | if (url === '') { 67 | editor.chain().focus().extendMarkRange('link').unsetLink().run() 68 | return 69 | } 70 | 71 | if (instant) { 72 | editor 73 | .chain() 74 | .focus() 75 | .extendMarkRange('link') 76 | .setLink({ 77 | // @ts-expect-error custom link with instant view 78 | href: null, 79 | onclick: `window.Telegram.WebApp.openLink('${url}', true); return false;`, 80 | target: null, 81 | rel: null, 82 | class: 'instant', 83 | }) 84 | .run() 85 | } else { 86 | editor 87 | .chain() 88 | .focus() 89 | .extendMarkRange('link') 90 | .setLink({ href: url }) 91 | .run() 92 | } 93 | }, 94 | [editor] 95 | ) 96 | 97 | return ( 98 | <> 99 | {editor && ( 100 | 104 | 111 | 118 | 125 | 132 | 139 | 140 | )} 141 | {editor && ( 142 | 143 | 154 | 165 | 172 | 179 | 180 | )} 181 | 182 | 187 | 188 | ) 189 | } 190 | 191 | export default Tiptap 192 | -------------------------------------------------------------------------------- /frontend/environment/client.ts: -------------------------------------------------------------------------------- 1 | export const getClientEnv = () => { 2 | return { 3 | graphqlEndpoint: process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT, 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /frontend/i18n/client.ts: -------------------------------------------------------------------------------- 1 | import { createI18nClient } from 'next-international/client' 2 | 3 | export const { useI18n, useScopedI18n, I18nProviderClient, useChangeLocale } = createI18nClient({ 4 | en: () => import('./locales/en'), 5 | uk: () => import('./locales/uk'), 6 | ru: () => import('./locales/ru'), 7 | }) 8 | -------------------------------------------------------------------------------- /frontend/i18n/locales/en.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | 'create_new_article': 'Create new article', 3 | 'views': 'Views(all/premium)', 4 | 'menu': 'Menu', 5 | 'edit': 'Edit', 6 | 'archive': 'Archive', 7 | 'new_article': 'New article', 8 | 'content_for_all': 'Content for all users', 9 | 'content_for_premium': 'Content for only Premium users', 10 | 'publish': 'Publish', 11 | 'enter_premium_content': 'Enter content for Premium users', 12 | 'enter_anyone_content': 'Enter content for all users', 13 | 'article_title': 'Article title', 14 | 'create_link': 'Create link', 15 | 'close': 'Close', 16 | 'set_link': 'Set', 17 | 'copy_link': 'Copy link', 18 | 'link_to_article': 'Link to article', 19 | 'article_not_found': 'Article not found', 20 | 'buy_premium': 'Buy Teleram Premium to view all content', 21 | 'publicated': 'Publicated', 22 | 'enter_link': 'Enter link', 23 | 'instant_view': 'Instant view', 24 | 'no_articles': 'No articles yet', 25 | 'enter_article_title': 'Enter article title', 26 | } as const 27 | -------------------------------------------------------------------------------- /frontend/i18n/locales/ru.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | 'create_new_article': 'Создать новую статью', 3 | 'views': 'Просмотры(все/премиум)', 4 | 'menu': 'Меню', 5 | 'edit': 'Редактировать', 6 | 'archive': 'Архивировать', 7 | 'new_article': 'Новая статья', 8 | 'content_for_all': 'Содержание для всех пользователей', 9 | 'content_for_premium': 'Содержание для только Premium пользователей', 10 | 'publish': 'Опубликовать', 11 | 'enter_premium_content': 'Введите содержание для Premium пользователей', 12 | 'enter_anyone_content': 'Введите содержание для всех пользователей', 13 | 'article_title': 'Название статьи', 14 | 'create_link': 'Создать ссылку', 15 | 'close': 'Закрыть', 16 | 'set_link': 'Создать', 17 | 'copy_link': 'Копировать ссылку', 18 | 'link_to_article': 'Ссылка на статью', 19 | 'article_not_found': 'Статья не найдена', 20 | 'buy_premium': 'Купите Teleram Premium, чтобы просмотреть все содержание', 21 | 'publicated': 'Опубликовано', 22 | 'enter_link': 'Введите ссылку', 23 | 'instant_view': 'Instant view', 24 | 'no_articles': 'Пока нет статей', 25 | 'enter_article_title': 'Введите название статьи' 26 | } as const 27 | -------------------------------------------------------------------------------- /frontend/i18n/locales/uk.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | 'create_new_article': 'Створити нову статтю', 3 | 'views': 'Перегляди(всі/преміум)', 4 | 'menu': 'Меню', 5 | 'edit': 'Редагувати', 6 | 'archive': 'Архівувати', 7 | 'new_article': 'Нова стаття', 8 | 'content_for_all': 'Вміст для всіх користувачів', 9 | 'content_for_premium': 'Вміст для преміум користувачів', 10 | 'publish': 'Опублікувати', 11 | 'enter_premium_content': 'Введіть вміст для преміум користувачів', 12 | 'enter_anyone_content': 'Введіть вміст для всіх користувачів', 13 | 'article_title': 'Назва статті', 14 | 'create_link': 'Створити посилання', 15 | 'close': 'Закрити', 16 | 'set_link': 'Створити', 17 | 'copy_link': 'Копіювати посилання', 18 | 'link_to_article': 'Посилання на статтю', 19 | 'article_not_found': 'Стаття не знайдена', 20 | 'buy_premium': 'Придбайте Teleram Premium, щоб переглянути повний вміст', 21 | 'publicated': 'Опубліковано', 22 | 'enter_link': 'Введіть посилання', 23 | 'instant_view': 'Instant view', 24 | 'no_articles': 'Поки намає жодної статті', 25 | 'enter_article_title': 'Введіть назву статті' 26 | } as const 27 | -------------------------------------------------------------------------------- /frontend/i18n/server.ts: -------------------------------------------------------------------------------- 1 | import { createI18nServer } from 'next-international/server' 2 | 3 | export const { getI18n, getScopedI18n, getStaticParams } = createI18nServer({ 4 | en: () => import('./locales/en'), 5 | uk: () => import('./locales/uk'), 6 | ru: () => import('./locales/ru'), 7 | }) 8 | -------------------------------------------------------------------------------- /frontend/middleware.ts: -------------------------------------------------------------------------------- 1 | import { createI18nMiddleware } from 'next-international/middleware' 2 | import { NextRequest } from 'next/server' 3 | 4 | const I18nMiddleware = createI18nMiddleware({ 5 | locales: ['en', 'uk', 'ru'], 6 | defaultLocale: 'en', 7 | }) 8 | 9 | export function middleware(request: NextRequest) { 10 | return I18nMiddleware(request) 11 | } 12 | 13 | export const config = { 14 | matcher: ['/((?!graphql|api|static|.*\\..*|_next|favicon.ico|robots.txt).*)'] 15 | } 16 | -------------------------------------------------------------------------------- /frontend/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | swcMinify: true, 5 | output: 'standalone', 6 | async rewrites() { 7 | return [ 8 | { 9 | source: '/graphql', 10 | destination: process.env.GRAPHQL_SERVER_ENDPOINT, 11 | }, 12 | ] 13 | }, 14 | }; 15 | 16 | export default nextConfig; 17 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mini-content-frontend", 3 | "private": true, 4 | "version": "1.0.0", 5 | "license": "MIT", 6 | "scripts": { 7 | "dev": "next dev", 8 | "build": "next build", 9 | "start": "next start", 10 | "lint": "next lint", 11 | "graphql-codegen": "graphql-codegen" 12 | }, 13 | "dependencies": { 14 | "@nextui-org/react": "^2.1.13", 15 | "@tanstack/react-query": "^5.0.0-rc.2", 16 | "@tiptap/extension-bubble-menu": "^2.1.11", 17 | "@tiptap/extension-floating-menu": "^2.1.11", 18 | "@tiptap/extension-link": "^2.1.11", 19 | "@tiptap/extension-placeholder": "^2.1.11", 20 | "@tiptap/pm": "^2.1.11", 21 | "@tiptap/react": "^2.1.11", 22 | "@tiptap/starter-kit": "^2.1.11", 23 | "@tma.js/sdk-react": "^0.4.3", 24 | "cssnano": "^6.0.1", 25 | "dayjs": "^1.11.10", 26 | "encoding": "^0.1.13", 27 | "framer-motion": "^10.16.4", 28 | "graphql": "^16.8.1", 29 | "graphql-request": "^6.1.0", 30 | "jotai": "^2.4.3", 31 | "next": "13.4.1", 32 | "next-international": "^1.0.1", 33 | "react": "latest", 34 | "react-aria": "^3.29.0", 35 | "react-dom": "latest", 36 | "react-use": "^17.4.0", 37 | "sass": "^1.68.0" 38 | }, 39 | "devDependencies": { 40 | "@graphql-codegen/cli": "^5.0.0", 41 | "@graphql-codegen/typescript": "^4.0.1", 42 | "@tailwindcss/typography": "^0.5.10", 43 | "@tanstack/eslint-plugin-query": "^4.34.1", 44 | "@types/node": "^20.7.0", 45 | "@types/react": "latest", 46 | "@types/react-dom": "latest", 47 | "autoprefixer": "^10.4.16", 48 | "eslint": "latest", 49 | "eslint-config-next": "latest", 50 | "eslint-config-prettier": "^9.0.0", 51 | "postcss": "^8.4.30", 52 | "prettier": "^3.0.3", 53 | "prettier-plugin-tailwindcss": "^0.5.4", 54 | "tailwindcss": "^3.3.3", 55 | "typescript": "latest" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /frontend/services/graphql/client.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLClient } from 'graphql-request' 2 | import { getClientEnv } from '@/environment/client' 3 | 4 | export const graphqlClient = new GraphQLClient(getClientEnv().graphqlEndpoint) 5 | -------------------------------------------------------------------------------- /frontend/services/keyFactory.ts: -------------------------------------------------------------------------------- 1 | import { QueryArticleArgs, QueryArticlesArgs } from "@/@types/gql" 2 | 3 | export const queryKeyFactory = { 4 | article: (args: QueryArticleArgs) => ['article', args], 5 | articles: (args: QueryArticlesArgs) => ['articles', args], 6 | user: () => ['user'], 7 | } 8 | 9 | export const mutationKeyFactory = { 10 | authenticate: () => ['authenticate'], 11 | createArticle: () => ['createArticle'], 12 | updateArticle: () => ['updateArticle'], 13 | archiveArticle: () => ['archiveArticle'], 14 | } 15 | -------------------------------------------------------------------------------- /frontend/services/useArchiveArticleMutation.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-request' 2 | import { graphqlClient } from '@/services/graphql/client' 3 | import { useMutation, useQueryClient } from '@tanstack/react-query' 4 | import { 5 | Article, 6 | Mutation, 7 | MutationArchiveArticleArgs, 8 | Query, 9 | } from '@/@types/gql' 10 | import { mutationKeyFactory, queryKeyFactory } from './keyFactory' 11 | 12 | const ARCHIVE_ARTICLE_MUTTATION = gql` 13 | mutation ($input: ArchiveArticleInput!) { 14 | archiveArticle(input: $input) 15 | } 16 | ` 17 | 18 | export async function archiveArticle(args: MutationArchiveArticleArgs) { 19 | return await graphqlClient.request( 20 | ARCHIVE_ARTICLE_MUTTATION, 21 | args 22 | ) 23 | } 24 | 25 | export function useArchiveArticleMutation() { 26 | const queryClient = useQueryClient() 27 | return useMutation({ 28 | mutationKey: mutationKeyFactory.archiveArticle(), 29 | mutationFn: archiveArticle, 30 | gcTime: 0, 31 | onSuccess: (data, variables) => { 32 | const key = queryKeyFactory.articles({ input: { archived: false } }) 33 | const currentTodos = queryClient.getQueryData(key) 34 | if (currentTodos) { 35 | queryClient.setQueryData(key, (oldData: Query | undefined) => { 36 | return { 37 | articles: oldData?.articles.filter((article: Article) => { 38 | return article.id !== variables.input.id 39 | }), 40 | } as any 41 | }) 42 | } 43 | }, 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /frontend/services/useArticleQuery.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-request' 2 | import { graphqlClient } from '@/services/graphql/client' 3 | import { Query, QueryArticleArgs } from '@/@types/gql' 4 | import { useQuery } from '@tanstack/react-query' 5 | import { queryKeyFactory } from './keyFactory' 6 | 7 | const ARTICLE_QUERY = gql` 8 | query ($input: ArticleInput!) { 9 | article(input: $input) { 10 | id 11 | title 12 | content 13 | premiumContent 14 | views 15 | premiumViews 16 | updatedAt 17 | createdAt 18 | } 19 | } 20 | ` 21 | 22 | export async function fetchArticle(args: QueryArticleArgs) { 23 | return await graphqlClient.request(ARTICLE_QUERY, args) 24 | } 25 | 26 | export function useArticleQuery(args: QueryArticleArgs) { 27 | return useQuery({ 28 | queryKey: queryKeyFactory.article(args), 29 | queryFn: () => fetchArticle(args), 30 | gcTime: 0 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /frontend/services/useArticlesQuery.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-request' 2 | import { graphqlClient } from '@/services/graphql/client' 3 | import { Query, QueryArticlesArgs } from '@/@types/gql' 4 | import { useQuery } from '@tanstack/react-query' 5 | import { queryKeyFactory } from './keyFactory' 6 | 7 | const ARTICLES_QUERY = gql` 8 | query ($input: ArticlesInput!) { 9 | articles(input: $input) { 10 | id 11 | title 12 | content 13 | premiumContent 14 | views 15 | premiumViews 16 | updatedAt 17 | createdAt 18 | } 19 | } 20 | ` 21 | 22 | export async function fetchArticles(args: QueryArticlesArgs) { 23 | return await graphqlClient.request(ARTICLES_QUERY, args) 24 | } 25 | 26 | export function useArticlesQuery(args: QueryArticlesArgs) { 27 | return useQuery({ 28 | queryKey: queryKeyFactory.articles(args), 29 | queryFn: () => fetchArticles(args), 30 | gcTime: 0 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /frontend/services/useAuthenticateMutation.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-request' 2 | import { graphqlClient } from '@/services/graphql/client' 3 | import { useMutation } from '@tanstack/react-query' 4 | import { Mutation, MutationAuthenticateArgs } from '@/@types/gql' 5 | import { mutationKeyFactory } from './keyFactory' 6 | 7 | const AUTHENTICATE_MUTTATION = gql` 8 | mutation ($input: AuthenticateInput!) { 9 | authenticate(input: $input) 10 | } 11 | ` 12 | 13 | export async function authenticate(args: MutationAuthenticateArgs) { 14 | return await graphqlClient.request( 15 | AUTHENTICATE_MUTTATION, 16 | args 17 | ) 18 | } 19 | 20 | export function useAuthenticateMutation() { 21 | return useMutation< 22 | Mutation, 23 | unknown, 24 | MutationAuthenticateArgs 25 | >({ 26 | mutationKey: mutationKeyFactory.authenticate(), 27 | mutationFn: authenticate, 28 | gcTime: 0 29 | }) 30 | } 31 | 32 | 33 | -------------------------------------------------------------------------------- /frontend/services/useCreateArticleMutation.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-request' 2 | import { graphqlClient } from '@/services/graphql/client' 3 | import { useMutation } from '@tanstack/react-query' 4 | import { Mutation, MutationCreateArticleArgs } from '@/@types/gql' 5 | import { mutationKeyFactory } from './keyFactory' 6 | 7 | const CREATE_ARTICLE_MUTTATION = gql` 8 | mutation ($input: CreateArticleInput!) { 9 | createArticle(input: $input) { 10 | id 11 | title 12 | content 13 | premiumContent 14 | views 15 | premiumViews 16 | updatedAt 17 | createdAt 18 | } 19 | } 20 | ` 21 | 22 | export async function createArticle(args: MutationCreateArticleArgs) { 23 | return await graphqlClient.request( 24 | CREATE_ARTICLE_MUTTATION, 25 | args 26 | ) 27 | } 28 | 29 | export function useCreateArticleMutation() { 30 | return useMutation< 31 | Mutation, 32 | unknown, 33 | MutationCreateArticleArgs 34 | >({ 35 | mutationKey: mutationKeyFactory.createArticle(), 36 | mutationFn: createArticle, 37 | gcTime: 0 38 | }) 39 | } 40 | 41 | 42 | -------------------------------------------------------------------------------- /frontend/services/useUpdateArticleMutation.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-request' 2 | import { graphqlClient } from '@/services/graphql/client' 3 | import { useMutation } from '@tanstack/react-query' 4 | import { Mutation, MutationUpdateArticleArgs } from '@/@types/gql' 5 | import { mutationKeyFactory } from './keyFactory' 6 | 7 | 8 | const UPDATE_ARTICLE_MUTTATION = gql` 9 | mutation ($input: UpdateArticleInput!) { 10 | updateArticle(input: $input) 11 | } 12 | ` 13 | 14 | export async function updateArticle(args: MutationUpdateArticleArgs) { 15 | return await graphqlClient.request( 16 | UPDATE_ARTICLE_MUTTATION, 17 | args 18 | ) 19 | } 20 | 21 | export function useUpdateArticleMutation() { 22 | return useMutation< 23 | Mutation, 24 | unknown, 25 | MutationUpdateArticleArgs 26 | >({ 27 | mutationKey: mutationKeyFactory.updateArticle(), 28 | mutationFn: updateArticle, 29 | gcTime: 0 30 | }) 31 | } 32 | 33 | 34 | -------------------------------------------------------------------------------- /frontend/services/useUserQuery.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-request' 2 | import { graphqlClient } from '@/services/graphql/client' 3 | import { Query } from '@/@types/gql' 4 | import { useQuery } from '@tanstack/react-query' 5 | import { queryKeyFactory } from './keyFactory' 6 | 7 | const USER_QUERY = gql` 8 | query { 9 | user { 10 | id 11 | firstName 12 | } 13 | } 14 | ` 15 | 16 | export async function fetchUser() { 17 | return await graphqlClient.request(USER_QUERY) 18 | } 19 | 20 | export function useUserQuery() { 21 | return useQuery({ 22 | queryKey: queryKeyFactory.user(), 23 | queryFn: fetchUser, 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /frontend/styles/globals.scss: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .tiptap { 6 | min-height: 100px; 7 | 8 | > * + * { 9 | margin-top: 0.75em; 10 | } 11 | 12 | ul, 13 | ol { 14 | padding: 0 1rem; 15 | } 16 | 17 | ul li::marker { 18 | display: list-item; 19 | font-weight: bold; 20 | } 21 | } 22 | 23 | .tiptap p.is-editor-empty:first-child::before { 24 | color: #adb5bd; 25 | content: attr(data-placeholder); 26 | float: left; 27 | height: 0; 28 | pointer-events: none; 29 | } 30 | 31 | .tiptap h1 { 32 | font-size: 18px; 33 | font-weight: bold; 34 | line-height: 2; 35 | } 36 | 37 | .tiptap h2 { 38 | font-size: 16px; 39 | font-weight: bold; 40 | line-height: 1.5; 41 | } 42 | 43 | .tiptap code { 44 | background-color: #6161611a; 45 | color: #616161; 46 | } 47 | 48 | .tiptap pre { 49 | background: #0d0d0d; 50 | border-radius: 0.5rem; 51 | color: #fff; 52 | font-family: JetBrainsMono, monospace; 53 | padding: 0.75rem 1rem; 54 | } 55 | 56 | .tiptap pre code { 57 | background: none; 58 | color: inherit; 59 | font-size: 0.8rem; 60 | padding: 0; 61 | } 62 | 63 | .tiptap mark { 64 | background-color: #faf594; 65 | } 66 | 67 | .tiptap hr { 68 | margin: 1rem 0; 69 | } 70 | 71 | .tiptap blockquote { 72 | border-left: 2px solid rgba(13, 13, 13, 0.1); 73 | padding-left: 1rem; 74 | } 75 | 76 | .tiptap hr { 77 | border: none; 78 | border-top: 2px solid rgba(13, 13, 13, 0.1); 79 | margin: 2rem 0; 80 | } 81 | 82 | ul { 83 | list-style-type: disc !important; 84 | list-style: disc; 85 | line-height: 1; 86 | } 87 | 88 | ol { 89 | list-style-type: decimal; 90 | list-style: decimal !important; 91 | line-height: 1; 92 | } 93 | 94 | .tiptap a { 95 | color: #0089c7; 96 | text-decoration-color: #a2dffb; 97 | text-decoration: underline; 98 | } 99 | -------------------------------------------------------------------------------- /frontend/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss' 2 | import { nextui } from '@nextui-org/react' 3 | 4 | const config: Config = { 5 | content: [ 6 | './pages/**/*.{js,ts,jsx,tsx,mdx}', 7 | './components/**/*.{js,ts,jsx,tsx,mdx}', 8 | './app/**/*.{js,ts,jsx,tsx,mdx}', 9 | "./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}" 10 | ], 11 | theme: { 12 | extend: { 13 | backgroundImage: { 14 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 15 | 'gradient-conic': 16 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', 17 | }, 18 | }, 19 | }, 20 | darkMode: "class", 21 | plugins: [nextui(), require('@tailwindcss/typography')] 22 | } 23 | export default config 24 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "node", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | }, 24 | "forceConsistentCasingInFileNames": true 25 | }, 26 | "include": [ 27 | "next-env.d.ts", 28 | "**/*.ts", 29 | "**/*.tsx", 30 | ".next/types/**/*.ts", 31 | "postcss.config.js" 32 | ], 33 | "exclude": ["node_modules"] 34 | } 35 | --------------------------------------------------------------------------------