├── .vscode └── settings.json ├── LICENSE ├── README.md ├── backend ├── .env.example ├── .eslintrc.cjs ├── .gitignore ├── Dockerfile ├── package.json ├── src │ ├── api │ │ ├── Server.ts │ │ ├── controllers │ │ │ ├── Controller.ts │ │ │ └── GenerateController.ts │ │ └── routes │ │ │ ├── GenerateRoute.ts │ │ │ ├── Route.ts │ │ │ └── index.ts │ ├── app.ts │ ├── constants │ │ ├── config.ts │ │ └── progress.ts │ ├── generator │ │ ├── animations │ │ │ └── wrap.ts │ │ ├── assets │ │ │ ├── archive.png │ │ │ ├── ending.png │ │ │ ├── logo.png │ │ │ ├── moneyCount.png │ │ │ ├── mostRecentGIFs.png │ │ │ ├── mostUsedWords.png │ │ │ ├── noSticker.png │ │ │ ├── opening.png │ │ │ ├── summary.png │ │ │ ├── topEmojis.png │ │ │ ├── topGames.png │ │ │ └── topStickers.png │ │ ├── extractor │ │ │ └── extract.ts │ │ ├── index.ts │ │ └── output │ │ │ ├── image10.png │ │ │ ├── image11.png │ │ │ ├── image3.png │ │ │ ├── image4.png │ │ │ ├── image5.png │ │ │ ├── image7.png │ │ │ ├── image9.png │ │ │ └── ok.txt │ ├── types │ │ └── global.ts │ └── utils │ │ └── Logger.ts ├── tsconfig.json └── uploads │ └── .gitkeep ├── docker-compose.yml └── frontend ├── .eslintrc.cjs ├── .gitignore ├── Dockerfile ├── package.json ├── postcss.config.cjs ├── src ├── Home.tsx ├── assets │ ├── clouds.svg │ ├── icon.ico │ └── icon.webp ├── custom.d.ts ├── demo │ ├── Demo.tsx │ └── index.html ├── generate │ ├── Generate.tsx │ └── index.html ├── index.css ├── index.html ├── public │ └── demo.mp4 └── vite-env.d.ts ├── tailwind.config.cjs ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": true 4 | }, 5 | "eslint.validate": [ 6 | "javascript" 7 | ], 8 | "git.ignoreLimitWarning": true 9 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Discord Wrapped 3 |

4 | 5 |

6 | An insight on all the data collected by Discord, formed into a video just like Spotify Wrapped! 7 |

