├── .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 |
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 | 
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(/(?:(a):)?(\w{2,32}):(\d{17,19})?>?/);
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 |
27 |
33 |
34 |
35 | {/* Mobile Navbar */}
36 |
37 |
43 |
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 |
Generate Your Wrapped
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 |
window.open('https://ko-fi.com/assassin1234')}>
72 | Support us
73 |
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 |
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 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 | videoRef.current?.requestFullscreen()}>
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 | downloadVideo()}>
83 | Download demo video
84 |
85 |
86 |
87 |
88 |
89 | {/* Clouds */}
90 |
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 |
218 |
219 |
220 |
221 |
222 |
223 |
224 |
225 | videoRef.current?.requestFullscreen()}>
226 |
227 |
228 |
229 |
230 |
231 |
232 |
233 |
234 |
235 |
236 | downloadVideo()}>
237 | Download your wrapped
238 |
239 |
240 |
241 | Share on Twitter
242 |
243 |
244 |
245 | Star
246 |
247 |
248 | window.open('https://ko-fi.com/assassin1234')}>
249 | Support us
250 |
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 |
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 |
283 | Drop it, we'll take care of it!
284 |
285 |
286 | Drop you data package here or browse .
287 |
288 |
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 |
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 |
--------------------------------------------------------------------------------