8 | 9 | # Try it out 10 | You can try it out on [discordwrapped.com](https://discordwrapped.com). 11 | 12 | # Credits 13 | - [Face](https://github.com/face-hh) & [AssassiN](https://github.com/Assassin-1234) for starting the project 14 | - [Iliannnn](https://github.com/Iliannnn) for cleaning up the code and creating the website 15 | - [RedVortexDev](https://github.com/RedVortexDev) for redesign #1 16 | - [Arnav](https://github.com/arnav-kr) for redesign #2 17 | - [Zyztem](https://github.com/Zyztem) for dockerizing the project 18 | 19 | # Self-hosting 20 | 21 | ## Prerequisites: 22 | - Node.js and npm installed on your machine. 23 | 24 | ## Development 25 | 26 | ```bash 27 | git clone https://github.com/Assassin-1234/discord-wrapped.git 28 | cd discord-wrapped 29 | cd frontend 30 | npm install 31 | npm run dev 32 | cd .. 33 | cd backend 34 | npm install 35 | npm run dev 36 | ``` 37 | 38 | Open your web browser and go to [localhost:5173](http://) or the URL specified in the frontend logs if the default port is already in use. 39 | 40 | ## Production 41 | 42 | ### Docker 🐳 43 | ```bash 44 | git clone https://github.com/Assassin-1234/discord-wrapped.git 45 | cd discord-wrapped 46 | ``` 47 | 48 | Edit `.env.example` in the backend directory to your preference. 49 | 50 | ```bash 51 | docker-compose up -d 52 | ``` 53 | 54 | Using a reverse proxy, put the backend and frontend on the same address by adding a custom address `/api/` with the chosen backend port (3020 by default). 55 | 56 | ### Process Manager 57 | ```bash 58 | cd frontend 59 | npm install 60 | npm run build 61 | # Serve the build with your chosen process manager (e.g., http-server) 62 | cd .. 63 | cd backend 64 | mv .env.example .env 65 | npm install 66 | npm run build 67 | # Run with your chosen process manager 68 | xvfb-run -s "-ac -screen 0 1920x1080x24" npm run start 69 | ``` 70 | 71 | Using a reverse proxy, put the backend and frontend on the same address by adding a custom address `/api/` with the chosen backend port (3020 by default). 72 | 73 | # Contributing 74 | Pull requests are appreciated 75 | - Do not modify the `audio.mp3` 76 | - Do not push your own data package 77 | 78 | # License 79 | This OSS project is under an Apache license. 80 | 81 | # Contributors 82 | 83 | ![image](https://contrib.rocks/image?repo=Assassin-1234/discord-wrapped) 84 | -------------------------------------------------------------------------------- /backend/.env.example: -------------------------------------------------------------------------------- 1 | APP_NAME="discord-wrapped-backend" 2 | APP_URL="discordwrapped.com" 3 | APP_ENV="production" 4 | APP_PORT=3020 -------------------------------------------------------------------------------- /backend/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | module.exports = { 3 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], 4 | parser: '@typescript-eslint/parser', 5 | plugins: ['@typescript-eslint'], 6 | root: true, 7 | rules: { 8 | 'brace-style': ['error', '1tbs', { 'allowSingleLine': true }], 9 | 'comma-dangle': ['error', 'always-multiline'], 10 | 'comma-spacing': 'error', 11 | 'comma-style': 'error', 12 | 'curly': ['error', 'multi-line', 'consistent'], 13 | 'dot-location': ['error', 'property'], 14 | 'handle-callback-err': 'off', 15 | 'indent': ['error', 'tab'], 16 | 'max-nested-callbacks': ['error', { 'max': 4 }], 17 | 'max-statements-per-line': ['error', { 'max': 2 }], 18 | 'no-console': 'off', 19 | 'no-empty-function': 'off', 20 | '@typescript-eslint/no-empty-function': 'off', 21 | 'no-floating-decimal': 'error', 22 | 'no-inline-comments': 'error', 23 | 'no-lonely-if': 'error', 24 | 'no-multi-spaces': 'error', 25 | 'no-multiple-empty-lines': ['error', { 'max': 2, 'maxEOF': 1, 'maxBOF': 0 }], 26 | 'no-shadow': ['error', { 'allow': ['err', 'resolve', 'reject'] }], 27 | 'no-trailing-spaces': ['error'], 28 | 'no-var': 'error', 29 | 'object-curly-spacing': ['error', 'always'], 30 | 'prefer-const': 'error', 31 | 'quotes': ['error', 'single'], 32 | 'semi': ['error', 'always'], 33 | 'space-before-blocks': 'error', 34 | 'space-before-function-paren': ['error', { 35 | 'anonymous': 'never', 36 | 'named': 'never', 37 | 'asyncArrow': 'always', 38 | }], 39 | 'space-in-parens': 'error', 40 | 'space-infix-ops': 'error', 41 | 'space-unary-ops': 'error', 42 | 'spaced-comment': 'error', 43 | 'yoda': 'error', 44 | '@typescript-eslint/no-explicit-any': ['off'], 45 | }, 46 | }; -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | # Build 2 | dist 3 | 4 | # Dependencies 5 | node_modules 6 | 7 | # Environment 8 | .env 9 | keys 10 | 11 | # Yarn & Npm 12 | yarn.lock 13 | yarn-error.log 14 | package-lock.json 15 | 16 | # Ffmpeg 17 | ffmpeg.exe 18 | ffprobe.exe -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts 2 | 3 | WORKDIR /backend 4 | 5 | ENV DEBIAN_FRONTEND noninteractive 6 | 7 | RUN apt-get update && apt-get install -y \ 8 | dumb-init \ 9 | xvfb \ 10 | ffmpeg 11 | 12 | RUN rm /bin/sh && ln -s /bin/bash /bin/sh 13 | 14 | RUN apt-get update \ 15 | && apt-get install -y curl \ 16 | && apt-get -y autoclean 17 | 18 | ENV NODE_ENV "production" 19 | 20 | RUN apt-get update && apt-get -qqy install \ 21 | build-essential python3 python-is-python3 libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev libxi-dev libglu1-mesa-dev libglew-dev ffmpeg 22 | 23 | COPY package.json /backend/ 24 | COPY tsconfig.json /backend/ 25 | RUN npm install -g typescript@4.9.5 && npm install --location=project 26 | 27 | COPY . /backend/ 28 | RUN npm run build 29 | COPY .env.example .env 30 | RUN rm -rf /backend/Dockerfile /backend/.env.example 31 | 32 | EXPOSE 3020 33 | ENTRYPOINT ["/usr/bin/dumb-init", "--", "xvfb-run", "--server-args", "-screen 0 1280x1024x24 -ac"] 34 | CMD ["npm", "run", "start"] -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "discord-wrapped-backend", 3 | "version": "1.0.0", 4 | "description": "Discord Wrapped backend", 5 | "main": "src/app.ts", 6 | "author": "Iliannnn", 7 | "license": "Apache-2.0", 8 | "scripts": { 9 | "start": "node dist/app.js", 10 | "dev": "nodemon src/app.ts --watch src", 11 | "build": "tsc" 12 | }, 13 | "devDependencies": { 14 | "eslint": "^8.36.0", 15 | "nodemon": "^2.0.16", 16 | "ts-node": "^10.7.0", 17 | "typescript": "^4.9.5" 18 | }, 19 | "dependencies": { 20 | "@types/cors": "^2.8.12", 21 | "@types/express": "^4.17.13", 22 | "@types/fluent-ffmpeg": "^2.1.21", 23 | "@types/morgan": "^1.9.3", 24 | "@types/multer": "^1.4.7", 25 | "@types/node": "^17.0.31", 26 | "@types/sharp": "^0.31.1", 27 | "@types/ws": "^8.5.4", 28 | "@typescript-eslint/eslint-plugin": "^5.55.0", 29 | "@typescript-eslint/parser": "^5.55.0", 30 | "axios": "^1.3.4", 31 | "canvas": "^2.11.0", 32 | "cors": "^2.8.5", 33 | "dotenv": "^16.0.0", 34 | "editly": "^0.14.2", 35 | "express": "^4.18.1", 36 | "fluent-ffmpeg": "^2.1.2", 37 | "fs": "^0.0.1-security", 38 | "github-emoji": "^1.2.0", 39 | "helmet": "^5.1.0", 40 | "moment": "^2.29.3", 41 | "morgan": "^1.10.0", 42 | "multer": "^1.4.5-lts.1", 43 | "node-stream-zip": "^1.15.0", 44 | "path": "^0.12.7", 45 | "sharp": "^0.31.3", 46 | "snake-case": "^3.0.4", 47 | "ws": "^8.13.0" 48 | }, 49 | "optionalDependencies": { 50 | "bufferutil": "^4.0.7", 51 | "utf-8-validate": "^6.0.3" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /backend/src/api/Server.ts: -------------------------------------------------------------------------------- 1 | import Express, { Application } from 'express'; 2 | import { initRoutes } from './routes'; 3 | import { IConfig } from '../types/global'; 4 | 5 | /** 6 | * Server 7 | */ 8 | class Server { 9 | 10 | private _app: Application = Express(); 11 | public config: ServerOptions; 12 | 13 | constructor(options: ServerOptions) { 14 | this.config = options; 15 | initRoutes(this); 16 | } 17 | 18 | /** 19 | * Get Express application 20 | */ 21 | public get application(): Application { 22 | return this._app; 23 | } 24 | } 25 | 26 | export interface ServerOptions 27 | extends IConfig { 28 | prefix: string; 29 | } 30 | 31 | export default Server; -------------------------------------------------------------------------------- /backend/src/api/controllers/Controller.ts: -------------------------------------------------------------------------------- 1 | import Logger from '../../utils/Logger'; 2 | 3 | /** 4 | * Controller 5 | */ 6 | class Controller { 7 | 8 | /** 9 | * Node logger 10 | */ 11 | protected Logger: typeof Logger = Logger; 12 | } 13 | 14 | export default Controller; -------------------------------------------------------------------------------- /backend/src/api/controllers/GenerateController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import Controller from './Controller'; 3 | import wrap from '../../generator'; 4 | import { renameSync } from 'fs'; 5 | import WebSocket from 'ws'; 6 | import path from 'path'; 7 | 8 | /** 9 | * GenerateController 10 | * @extends Controller 11 | */ 12 | class GenerateController extends Controller { 13 | /** 14 | * Upload a data package 15 | * @param req Express request 16 | * @param res Express response 17 | * @returns {void} 18 | */ 19 | public async upload(req: Request, res: Response): Promise { 20 | const dataPackage = req.file?.path; 21 | if (!dataPackage) { 22 | res.status(400).send('No data package provided'); 23 | return; 24 | } 25 | 26 | const id = Math.random().toString(36).substring(7); 27 | 28 | const uploadsDir = path.join(process.cwd(), 'uploads'); 29 | const newPath = path.join(uploadsDir, `${id}.zip`); 30 | renameSync(dataPackage, newPath); 31 | 32 | res.send({ id }); 33 | } 34 | 35 | /** 36 | * Generate a wrapped 37 | * @param ws WebSocket 38 | * @param message WebSocket message 39 | */ 40 | public async generate(ws: WebSocket.WebSocket, message: WebSocket.RawData): Promise { 41 | const { id } = JSON.parse(message.toString()); 42 | try { 43 | await wrap(id, (progress: number, info: string) => { 44 | try { 45 | ws.send(JSON.stringify({ progress, info })); 46 | } catch { 47 | return; 48 | } 49 | }); 50 | } catch (e) { 51 | console.error(e); 52 | try { 53 | ws.send(JSON.stringify({ progress: 500, info: 'There went something wrong, are you sure your data package is complete or valid?' })); 54 | } catch { 55 | return; 56 | } 57 | } finally { 58 | ws.close(); 59 | } 60 | } 61 | 62 | /** 63 | * Download the generated wrapped 64 | * @param req Express request 65 | * @param res Express response 66 | * @returns {void} 67 | */ 68 | public async download(req: Request, res: Response): Promise { 69 | const { id } = req.params; 70 | res.download(`${id}/wrapped.mp4`); 71 | } 72 | } 73 | 74 | export default GenerateController; -------------------------------------------------------------------------------- /backend/src/api/routes/GenerateRoute.ts: -------------------------------------------------------------------------------- 1 | import GenerateController from '../controllers/GenerateController'; 2 | import Route from './Route'; 3 | import multer from 'multer'; 4 | import path from 'path'; 5 | const storage = multer.diskStorage({ 6 | destination: function(req, file, cb) { 7 | const uploadsDir = path.join(process.cwd(), 'uploads'); 8 | cb(null, uploadsDir); 9 | }, 10 | filename: function(req, file, cb) { 11 | cb(null, Date.now() + '.zip'); 12 | }, 13 | }); 14 | 15 | const upload = multer({ storage: storage }); 16 | 17 | /** 18 | * GenerateRoute 19 | * @extends Route 20 | */ 21 | class GenerateRoute extends Route { 22 | 23 | private readonly controller: GenerateController = new GenerateController(); 24 | 25 | constructor() { 26 | super(); 27 | this.init(); 28 | } 29 | 30 | /** 31 | * Initialise routes 32 | */ 33 | private init(): void { 34 | this.router.post('/upload', upload.single('file'), (req, res) => this.controller.upload(req, res)); 35 | this.router.get('/download/:id', (req, res) => this.controller.download(req, res)); 36 | 37 | import('../../app').then(({ wss }) => { 38 | wss.on('connection', (ws) => { 39 | ws.on('message', (message) => { 40 | this.controller.generate(ws, message); 41 | }); 42 | }); 43 | }); 44 | } 45 | } 46 | 47 | export default GenerateRoute; -------------------------------------------------------------------------------- /backend/src/api/routes/Route.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { IRoute } from '../../types/global'; 3 | 4 | /** 5 | * Route 6 | */ 7 | class Route implements IRoute { 8 | 9 | /** 10 | * Router 11 | */ 12 | public router: Router = Router(); 13 | } 14 | 15 | export default Route; -------------------------------------------------------------------------------- /backend/src/api/routes/index.ts: -------------------------------------------------------------------------------- 1 | import Server from '../Server'; 2 | import cors from 'cors'; 3 | import GenerateRoute from './GenerateRoute'; 4 | 5 | /** 6 | * Initialise routes 7 | * @param server Express server instance 8 | */ 9 | export const initRoutes = ({ application, config }: Server): void => { 10 | application.use(cors()); 11 | application.use(`${config.prefix}/generate`, new GenerateRoute().router); 12 | }; -------------------------------------------------------------------------------- /backend/src/app.ts: -------------------------------------------------------------------------------- 1 | import { createServer, Server as HTTPServer } from 'http'; 2 | import { config as insertEnv } from 'dotenv'; 3 | insertEnv(); 4 | 5 | import Server from './api/Server'; 6 | import Logger from './utils/Logger'; 7 | import config from './constants/config'; 8 | import { WebSocketServer } from 'ws'; 9 | 10 | const exServer: Server = new Server({ 11 | ...config, 12 | prefix: '/api', 13 | }); 14 | const server: HTTPServer = createServer(exServer.application); 15 | const wss = new WebSocketServer({ server }); 16 | 17 | Logger.info(`Initialing back-end service on ${config.environment} environment...`); 18 | 19 | server.timeout = 600000; 20 | 21 | server.listen(config.port, (): void => { 22 | Logger.ready(`Back-end service is running on port ${config.port}`); 23 | }); 24 | 25 | server.on('close', (): void => { 26 | Logger.info('Closing back-end service...'); 27 | }); 28 | 29 | export { wss }; -------------------------------------------------------------------------------- /backend/src/constants/config.ts: -------------------------------------------------------------------------------- 1 | import { IConfig } from '../types/global'; 2 | 3 | const config: IConfig = { 4 | appname: process.env.APP_NAME || 'discordwrapped-backend', 5 | environment: process.env.APP_ENV || 'development', 6 | port: parseInt(process.env.APP_PORT || '3020'), 7 | url: process.env.APP_URL || 'https://discordwrapped.com', 8 | }; 9 | 10 | export default config; -------------------------------------------------------------------------------- /backend/src/constants/progress.ts: -------------------------------------------------------------------------------- 1 | const Tasks: string[] = [ 2 | 'Uploading your data package', 3 | 'Unzipping your data package', 4 | 'Processing events, might take a while', 5 | 'Processing analytics data (progress%)', 6 | 'Creating the first frame', 7 | 'Creating the second frame', 8 | 'Creating the third frame', 9 | 'Creating the fourth frame', 10 | 'Creating the fifth frame', 11 | 'Creating the sixth frame', 12 | 'Creating the seventh frame', 13 | 'Generating your wrapped', 14 | 'Done generating your wrapped', 15 | ]; 16 | 17 | export default Tasks; -------------------------------------------------------------------------------- /backend/src/generator/animations/wrap.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { createCanvas, loadImage } from 'canvas'; 3 | import sharp from 'sharp'; 4 | import { existsSync, mkdirSync, rmSync, readFileSync, writeFileSync } from 'fs'; 5 | import type editlyType from 'editly'; 6 | import ffmpeg from 'fluent-ffmpeg'; 7 | import axios from 'axios'; 8 | import * as ghEmoji from 'github-emoji'; 9 | import { getUserInfo } from '../extractor/extract'; 10 | import Tasks from '../../constants/progress'; 11 | import StreamZip from 'node-stream-zip'; 12 | 13 | /** 14 | * Get the editly library 15 | * @returns {Promise} The editly library 16 | */ 17 | const getEditly = async (): Promise => { 18 | const lib = await (eval('import(\'editly\')') as Promise<{ 19 | default: typeof import('editly'); 20 | }>); 21 | return lib.default; 22 | }; 23 | 24 | /** 25 | * Get number with commas 26 | * @param {number} number Number to format 27 | * @returns {string} Formatted number 28 | */ 29 | function numberWithCommas(number: number): string { 30 | return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); 31 | } 32 | 33 | /** 34 | * Get the path to a tool 35 | * @param tool Tool name 36 | * @returns {string} The path to the tool 37 | */ 38 | function getToolPath(tool: string): string { 39 | const pathsToCheck = [ 40 | `${tool}.exe`, 41 | `/usr/bin/${tool}`, 42 | tool, 43 | ]; 44 | 45 | for (let i = 0; i < pathsToCheck.length; i++) { 46 | const filePath = path.resolve(pathsToCheck[i]); 47 | if (existsSync(filePath)) { 48 | return filePath; 49 | } 50 | } 51 | 52 | console.error(`Could not find: ${tool}! Please check if you've downloaded it`); 53 | process.exit(1); 54 | } 55 | 56 | const ffmpegPath = getToolPath('ffmpeg'); 57 | const ffprobePath = getToolPath('ffprobe'); 58 | 59 | ffmpeg.setFfmpegPath(ffmpegPath); 60 | 61 | const coordinates = [ 62 | { x: 255, y: 402, w: 384, h: 384 }, 63 | { x: 768, y: 402, w: 384, h: 384 }, 64 | { x: 1280, y: 402, w: 384, h: 384 }, 65 | ]; 66 | 67 | const emojisCoordinates = [ 68 | [110, 460, 290, 290], 69 | [1250, 325, 235, 235], 70 | [1570, 325, 235, 235], 71 | [1240, 655, 235, 235], 72 | [1564, 655, 235, 235], 73 | ]; 74 | 75 | const gamesCoordinates = [ 76 | [360, 323, 241, 241], 77 | [679, 323, 241, 241], 78 | [999, 323, 241, 241], 79 | [1319, 323, 241, 241], 80 | ]; 81 | 82 | const stickersCoordinates = [ 83 | [840, 292, 285, 295], 84 | [385, 660, 234, 234], 85 | [706, 660, 234, 234], 86 | [1025, 660, 234, 234], 87 | [1345, 660, 234, 234], 88 | ]; 89 | 90 | const wordsCoordinates = { 91 | name: [ 92 | [610, 364], 93 | [610, 467], 94 | [610, 570], 95 | [610, 673], 96 | [610, 776], 97 | ], 98 | count: [ 99 | [1255, 364], 100 | [1255, 467], 101 | [1255, 570], 102 | [1255, 673], 103 | [1255, 776], 104 | ], 105 | }; 106 | 107 | const summaryCoordinates = [ 108 | [560, 380], 109 | [730, 495], 110 | [580, 613], 111 | [600, 730], 112 | [680, 845], 113 | [1540, 370], 114 | [1592, 485], 115 | [1595, 604], 116 | [1400, 717], 117 | [1570, 834], 118 | ]; 119 | 120 | /** 121 | * Wrap the data package 122 | * @param {string} wrappedId The id of the wrapped 123 | * @param {(progress: number, info: string) => void} progressCallback The callback to call when the progress changes 124 | */ 125 | export default async (wrappedId: string, progressCallback: (progress: number, info: string) => void) => { 126 | progressCallback(Math.round((2 / Tasks.length) * 100), Tasks[1]); 127 | const dir = `./${wrappedId}/`; 128 | mkdirSync(dir); 129 | 130 | const uploadsDir = path.join(process.cwd(), 'uploads'); 131 | 132 | const zip = new StreamZip.async({ file: path.join(uploadsDir, `${wrappedId}.zip`) }); 133 | await zip.extract(null, `${dir}/package`); 134 | 135 | const dataPackage = `${dir}/package`; 136 | const data: any = await getUserInfo(`${dataPackage}/account/user.json`, dataPackage, progressCallback); 137 | 138 | /** 139 | * Fetch the buffer of an emoji 140 | * @param {string} emoji The emoji to fetch the buffer of 141 | * @returns {Promise} The buffer of the emoji 142 | */ 143 | async function fetchEmojiBuffer(emoji: string): Promise { 144 | const codepoint = Array.from(emoji).map((char) => char?.codePointAt(0)?.toString(16))[0]; 145 | 146 | const url = `https://abs.twimg.com/emoji/v2/svg/${codepoint}.svg`; 147 | 148 | const response = await axios.get(url, { responseType: 'arraybuffer' }); 149 | 150 | const buffer = await sharp(response.data) 151 | .resize(512, 512) 152 | .png() 153 | .toBuffer(); 154 | return buffer; 155 | } 156 | 157 | /** 158 | * Fetch the buffer of a Discord emoji 159 | * @param {string} id The ID of the emoji 160 | * @returns {Promise} The buffer of the emoji 161 | */ 162 | async function fetchDiscordEmojiBuffer(id: string): Promise { 163 | const url = `https://cdn.discordapp.com/emojis/${id}.png`; 164 | 165 | const response = await axios.get(url, { responseType: 'arraybuffer' }); 166 | return response.data; 167 | } 168 | 169 | /** 170 | * Fetch a game image 171 | * @param {string} id The ID of the game 172 | * @param {string} image_id The image ID of the game 173 | * @returns {Promise} The buffer of the game image 174 | */ 175 | async function fetchGameImage(id: string, image_id: string): Promise { 176 | const url = `https://cdn.discordapp.com/app-icons/${id}/${image_id}.png`; 177 | 178 | const response = await axios.get(url, { responseType: 'arraybuffer' }); 179 | return response.data; 180 | } 181 | 182 | /** 183 | * Fetch a sticker image 184 | * @param {string} id The ID of the sticker 185 | * @returns {Promise} The buffer of the sticker image 186 | */ 187 | async function fetchStickerImage(id: string): Promise { 188 | const url = `https://cdn.discordapp.com/stickers/${id}.png`; 189 | 190 | // eslint-disable-next-line no-async-promise-executor 191 | return new Promise(async (resolve) => { 192 | try { 193 | const response = await axios.get(url, { responseType: 'arraybuffer' }); 194 | resolve(response.data); 195 | } catch (e) { 196 | const response = readFileSync('./src/generator/assets/noSticker.png'); 197 | resolve(response); 198 | } 199 | }); 200 | } 201 | 202 | /** 203 | * Fetch a GIF from Tenor 204 | * @param {string} url The URL of the GIF 205 | * @returns {Promise} The buffer of the GIF 206 | */ 207 | async function fetchTenorGIF(url: string): Promise { 208 | // eslint-disable-next-line no-async-promise-executor 209 | return new Promise(async (resolve) => { 210 | try { 211 | const response = await axios.get(url, { responseType: 'arraybuffer' }); 212 | resolve(response.data); 213 | } catch (e) { 214 | const response = readFileSync('./src/generator/assets/noSticker.png'); 215 | resolve(response); 216 | } 217 | }); 218 | } 219 | 220 | /** 221 | * Get the first frame of a video 222 | * @param {string} inputPath The path of the video 223 | * @returns {Promise} The buffer of the first frame 224 | */ 225 | function getVideoFirstFrame(inputPath: string): Promise { 226 | const id = String(Math.random()) + '.png'; 227 | 228 | return new Promise((resolve, reject) => { 229 | ffmpeg(inputPath) 230 | .screenshots({ 231 | count: 1, 232 | timemarks: ['0'], 233 | size: '320x240', 234 | filename: id, 235 | }) 236 | .on('end', async () => { 237 | const imageBuffer = readFileSync(id); 238 | resolve(imageBuffer); 239 | rmSync(id); 240 | }) 241 | .on('error', (err: Error) => { 242 | reject(new Error(`Error extracting first frame: ${err.message}`)); 243 | }); 244 | }); 245 | } 246 | 247 | /** 248 | * Create the recent GIFs frame 249 | * @param {string[]} tenorLinks The links to the GIFs 250 | * @returns {Promise} 251 | */ 252 | async function createRecentGIFs(tenorLinks: any[]): Promise { 253 | const canvas = createCanvas(1920, 1080); 254 | const ctx = canvas.getContext('2d'); 255 | 256 | let i = 0; 257 | 258 | ctx.drawImage((await loadImage(path.resolve('./src/generator/assets/mostRecentGIFs.png'))), 0, 0); 259 | 260 | const pagePromises = tenorLinks.map(async (tenorLink) => { 261 | tenorLink = tenorLink.src; 262 | 263 | let filename = tenorLink.split('/').pop() 264 | 265 | filename = filename.includes('?') ? 266 | filename.split('?').shift() : 267 | filename + '.gif'; 268 | 269 | const tenorBuffer = await fetchTenorGIF(tenorLink); 270 | let ext = ''; 271 | if (tenorBuffer[0] === 0x89 && tenorBuffer[1] === 0x50 && tenorBuffer[2] === 0x4e && tenorBuffer[3] === 0x47) ext = 'png'; 272 | 273 | 274 | if (ext === 'png') { 275 | ctx.drawImage((await loadImage(tenorBuffer)), coordinates[i].x, coordinates[i].y, coordinates[i].w, coordinates[i].h); 276 | } else { 277 | writeFileSync(filename, tenorBuffer); 278 | 279 | const frameData = await getVideoFirstFrame(filename); 280 | 281 | ctx.drawImage((await loadImage(frameData)), coordinates[i].x, coordinates[i].y, coordinates[i].w, coordinates[i].h); 282 | } 283 | 284 | try { 285 | rmSync(filename); 286 | } catch (e) { 287 | return i++; 288 | } 289 | i++; 290 | }); 291 | await Promise.all(pagePromises); 292 | 293 | const buffer = canvas.toBuffer(); 294 | writeFileSync(path.resolve(`${dir}/mostRecentGIFs.png`), buffer); 295 | } 296 | 297 | /** 298 | * Create the most used emojis frame 299 | * @param {any[]} array The array of emojis 300 | * @returns {Promise} 301 | */ 302 | async function createMostUsedEmojis(array: any[]): Promise { 303 | const canvas = createCanvas(1920, 1080); 304 | const ctx = canvas.getContext('2d'); 305 | 306 | ctx.drawImage((await loadImage(path.resolve('./src/generator/assets/topEmojis.png'))), 0, 0); 307 | 308 | for (let i = 0; i < array.length; i++) { 309 | const emojiObj = array[i]; 310 | const emoji = emojiObj.name; 311 | 312 | let buffer; 313 | 314 | if (!emojiObj.id) buffer = await fetchEmojiBuffer(emoji); 315 | if (emojiObj.id) buffer = await fetchDiscordEmojiBuffer(emojiObj.id); 316 | 317 | const image = await loadImage(buffer as Buffer); 318 | 319 | ctx.drawImage(image, emojisCoordinates[i][0], emojisCoordinates[i][1], emojisCoordinates[i][2], emojisCoordinates[i][3]); 320 | 321 | if (i === (array.length - 1)) { 322 | 323 | ctx.font = '75px Arial'; 324 | 325 | array.forEach((x) => { 326 | let emojiName = ghEmoji.namesOf(x.name)[0] ? ghEmoji.namesOf(x.name)[0].toString() : 'unknown'; 327 | emojiName.replace('+1', 'thumbs_up'); 328 | 329 | if (emojiName === 'unknown' && x.id) emojiName = x.name; 330 | 331 | if (emojiName.length >= 11) { 332 | x.name = emojiName.slice(0, 11) + '...'; 333 | } else { 334 | x.name = emojiName; 335 | } 336 | }); 337 | 338 | ctx.fillText(array[0].name, 610, 430); 339 | ctx.fillText(array[1].name, 610, 530); 340 | ctx.fillText(array[2].name, 610, 630); 341 | ctx.fillText(array[3].name, 610, 730); 342 | ctx.fillText(array[4].name, 610, 830); 343 | 344 | const outputBuffer = canvas.toBuffer(); 345 | writeFileSync(path.resolve(`${dir}/topEmojis.png`), outputBuffer); 346 | } 347 | } 348 | } 349 | 350 | /** 351 | * Create the money count frame 352 | * @param {string} text The text to display 353 | * @returns {Promise} 354 | */ 355 | async function createMoneyCount(text: string): Promise { 356 | const canvas = createCanvas(1920, 1080); 357 | const ctx = canvas.getContext('2d'); 358 | ctx.drawImage((await loadImage(path.resolve('./src/generator/assets/moneyCount.png'))), 0, 0); 359 | 360 | ctx.font = '235px Sans'; 361 | ctx.strokeStyle = '#000000'; 362 | ctx.lineWidth = 7; 363 | 364 | ctx.strokeText(text, 115, 500); 365 | 366 | const buffer = canvas.toBuffer(); 367 | 368 | writeFileSync(path.resolve(`${dir}/moneyCount.png`), buffer); 369 | } 370 | 371 | /** 372 | * Create the most played games frame 373 | * @param {any[]} array The array of games 374 | * @returns {Promise} 375 | */ 376 | async function createMostPlayedGames(array: any[]): Promise { 377 | const canvas = createCanvas(1920, 1080); 378 | const ctx = canvas.getContext('2d'); 379 | 380 | ctx.drawImage((await loadImage(path.resolve('./src/generator/assets/topGames.png'))), 0, 0); 381 | ctx.font = '40px Arial'; 382 | ctx.textAlign = 'center'; 383 | ctx.fillStyle = '#ffffff'; 384 | for (let i = 0; i < array.length; i++) { 385 | const imageBuffer = await fetchGameImage(array[i].id, array[i].icon); 386 | const image = await loadImage(imageBuffer); 387 | const cdnts = gamesCoordinates[i]; 388 | let name = array[i].name; 389 | 390 | if (name.length >= 12) { 391 | name = name.slice(0, 12) + '...'; 392 | } 393 | 394 | ctx.drawImage(image, cdnts[0], cdnts[1], cdnts[2], cdnts[3]); 395 | ctx.fillText(name, cdnts[0] + 122, cdnts[1] - 18); 396 | } 397 | 398 | const buffer = canvas.toBuffer(); 399 | writeFileSync(path.resolve(`${dir}/topGames.png`), buffer); 400 | } 401 | 402 | /** 403 | * Create the most used stickers frame 404 | * @param {any[]} array The array of stickers 405 | * @returns {Promise} 406 | */ 407 | async function createMostUsedStickers(array: any[]): Promise { 408 | const canvas = createCanvas(1920, 1080); 409 | const ctx = canvas.getContext('2d'); 410 | 411 | ctx.drawImage((await loadImage(path.resolve('./src/generator/assets/topStickers.png'))), 0, 0); 412 | ctx.font = '40px Arial'; 413 | ctx.textAlign = 'center'; 414 | for (let i = 0; i < array.length; i++) { 415 | const imageBuffer = await fetchStickerImage(array[i].name); 416 | const image = await loadImage(imageBuffer); 417 | const cdnts = stickersCoordinates[i]; 418 | 419 | ctx.drawImage(image, cdnts[0], cdnts[1], cdnts[2], cdnts[3]); 420 | } 421 | 422 | const buffer = canvas.toBuffer(); 423 | writeFileSync(path.resolve(`${dir}/topStickers.png`), buffer); 424 | } 425 | 426 | /** 427 | * Create the most used words frame 428 | * @param {any[]} array The array of words 429 | * @returns {Promise} 430 | */ 431 | async function createMostUsedWords(array: any[]): Promise { 432 | const canvas = createCanvas(1920, 1080); 433 | const ctx = canvas.getContext('2d'); 434 | 435 | ctx.drawImage((await loadImage(path.resolve('./src/generator/assets/mostUsedWords.png'))), 0, 0); 436 | ctx.font = 'bold 80px Arial'; 437 | 438 | for (let i = 0; i < array.length; i++) { 439 | const cdnts = wordsCoordinates.name[i]; 440 | const cdnts2 = wordsCoordinates.count[i]; 441 | let word = array[i][0]; 442 | 443 | if (word.length >= 10) { 444 | word = word.slice(0, 10) + '...'; 445 | } 446 | 447 | ctx.fillText(word, cdnts[0], cdnts[1]); 448 | ctx.fillText(array[i][1], cdnts2[0], cdnts2[1]); 449 | } 450 | 451 | const buffer = canvas.toBuffer(); 452 | writeFileSync(path.resolve(`${dir}/mostUsedWords.png`), buffer); 453 | } 454 | 455 | /** 456 | * Create the summary frame 457 | * @param {any[]} array The array of summary 458 | * @returns {Promise} 459 | */ 460 | async function createSummary(array: any[]): Promise { 461 | const canvas = createCanvas(1920, 1080); 462 | const ctx = canvas.getContext('2d'); 463 | const values = Object.values(array); 464 | const keys = Object.keys(array); 465 | 466 | ctx.drawImage((await loadImage(path.resolve('./src/generator/assets/summary.png'))), 0, 0); 467 | ctx.font = 'bold 50px Arial'; 468 | 469 | for (let i = 0; i < values.length; i++) { 470 | const cdnts = summaryCoordinates[i]; 471 | let name = values[i]; 472 | 473 | name = numberWithCommas(name); 474 | 475 | if (name == '0') name = 'N/A'; 476 | 477 | if (['joinCallCount', 'openCount'].includes(keys[i])) name += ' times'; 478 | if (['dmChannelCount'].includes(keys[i])) name += ' people'; 479 | if (['channelCount'].includes(keys[i])) name += ' channels'; 480 | 481 | ctx.fillText(name, cdnts[0], cdnts[1]); 482 | } 483 | 484 | const buffer = canvas.toBuffer(); 485 | writeFileSync(path.resolve(`${dir}/summary.png`), buffer); 486 | } 487 | 488 | progressCallback(Math.round((5 / Tasks.length) * 100), Tasks[4]); 489 | await createRecentGIFs(data.most_recent_favorite_gifs || []); 490 | progressCallback(Math.round((6 / Tasks.length) * 100), Tasks[5]); 491 | await createMostUsedEmojis(data.most_used_emojis || []); 492 | progressCallback(Math.round((7 / Tasks.length) * 100), Tasks[6]); 493 | await createMoneyCount(data.total_spend || 0); 494 | progressCallback(Math.round((8 / Tasks.length) * 100), Tasks[7]); 495 | await createMostPlayedGames(data.most_played_games || []); 496 | progressCallback(Math.round((9 / Tasks.length) * 100), Tasks[8]); 497 | await createMostUsedStickers(data.most_used_stickers || []); 498 | progressCallback(Math.round((10 / Tasks.length) * 100), Tasks[9]); 499 | await createMostUsedWords(data.most_used_words || []); 500 | progressCallback(Math.round((11 / Tasks.length) * 100), Tasks[10]); 501 | await createSummary(data.statistics); 502 | 503 | rmSync(`${dir}/package`, { recursive: true, force: true }); 504 | 505 | const editly = await getEditly(); 506 | 507 | progressCallback(Math.round((12 / Tasks.length) * 100), Tasks[11]); 508 | await editly({ 509 | ffprobePath, 510 | ffmpegPath, 511 | outPath: `${dir}/wrapped.mp4`, 512 | width: 1920, 513 | height: 1080, 514 | enableFfmpegLog: false, 515 | verbose: false, 516 | fast: false, 517 | clips: [ 518 | { 519 | duration: 2.5, 520 | layers: [ 521 | { type: 'image', path: path.join(process.cwd(), 'src', 'generator', 'assets', 'opening.png') }, 522 | ], 523 | }, 524 | { 525 | duration: 5, 526 | layers: [ 527 | { type: 'image', path: path.join(process.cwd(), dir, 'mostRecentGIFs.png') }, 528 | ], 529 | }, 530 | { 531 | duration: 5, 532 | layers: [ 533 | { type: 'image', path: path.join(process.cwd(), dir, 'topEmojis.png') }, 534 | ], 535 | }, 536 | { 537 | duration: 5, 538 | layers: [ 539 | { type: 'image', path: path.join(process.cwd(), dir, 'moneyCount.png') }, 540 | ], 541 | }, 542 | { 543 | duration: 5, 544 | layers: [ 545 | { type: 'image', path: path.join(process.cwd(), dir, 'topGames.png') }, 546 | ], 547 | }, 548 | { 549 | duration: 5, 550 | layers: [ 551 | { type: 'image', path: path.join(process.cwd(), dir, 'topStickers.png') }, 552 | ], 553 | }, 554 | { 555 | duration: 5, 556 | layers: [ 557 | { type: 'image', path: path.join(process.cwd(), dir, 'mostUsedWords.png') }, 558 | ], 559 | }, 560 | { 561 | duration: 10, 562 | layers: [ 563 | { type: 'image', path: path.join(process.cwd(), dir, 'summary.png') }, 564 | ], 565 | }, 566 | { 567 | duration: 6, 568 | layers: [ 569 | { type: 'image', path: path.join(process.cwd(), 'src', 'generator', 'assets', 'ending.png') }, 570 | ], 571 | }, 572 | ], 573 | }); 574 | 575 | progressCallback(Math.round((13 / Tasks.length) * 100), Tasks[12]); 576 | 577 | return dir; 578 | }; 579 | -------------------------------------------------------------------------------- /backend/src/generator/assets/archive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Assassin-1234/discord-wrapped/7276dad54c80099df24996c7e5bff74c39fc85a9/backend/src/generator/assets/archive.png -------------------------------------------------------------------------------- /backend/src/generator/assets/ending.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Assassin-1234/discord-wrapped/7276dad54c80099df24996c7e5bff74c39fc85a9/backend/src/generator/assets/ending.png -------------------------------------------------------------------------------- /backend/src/generator/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Assassin-1234/discord-wrapped/7276dad54c80099df24996c7e5bff74c39fc85a9/backend/src/generator/assets/logo.png -------------------------------------------------------------------------------- /backend/src/generator/assets/moneyCount.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Assassin-1234/discord-wrapped/7276dad54c80099df24996c7e5bff74c39fc85a9/backend/src/generator/assets/moneyCount.png -------------------------------------------------------------------------------- /backend/src/generator/assets/mostRecentGIFs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Assassin-1234/discord-wrapped/7276dad54c80099df24996c7e5bff74c39fc85a9/backend/src/generator/assets/mostRecentGIFs.png -------------------------------------------------------------------------------- /backend/src/generator/assets/mostUsedWords.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Assassin-1234/discord-wrapped/7276dad54c80099df24996c7e5bff74c39fc85a9/backend/src/generator/assets/mostUsedWords.png -------------------------------------------------------------------------------- /backend/src/generator/assets/noSticker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Assassin-1234/discord-wrapped/7276dad54c80099df24996c7e5bff74c39fc85a9/backend/src/generator/assets/noSticker.png -------------------------------------------------------------------------------- /backend/src/generator/assets/opening.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Assassin-1234/discord-wrapped/7276dad54c80099df24996c7e5bff74c39fc85a9/backend/src/generator/assets/opening.png -------------------------------------------------------------------------------- /backend/src/generator/assets/summary.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Assassin-1234/discord-wrapped/7276dad54c80099df24996c7e5bff74c39fc85a9/backend/src/generator/assets/summary.png -------------------------------------------------------------------------------- /backend/src/generator/assets/topEmojis.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Assassin-1234/discord-wrapped/7276dad54c80099df24996c7e5bff74c39fc85a9/backend/src/generator/assets/topEmojis.png -------------------------------------------------------------------------------- /backend/src/generator/assets/topGames.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Assassin-1234/discord-wrapped/7276dad54c80099df24996c7e5bff74c39fc85a9/backend/src/generator/assets/topGames.png -------------------------------------------------------------------------------- /backend/src/generator/assets/topStickers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Assassin-1234/discord-wrapped/7276dad54c80099df24996c7e5bff74c39fc85a9/backend/src/generator/assets/topStickers.png -------------------------------------------------------------------------------- /backend/src/generator/extractor/extract.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs'; 3 | import { snakeCase } from 'snake-case'; 4 | import axios from 'axios'; 5 | import Tasks from '../../constants/progress'; 6 | 7 | const eventsData = [ 8 | 'joinVoiceChannel', 9 | 'notificationClicked', 10 | 'appOpened', 11 | 'joinCall', 12 | 'addReaction', 13 | 'messageEdited', 14 | 'sendMessage', 15 | 'slashCommandUsed', 16 | ]; 17 | 18 | let messages: any[] = []; 19 | 20 | /** 21 | * 22 | * @param {string} filePath The path to the analytics file 23 | * @param {(progress: number, info: string) => void} progressCallback The callback to call when the progress changes 24 | * @returns {any} The analytics data 25 | */ 26 | const readAnalyticsFile = (filePath: string, progressCallback: (progress: number, info: string) => void): any => { 27 | if (!fs.existsSync(filePath)) { 28 | return { 29 | joinVoiceChannelCount: 0, 30 | joinCallCount: 0, 31 | channelCount: 0, 32 | dmChannelCount: 0, 33 | notificationCount: 0, 34 | sendMessageCount: 0, 35 | messageEditedCount: 0, 36 | slashCommandUsedCount: 0, 37 | addReactionCount: 0, 38 | openCount: 0, 39 | }; 40 | } 41 | 42 | return new Promise((resolve, reject) => { 43 | const eventsOccurrences: any = {}; 44 | for (const eventName of eventsData) { 45 | eventsOccurrences[eventName] = 0; 46 | } 47 | 48 | const stream = fs.createReadStream(filePath, { encoding: 'utf8' }); 49 | 50 | progressCallback(Math.round((3 / Tasks.length) * 100), Tasks[2]); 51 | 52 | const stats = fs.statSync(filePath); 53 | 54 | let str = ''; 55 | let bytesRead = 0; 56 | const maxBytes = Math.floor(stats.size); 57 | 58 | stream.on('data', (chunk) => { 59 | str += chunk; 60 | bytesRead += chunk.length; 61 | 62 | for (const event of Object.keys(eventsOccurrences)) { 63 | const eventName = snakeCase(event); 64 | 65 | // eslint-disable-next-line no-constant-condition 66 | while (true) { 67 | const ind = str.indexOf(eventName); 68 | if (ind == -1) { 69 | break; 70 | } 71 | str = str.slice(ind + eventName.length); 72 | eventsOccurrences[event]++; 73 | 74 | progressCallback(Math.round((4 / Tasks.length) * 100), Tasks[3].replace('progress', Math.round((bytesRead / maxBytes) * 100).toString())); 75 | } 76 | } 77 | }); 78 | 79 | stream.on('end', () => { 80 | resolve({ 81 | joinVoiceChannelCount: eventsOccurrences.joinVoiceChannel, 82 | joinCallCount: eventsOccurrences.joinCall, 83 | channelCount: 0, 84 | dmChannelCount: 0, 85 | notificationCount: eventsOccurrences.notificationClicked, 86 | sendMessageCount: eventsOccurrences.sendMessage, 87 | messageEditedCount: eventsOccurrences.messageEdited, 88 | slashCommandUsedCount: eventsOccurrences.slashCommandUsed, 89 | addReactionCount: eventsOccurrences.addReaction, 90 | openCount: eventsOccurrences.appOpened, 91 | }); 92 | }); 93 | 94 | stream.on('error', (err) => { 95 | reject(err); 96 | }); 97 | }); 98 | }; 99 | 100 | /** 101 | * Fetch messages 102 | * @returns {void} 103 | */ 104 | function fetchMessages(dataPackage: string): void { 105 | const messagesPath = path.resolve(`${dataPackage}/messages`); 106 | 107 | const files = fs.readdirSync(messagesPath); 108 | 109 | files.forEach(file => { 110 | if (file.includes('.json')) return; 111 | 112 | const filePath = path.join(messagesPath, file, 'messages.csv'); 113 | const data = fs.readFileSync(filePath, 'utf-8'); 114 | 115 | const lines = data.split('\n').slice(1); 116 | lines.forEach(function(line) { 117 | const parts = line.split(','); 118 | 119 | if (parts.length > 2) messages.push(parts[2]); 120 | }); 121 | }); 122 | } 123 | 124 | /** 125 | * Return the most common 126 | * @returns {any[][]} 127 | */ 128 | function returnMostCommon(): any[][] { 129 | const frequency: any = {}; 130 | messages = messages.filter((x) => x !== ''); 131 | messages.forEach(function(value) { frequency[value] = 0; }); 132 | messages.forEach(function(value) { frequency[value]++; }); 133 | let sortable = []; 134 | for (const emoji in frequency) { 135 | sortable.push([emoji, frequency[emoji]]); 136 | } 137 | sortable.sort(function(a, b) { 138 | return b[1] - a[1]; 139 | }); 140 | 141 | sortable = sortable.slice(0, 5); 142 | return sortable; 143 | } 144 | 145 | /** 146 | * Return the most used emoji 147 | * @param {string} message The message 148 | * @returns {any[]} 149 | */ 150 | function returnMostUsedEmoji(message: string): any[] { 151 | const emojiRegex = /<(a)?:[a-zA-Z0-9_]+:([0-9]+)>|([\u{1f300}-\u{1f5ff}\u{1f900}-\u{1f9ff}\u{1f600}-\u{1f64f}\u{1f680}-\u{1f6ff}\u{2600}-\u{26ff}\u{2700}-\u{27bf}\u{1f1e6}-\u{1f1ff}]{1,3})/gu; 152 | 153 | let emojiList: any = {}; 154 | let emojiMatch; 155 | 156 | // eslint-disable-next-line no-cond-assign 157 | while (emojiMatch = emojiRegex.exec(message)) { 158 | const emoji = emojiMatch[0]; 159 | 160 | if (!emojiList[emoji]) { 161 | const match = emoji.match(/?/); 162 | 163 | if (match === null) { 164 | emojiList[emoji] = { 165 | id: null, 166 | name: emoji, 167 | count: 0, 168 | }; 169 | } else { 170 | emojiList[emoji] = { 171 | id: match[3], 172 | name: match[2], 173 | count: 0, 174 | }; 175 | } 176 | } 177 | 178 | emojiList[emoji].count++; 179 | } 180 | 181 | emojiList = Object.values(emojiList).sort((a: any, b: any) => b.count - a.count); 182 | 183 | return emojiList; 184 | } 185 | 186 | /** 187 | * Return game data 188 | * @param {any[]} arr The array with game data 189 | * @returns {Promise} 190 | */ 191 | async function returnGameData(arr: any[]): Promise { 192 | const gamedata = await Promise.all(arr.map(async (e) => { 193 | const { data } = await axios.get(`https://discord.com/api/v9/applications/${e.application_id}/rpc`); 194 | return data; 195 | })); 196 | 197 | return gamedata; 198 | } 199 | 200 | /** 201 | * Get user info 202 | * @param {string} dir The directory to get the data from 203 | * @param {(progress: number, info: string) => void} progressCallback The callback to call when the progress changes 204 | * @returns {Promise} The user info 205 | */ 206 | export async function getUserInfo(dir: string, dataPackage: string, progressCallback: (progress: number, info: string) => void): Promise { 207 | const rawData = fs.readFileSync(path.resolve(dir), 'utf-8').toString(); 208 | const data = JSON.parse(rawData); 209 | const stickers = data.settings.frecency.stickerFrecency?.stickers; 210 | 211 | fetchMessages(dataPackage); 212 | 213 | const sortedEmojis = returnMostUsedEmoji(messages.join('')); 214 | const sortedActivities = data.user_activity_application_statistics 215 | .sort((a: any, b: any) => b.total_duration - a.total_duration); 216 | const totalSpent = (data.payments.reduce((total: number, payment: any) => payment.status === 1 ? total + payment.amount : total, 0) / 100).toFixed(2); 217 | const lastFavGifs = Object.values(data.settings.frecency.favoriteGifs.gifs).sort((a: any, b: any) => b.order - a.order).slice(0, 5); 218 | const favStickers = Object.keys(stickers) 219 | .sort((a, b) => stickers[b].totalUses - stickers[a].totalUses) 220 | .map(key => ({ name: key, ...stickers[key] })); 221 | 222 | const mostUsedWords = returnMostCommon(); 223 | 224 | const mostPlayedGames = await returnGameData(sortedActivities.slice(0, 5)); 225 | 226 | const files = fs.existsSync(path.resolve(`${dataPackage}/activity/analytics`)) 227 | ? fs.readdirSync(path.resolve(`${dataPackage}/activity/analytics`)) 228 | : []; 229 | 230 | const filePath = files.find((file) => /events-[0-9]{4}-[0-9]{5}-of-[0-9]{5}\.json/.test(file)) || 'not_found'; 231 | const statistics: any = await readAnalyticsFile(path.resolve(`${dataPackage}/activity/analytics/`, filePath), progressCallback); 232 | 233 | const messagesPathRegex = /c?([0-9]{16,32})/; 234 | 235 | const channelsIDsFile = fs.readdirSync(path.resolve(`${dataPackage}/messages`)); 236 | 237 | const isOldPackage = !channelsIDsFile[0].includes('c'); 238 | const channelsIDs = channelsIDsFile.slice(0, channelsIDsFile.length - 1).map((file) => file.match(messagesPathRegex)?.[1]); 239 | const channels: any[] = []; 240 | 241 | await Promise.all(channelsIDs.map((channelID) => { 242 | return new Promise((resolve) => { 243 | const channelDataPath = path.resolve(`${dataPackage}/messages/${isOldPackage ? '' : 'c'}${channelID}/channel.json`); 244 | const channelMessagesPath = path.resolve(`${dataPackage}/messages/${isOldPackage ? '' : 'c'}${channelID}/messages.csv`); 245 | 246 | Promise.all([ 247 | fs.readFileSync(channelDataPath), 248 | fs.readFileSync(channelMessagesPath), 249 | ]).then(([rawData2]) => { 250 | const data2 = JSON.parse(rawData2.toString()); 251 | const isDM = data2.recipients && data2.recipients.length === 2; 252 | channels.push({ 253 | data: data2, 254 | isDM, 255 | }); 256 | 257 | resolve(); 258 | }); 259 | 260 | }); 261 | })); 262 | 263 | statistics.channelCount = channels.filter(c => !c.isDM).length; 264 | statistics.dmChannelCount = channels.length - statistics.channelCount; 265 | 266 | return { 267 | statistics, 268 | username: data.username, 269 | discrim: data.discriminator, 270 | avatar: data.avatar_hash, 271 | total_spend: '$' + totalSpent, 272 | most_recent_favorite_gifs: lastFavGifs ? lastFavGifs.slice(0, 3) : [], 273 | most_used_stickers: favStickers.length ? favStickers.slice(0, 5) : [], 274 | most_used_emojis: sortedEmojis.length ? sortedEmojis.slice(0, 5) : [], 275 | most_played_games: mostPlayedGames.length ? mostPlayedGames.slice(0, 4) : [], 276 | most_used_words: mostUsedWords, 277 | }; 278 | } -------------------------------------------------------------------------------- /backend/src/generator/index.ts: -------------------------------------------------------------------------------- 1 | import wrap from './animations/wrap'; 2 | export default wrap; -------------------------------------------------------------------------------- /backend/src/generator/output/image10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Assassin-1234/discord-wrapped/7276dad54c80099df24996c7e5bff74c39fc85a9/backend/src/generator/output/image10.png -------------------------------------------------------------------------------- /backend/src/generator/output/image11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Assassin-1234/discord-wrapped/7276dad54c80099df24996c7e5bff74c39fc85a9/backend/src/generator/output/image11.png -------------------------------------------------------------------------------- /backend/src/generator/output/image3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Assassin-1234/discord-wrapped/7276dad54c80099df24996c7e5bff74c39fc85a9/backend/src/generator/output/image3.png -------------------------------------------------------------------------------- /backend/src/generator/output/image4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Assassin-1234/discord-wrapped/7276dad54c80099df24996c7e5bff74c39fc85a9/backend/src/generator/output/image4.png -------------------------------------------------------------------------------- /backend/src/generator/output/image5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Assassin-1234/discord-wrapped/7276dad54c80099df24996c7e5bff74c39fc85a9/backend/src/generator/output/image5.png -------------------------------------------------------------------------------- /backend/src/generator/output/image7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Assassin-1234/discord-wrapped/7276dad54c80099df24996c7e5bff74c39fc85a9/backend/src/generator/output/image7.png -------------------------------------------------------------------------------- /backend/src/generator/output/image9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Assassin-1234/discord-wrapped/7276dad54c80099df24996c7e5bff74c39fc85a9/backend/src/generator/output/image9.png -------------------------------------------------------------------------------- /backend/src/generator/output/ok.txt: -------------------------------------------------------------------------------- 1 | output directory -------------------------------------------------------------------------------- /backend/src/types/global.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | 3 | export interface IRoute { 4 | router: Router; 5 | } 6 | 7 | export interface IConfig { 8 | appname: string; 9 | environment: string; 10 | port: number; 11 | url: string; 12 | } 13 | 14 | interface Asset { 15 | left: number; 16 | top: number; 17 | width: number; 18 | height: number; 19 | } 20 | 21 | export interface IAsset { 22 | [key: string]: Asset; 23 | } 24 | 25 | export type AssetType = 'opening' | 'mostRecentGIFs' | 'topEmojis' | 'moneyCount' | 'topGames' | 'topStickers' | 'mostUsedWords' | 'summary' | 'ending' | 'noSticker'; -------------------------------------------------------------------------------- /backend/src/utils/Logger.ts: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | 3 | const RED = '\x1b[31m'; 4 | const GREEN = '\x1b[32m'; 5 | const MAGENTA = '\x1b[35m'; 6 | const RESET = '\x1b[0m'; 7 | 8 | /** 9 | * Logger 10 | */ 11 | class Logger { 12 | 13 | constructor() { 14 | throw new Error('Logger class should not be an instance.'); 15 | } 16 | 17 | /** 18 | * Info log 19 | * @param message Log message 20 | */ 21 | public static info(message: string, enable = true): void { 22 | if (enable) console.info(`[${moment().format('DD/MM/YYYY HH:mm:ss')}] ${MAGENTA}Info${RESET} - ${message}`); 23 | } 24 | 25 | /** 26 | * Ready log 27 | * @param message Log message 28 | */ 29 | public static ready(message: string, enable = true): void { 30 | if (enable) console.info(`[${moment().format('DD/MM/YYYY HH:mm:ss')}] ${GREEN}Ready${RESET} - ${message}`); 31 | } 32 | 33 | /** 34 | * Error log 35 | * @param message Log message 36 | */ 37 | public static error(message: string, enable = true): void { 38 | if (enable) console.error(`[${moment().format('DD/MM/YYYY HH:mm:ss')}] ${RED}Error${RESET} - ${message}`); 39 | } 40 | } 41 | 42 | export default Logger; -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ 22 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | 26 | /* Modules */ 27 | "module": "commonjs", /* Specify what module code is generated. */ 28 | "rootDir": "./src", /* Specify the root folder within your source files. */ 29 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 30 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 31 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 32 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 33 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ 34 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 35 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 36 | // "resolveJsonModule": true, /* Enable importing .json files */ 37 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ 38 | 39 | /* JavaScript Support */ 40 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 41 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 42 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ 43 | 44 | /* Emit */ 45 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 46 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 47 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 48 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 49 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ 50 | "outDir": "./dist", /* Specify an output folder for all emitted files. */ 51 | // "removeComments": true, /* Disable emitting comments. */ 52 | // "noEmit": true, /* Disable emitting files from a compilation. */ 53 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 54 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ 55 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 56 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 59 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 60 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 61 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 62 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ 63 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ 64 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 65 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ 66 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 67 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 68 | 69 | /* Interop Constraints */ 70 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 71 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 72 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */ 73 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 74 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 75 | 76 | /* Type Checking */ 77 | "strict": true, /* Enable all strict type-checking options. */ 78 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 79 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 80 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 81 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ 82 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 83 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ 84 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ 85 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 86 | // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ 87 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ 88 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 89 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 90 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 91 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 92 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 93 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ 94 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 95 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 96 | 97 | /* Completeness */ 98 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 99 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 100 | }, 101 | "exclude": ["prisma/**"] 102 | } 103 | -------------------------------------------------------------------------------- /backend/uploads/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Assassin-1234/discord-wrapped/7276dad54c80099df24996c7e5bff74c39fc85a9/backend/uploads/.gitkeep -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | backend: 4 | build: ./backend 5 | container_name: discord-wrapped-backend 6 | frontend: 7 | build: ./frontend 8 | container_name: discord-wrapped-frontend 9 | 10 | networks: 11 | default: 12 | external: true 13 | name: proxy 14 | 15 | -------------------------------------------------------------------------------- /frontend/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | module.exports = { 3 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], 4 | parser: '@typescript-eslint/parser', 5 | plugins: ['@typescript-eslint'], 6 | root: true, 7 | rules: { 8 | 'brace-style': ['error', '1tbs', { 'allowSingleLine': true }], 9 | 'comma-dangle': ['error', 'always-multiline'], 10 | 'comma-spacing': 'error', 11 | 'comma-style': 'error', 12 | 'curly': ['error', 'multi-line', 'consistent'], 13 | 'dot-location': ['error', 'property'], 14 | 'handle-callback-err': 'off', 15 | 'indent': ['error', 'tab'], 16 | 'max-nested-callbacks': ['error', { 'max': 4 }], 17 | 'max-statements-per-line': ['error', { 'max': 2 }], 18 | 'no-console': 'off', 19 | 'no-empty-function': 'error', 20 | 'no-floating-decimal': 'error', 21 | 'no-inline-comments': 'error', 22 | 'no-lonely-if': 'error', 23 | 'no-multi-spaces': 'error', 24 | 'no-multiple-empty-lines': ['error', { 'max': 2, 'maxEOF': 1, 'maxBOF': 0 }], 25 | 'no-trailing-spaces': ['error'], 26 | 'no-var': 'error', 27 | 'object-curly-spacing': ['error', 'always'], 28 | 'prefer-const': 'error', 29 | 'quotes': ['error', 'single'], 30 | 'semi': ['error', 'always'], 31 | 'space-before-blocks': 'error', 32 | 'space-before-function-paren': ['error', { 33 | 'anonymous': 'never', 34 | 'named': 'never', 35 | 'asyncArrow': 'always', 36 | }], 37 | 'space-in-parens': 'error', 38 | 'space-infix-ops': 'error', 39 | 'space-unary-ops': 'error', 40 | 'spaced-comment': 'error', 41 | 'yoda': 'error', 42 | '@typescript-eslint/no-explicit-any': ['off'], 43 | }, 44 | }; -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | package-lock.json 12 | dist 13 | dist-ssr 14 | *.local 15 | .env 16 | 17 | # Editor directories and files 18 | .vscode/* 19 | !.vscode/extensions.json 20 | .idea 21 | .DS_Store 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine3.17 2 | WORKDIR /frontend 3 | COPY . /frontend 4 | RUN apk update 5 | RUN apk add --no-progress --no-cache \ 6 | curl 7 | ENV NODE_ENV "production" 8 | RUN npm install && npm install -g typescript http-server 9 | RUN npm run build 10 | RUN rm -rf /frontend/Dockerfile /frontend/.env.example 11 | HEALTHCHECK --interval=60s --timeout=10s CMD curl -s -f -o /dev/null http://localhost:5173 || exit 1 12 | ENTRYPOINT ["http-server", "/frontend/dist/", "-e", "--cors", "-a", "0.0.0.0", "-p", "5173", "-d", "false"] -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build --mode production", 9 | "preview": "vite preview --port 5173" 10 | }, 11 | "dependencies": { 12 | "@types/react": "^18.0.31", 13 | "@types/react-dom": "^18.0.11", 14 | "@vitejs/plugin-react": "^3.1.0", 15 | "@vitejs/plugin-react-refresh": "^1.3.6", 16 | "autoprefixer": "^10.4.14", 17 | "postcss": "^8.4.21", 18 | "react": "^18.2.0", 19 | "react-dom": "^18.2.0", 20 | "react-icons": "^4.8.0", 21 | "react-router-dom": "^6.9.0", 22 | "react-toastify": "^9.1.1", 23 | "tailwindcss": "^3.2.7", 24 | "typescript": "^4.9.5", 25 | "vite": "^4.2.0", 26 | "vite-plugin-solid": "^2.6.1" 27 | }, 28 | "devDependencies": { 29 | "@typescript-eslint/eslint-plugin": "^5.55.0", 30 | "@typescript-eslint/parser": "^5.55.0", 31 | "eslint": "^8.36.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /frontend/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/Home.tsx: -------------------------------------------------------------------------------- 1 | import './index.css'; 2 | import icon from './assets/icon.webp'; 3 | import clouds from './assets/clouds.svg'; 4 | import { RiArrowDropDownLine } from 'react-icons/ri'; 5 | import { BsGithub } from 'react-icons/bs'; 6 | import { SiKofi } from 'react-icons/si'; 7 | import { createRoot } from 'react-dom/client'; 8 | 9 | const root = createRoot(document.getElementById('root') as HTMLElement); 10 | root.render(); 11 | 12 | /** 13 | * Home page 14 | */ 15 | function Home() { 16 | const generate = () => { 17 | window.location.href = '/generate/'; 18 | }; 19 | 20 | const now = new Date(); 21 | const utcTime = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 12, 0, 0)); 22 | 23 | return ( 24 | <> 25 | {/* Desktop Navbar */} 26 | 34 | 35 | {/* Mobile Navbar */} 36 | 44 | 45 | {/* Main Content */} 46 |
47 |
48 |

Discord Wrapped

49 |

An insight on all the data collected by Discord, formed into a video just like Spotify Wrapped!

50 | 51 | 52 |
53 | 54 | {/* Extra info */} 55 |
56 |
57 |

Discord Data Package

58 |

We need your data package to be able to generate your wrapped. Find out 59 | how to request your data package in this article.

60 |

Didn't receive your data package yet? Check out the demo on our demo page.

61 |
62 | 63 |
64 |

Privacy

65 |

We only use your data to generate your wrapped. We do not share your data with anyone. We do not sell your data. We do not use your data for anything else than generating your wrapped. Your data automatically gets deleted after your wrapped is generated.

66 |
67 | 68 |
69 |

Donate

70 |

You can donate and thank us for our work on Ko-Fi with the button below.

71 | 74 |
75 | 76 | 77 |
78 |

Open-source

79 |

Discord Wrapped is open-source. You can find the source code in our GitHub repository. Feel free to contribute!

80 |

If you like this project please consider staring the repository on GitHub, so we can reach more people.

81 |
82 | 83 |
84 |

Restarts

85 |

All wrappeds are stored in the storage of the server. This means that you got the time to download your wrapped before the server restarts. If you don't download your wrapped in time, you will have to generate it again.

86 |

The website restarts everyday at {utcTime.toLocaleTimeString()}

87 |
88 | 89 | {/* Footer */} 90 |
91 | 92 |

Discord Wrapped is not affiliated with Discord Inc. Made with ❤ by Iliannnn

93 |
94 |
95 |
96 | 97 | {/* Clouds */} 98 | clouds 99 | 100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /frontend/src/assets/clouds.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /frontend/src/assets/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Assassin-1234/discord-wrapped/7276dad54c80099df24996c7e5bff74c39fc85a9/frontend/src/assets/icon.ico -------------------------------------------------------------------------------- /frontend/src/assets/icon.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Assassin-1234/discord-wrapped/7276dad54c80099df24996c7e5bff74c39fc85a9/frontend/src/assets/icon.webp -------------------------------------------------------------------------------- /frontend/src/custom.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | const content: any; 3 | export default content; 4 | } 5 | 6 | declare module '*.webp' { 7 | const content: any; 8 | export default content; 9 | } -------------------------------------------------------------------------------- /frontend/src/demo/Demo.tsx: -------------------------------------------------------------------------------- 1 | import '../index.css'; 2 | import clouds from '../assets/clouds.svg'; 3 | import { useState, useRef } from 'react'; 4 | import { IconContext } from 'react-icons/lib'; 5 | import { BiPlay, BiFullscreen } from 'react-icons/bi'; 6 | import { createRoot } from 'react-dom/client'; 7 | 8 | const root = createRoot(document.getElementById('root') as HTMLElement); 9 | root.render(); 10 | 11 | /** 12 | * Demo page 13 | */ 14 | function Demo() { 15 | const videoRef = useRef(null); 16 | const [isPlaying, setIsPlaying] = useState(false); 17 | 18 | const handlePlay = () => { 19 | if (isPlaying) { 20 | videoRef.current?.pause(); 21 | setIsPlaying(false); 22 | } else { 23 | videoRef.current?.play(); 24 | setIsPlaying(true); 25 | } 26 | }; 27 | 28 | const handleEnded = () => { 29 | setIsPlaying(false); 30 | }; 31 | 32 | const downloadVideo = async () => { 33 | const videoPath = '/demo.mp4'; 34 | 35 | const response = await fetch(videoPath); 36 | const blob = await response.blob(); 37 | 38 | const url = window.URL.createObjectURL(new Blob([blob])); 39 | const link = document.createElement('a'); 40 | link.href = url; 41 | link.setAttribute('download', 'Demo.mp4'); 42 | document.body.appendChild(link); 43 | link.click(); 44 | }; 45 | 46 | return ( 47 |
48 | {/* Overlay */} 49 |
50 | 51 | {/* Modal */} 52 |
53 |
54 |
55 |

Demo

56 |
57 | 58 |
59 |
60 |
61 | 62 |
63 | 68 |
69 |
70 | 71 | 74 | 75 |
76 |
77 |
78 | 79 |
80 | 81 |
82 | 85 |
86 |
87 |
88 | 89 | {/* Clouds */} 90 | clouds 91 |
92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /frontend/src/demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 22 | 23 | 24 | 25 | 26 | 28 | 29 | 30 | 31 | 32 | Discord Wrapped 33 | 34 | 35 | 36 | 37 |
38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /frontend/src/generate/Generate.tsx: -------------------------------------------------------------------------------- 1 | import '../index.css'; 2 | import clouds from '../assets/clouds.svg'; 3 | import { DragEvent, useState, useRef } from 'react'; 4 | import { ToastContainer, toast } from 'react-toastify'; 5 | import 'react-toastify/dist/ReactToastify.css'; 6 | import { IconContext } from 'react-icons/lib'; 7 | import { IoCloudUploadOutline } from 'react-icons/io5'; 8 | import { BiPlay, BiFullscreen } from 'react-icons/bi'; 9 | import { BsTwitter, BsStarFill } from 'react-icons/bs'; 10 | import { SiKofi } from 'react-icons/si'; 11 | import { createRoot } from 'react-dom/client'; 12 | 13 | const root = createRoot(document.getElementById('root') as HTMLElement); 14 | root.render(); 15 | 16 | /** 17 | * Generate page 18 | */ 19 | function Generate() { 20 | const [hover, setHover] = useState(false); 21 | const [progress, setProgress] = useState(0); 22 | const [info, setInfo] = useState(''); 23 | const [videoUrl, setVideoUrl] = useState(''); 24 | const videoRef = useRef(null); 25 | const [isPlaying, setIsPlaying] = useState(false); 26 | 27 | const handlePlay = () => { 28 | if (isPlaying) { 29 | videoRef.current?.pause(); 30 | setIsPlaying(false); 31 | } else { 32 | videoRef.current?.play(); 33 | setIsPlaying(true); 34 | } 35 | }; 36 | 37 | const handleEnded = () => { 38 | setIsPlaying(false); 39 | }; 40 | 41 | const handleFile = async (file: File) => { 42 | if (file.type !== 'application/zip' && file.type !== 'application/x-zip-compressed') { 43 | toast.error('Invalid file type, please upload a .zip file.', { 44 | position: 'top-center', 45 | autoClose: 5000, 46 | hideProgressBar: true, 47 | closeOnClick: true, 48 | pauseOnHover: true, 49 | draggable: true, 50 | theme: 'dark', 51 | }); 52 | return; 53 | } 54 | 55 | const formData = new FormData(); 56 | formData.append('file', file); 57 | 58 | setProgress(Math.round((1 / 13) * 100)); 59 | setInfo('Uploading your data package'); 60 | 61 | await fetch(import.meta.env.MODE === 'production' ? '/api/generate/upload' : 'http://localhost:3020/api/generate/upload', { 62 | method: 'POST', 63 | body: formData, 64 | }).then(async res => { 65 | const data = await res.json(); 66 | 67 | const ws = new WebSocket(import.meta.env.MODE === 'production' ? 'wss://discordwrapped.com/api/' : 'ws://localhost:3020/api/'); 68 | 69 | ws.onopen = () => { 70 | ws.send(JSON.stringify({ id: data.id })); 71 | }; 72 | 73 | ws.onmessage = async (event) => { 74 | const progressData = JSON.parse(event.data); 75 | setProgress(progressData.progress); 76 | setInfo(progressData.info); 77 | 78 | console.log(progressData); 79 | 80 | if (progressData.progress === 100) { 81 | ws.close(); 82 | 83 | const videoResponse = await fetch(import.meta.env.MODE === 'production' ? `/api/generate/download/${data.id}` : `http://localhost:3020/api/generate/download/${data.id}`, { 84 | method: 'GET', 85 | }); 86 | 87 | const videoBlob = await videoResponse.blob(); 88 | 89 | setVideoUrl(URL.createObjectURL(videoBlob)); 90 | } 91 | 92 | if (progressData.progress === 500) { 93 | ws.close(); 94 | setProgress(0); 95 | setInfo(''); 96 | 97 | toast.error(progressData.info, { 98 | position: 'top-center', 99 | autoClose: 5000, 100 | hideProgressBar: true, 101 | closeOnClick: true, 102 | pauseOnHover: true, 103 | draggable: true, 104 | theme: 'dark', 105 | }); 106 | } 107 | }; 108 | }).catch(() => { 109 | toast.error('Something went wrong, try uploading again.', { 110 | position: 'top-center', 111 | autoClose: 5000, 112 | hideProgressBar: true, 113 | closeOnClick: true, 114 | pauseOnHover: true, 115 | draggable: true, 116 | theme: 'dark', 117 | }); 118 | }); 119 | }; 120 | 121 | const handleDragOver = (event: DragEvent) => { 122 | event.preventDefault(); 123 | event.dataTransfer.dropEffect = 'copy'; 124 | setHover(true); 125 | }; 126 | 127 | const handleDragLeave = (event: DragEvent) => { 128 | event.preventDefault(); 129 | setHover(false); 130 | }; 131 | 132 | const handleDrop = (event: DragEvent) => { 133 | event.preventDefault(); 134 | const file = event.dataTransfer.items[0].getAsFile(); 135 | 136 | if (file) { 137 | handleFile(file); 138 | setHover(false); 139 | } else { 140 | toast.error('Something went wrong, try uploading instead.', { 141 | position: 'top-center', 142 | autoClose: 5000, 143 | hideProgressBar: true, 144 | closeOnClick: true, 145 | pauseOnHover: true, 146 | draggable: true, 147 | theme: 'dark', 148 | }); 149 | } 150 | }; 151 | 152 | const filePopup = () => { 153 | const input = document.createElement('input'); 154 | input.setAttribute('type', 'file'); 155 | input.setAttribute('accept', '.zip'); 156 | input.addEventListener('change', (e: any) => handleFile(e.target.files[0])); 157 | input.addEventListener('error', () => { 158 | toast.error('Something went wrong while trying to open file popup.', { 159 | position: 'top-center', 160 | autoClose: 5000, 161 | hideProgressBar: true, 162 | closeOnClick: true, 163 | pauseOnHover: true, 164 | draggable: true, 165 | theme: 'dark', 166 | }); 167 | }); 168 | input.click(); 169 | }; 170 | 171 | const downloadVideo = async () => { 172 | if (!videoUrl) { 173 | return; 174 | } 175 | 176 | const response = await fetch(videoUrl); 177 | const blob = await response.blob(); 178 | 179 | const url = window.URL.createObjectURL(new Blob([blob])); 180 | const link = document.createElement('a'); 181 | link.href = url; 182 | link.setAttribute('download', 'DiscordWrapped.mp4'); 183 | document.body.appendChild(link); 184 | link.click(); 185 | }; 186 | 187 | const shareOnTwitter = () => { 188 | const twitterUrl = 'https://twitter.com/intent/tweet?text=Check%20out%20my%20%23DiscordWrapped!%20Generate%20your%20own%3A%20https%3A%2F%2Fwww.discordwrapped.com%0A%0A%5BRemove%20this%20placeholder%2C%20then%20download%20your%20wrapped%20and%20drag%20it%20here%5D'; 189 | window.open(twitterUrl, '_blank'); 190 | }; 191 | 192 | const star = () => { 193 | const githubUrl = 'https://github.com/Assassin-1234/discord-wrapped'; 194 | window.open(githubUrl, '_blank'); 195 | }; 196 | 197 | return ( 198 |
199 | {/* Overlay */} 200 |
201 | 202 | {/* Modal */} 203 |
204 | 205 |
206 | {videoUrl ? ( 207 | <> 208 |
209 |

Your wrapped is ready

210 |
211 | 212 |
213 |
214 |
215 | 216 |
217 | 222 |
223 |
224 | 225 | 228 | 229 |
230 |
231 |
232 | 233 |
234 | 235 |
236 | 239 | 240 | 243 | 244 | 247 | 248 | 251 |
252 | 253 | ) : 254 | progress > 0 && progress < 100 ? ( 255 | <> 256 |
257 |

Generation in progress

258 |

We are generating your wrapped. The bigger your data package is, the longer it usually takes.

259 |
260 | 261 |
262 |

{info}

263 |
264 |
265 |
266 |
267 | 268 | ) : ( 269 | <> 270 |
271 |

Upload your data package

272 |

We need your data package in order to generate your wrapped. Your data package will be removed immediately after your wrapped is done.

273 |
274 | 275 |
282 | 289 |
290 |

Didn't receive your data package yet? Check out the demo on our demo page.

291 | 292 | )} 293 |
294 |
295 | 296 | {/* Clouds */} 297 | clouds 298 |
299 | ); 300 | } 301 | -------------------------------------------------------------------------------- /frontend/src/generate/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 22 | 23 | 24 | 25 | 26 | 28 | 29 | 30 | 31 | 32 | Discord Wrapped 33 | 34 | 35 | 36 | 37 |
38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | 9 | font-synthesis: none; 10 | text-rendering: optimizeLegibility; 11 | -webkit-font-smoothing: antialiased; 12 | -moz-osx-font-smoothing: grayscale; 13 | -webkit-text-size-adjust: 100%; 14 | } 15 | 16 | a { 17 | font-weight: 500; 18 | color: #5865F2; 19 | text-decoration: inherit; 20 | } 21 | 22 | a:hover { 23 | opacity: 0.8; 24 | } 25 | 26 | body { 27 | margin: 0; 28 | background: linear-gradient(180deg, rgba(88, 101, 242, 1) 0%, rgba(162, 168, 233, 0.9444152661064426) 100%); 29 | background-attachment: fixed; 30 | font-family: 'Whitney', sans-serif; 31 | } 32 | 33 | h1 { 34 | font-size: 3.2em; 35 | line-height: 1.1; 36 | } 37 | 38 | button { 39 | border-radius: 18px; 40 | border: 1px solid transparent; 41 | padding: 0.6em 1.2em; 42 | font-size: 1em; 43 | font-weight: 500; 44 | font-family: inherit; 45 | background-color: #5865F2; 46 | cursor: pointer; 47 | transition: border-color 0.25s; 48 | } 49 | 50 | button:hover { 51 | opacity: 0.9; 52 | } 53 | -------------------------------------------------------------------------------- /frontend/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 22 | 23 | 24 | 25 | 26 | 28 | 29 | 30 | 31 | Discord Wrapped 32 | 33 | 34 | 35 | 36 |
37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /frontend/src/public/demo.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Assassin-1234/discord-wrapped/7276dad54c80099df24996c7e5bff74c39fc85a9/frontend/src/public/demo.mp4 -------------------------------------------------------------------------------- /frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | // / 2 | 3 | interface ImportMeta { 4 | readonly env: any; 5 | } -------------------------------------------------------------------------------- /frontend/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | /** @type {import('tailwindcss').Config} */ 3 | module.exports = { 4 | content: [ 5 | './index.html', 6 | './src/**/*.{ts,tsx}', 7 | ], 8 | theme: { 9 | colors: { 10 | gray: '#23272A', 11 | white: '#FFFFFF', 12 | black: '#000000', 13 | blurple: '#5865F2', 14 | blurpleLight: '#A2A8E9', 15 | twitter: '#1C9CEB', 16 | github: 'linear-gradient(180deg,#f0f3f6,#e6ebf1 90%)', 17 | red: '#FF0000', 18 | kofi: '#00B9FE', 19 | }, 20 | fontFamily: { 21 | whitney: ['Whitney', 'sans-serif'], 22 | }, 23 | plugins: [], 24 | }, 25 | }; -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"], 20 | "references": [{ "path": "./tsconfig.node.json" }] 21 | } 22 | -------------------------------------------------------------------------------- /frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path'; 2 | import { defineConfig } from 'vite'; 3 | import react from '@vitejs/plugin-react-refresh'; 4 | 5 | const root = resolve(__dirname, 'src'); 6 | const outDir = resolve(__dirname, 'dist'); 7 | 8 | // https://vitejs.dev/config/ 9 | export default defineConfig({ 10 | plugins: [react()], 11 | preview: { 12 | port: 5173, 13 | }, 14 | root, 15 | build: { 16 | outDir, 17 | emptyOutDir: true, 18 | rollupOptions: { 19 | input: { 20 | main: resolve(root, 'index.html'), 21 | generate: resolve(root, 'generate', 'index.html'), 22 | demo: resolve(root, 'demo', 'index.html'), 23 | }, 24 | }, 25 | }, 26 | }); 27 | --------------------------------------------------------------------------------