From 61e52b0e69c96282030351ce3bef1ab61eb3224b Mon Sep 17 00:00:00 2001
From: Julien Constant <julienconstant190@gmail.com>
Date: Fri, 16 Aug 2024 04:26:34 +0200
Subject: [PATCH 01/30] version : bump to 1.0.9

---
 package-lock.json | 4 ++--
 package.json      | 2 +-
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index c16cb75a..342f0faa 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
 	"name": "faithful-mods",
-	"version": "1.0.8",
+	"version": "1.0.9",
 	"lockfileVersion": 3,
 	"requires": true,
 	"packages": {
 		"": {
 			"name": "faithful-mods",
-			"version": "1.0.8",
+			"version": "1.0.9",
 			"hasInstallScript": true,
 			"dependencies": {
 				"@auth/prisma-adapter": "^2.4.2",
diff --git a/package.json b/package.json
index 12642b58..6b624aea 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
 	"name": "faithful-mods",
-	"version": "1.0.8",
+	"version": "1.0.9",
 	"private": true,
 	"scripts": {
 		"dev": "npx cross-env NODE_ENV=development tsx watch src/server.ts",

From a5ca8313624936a665237b8b0e68fbcd96c0a349 Mon Sep 17 00:00:00 2001
From: Julien Constant <julienconstant190@gmail.com>
Date: Fri, 16 Aug 2024 04:31:36 +0200
Subject: [PATCH 02/30] fix : remove contribution owner from co-authors #132

---
 src/server/actions/faithful-pack.ts | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/server/actions/faithful-pack.ts b/src/server/actions/faithful-pack.ts
index 03b69b2e..053ad25c 100644
--- a/src/server/actions/faithful-pack.ts
+++ b/src/server/actions/faithful-pack.ts
@@ -110,6 +110,7 @@ export async function updateCachedFP(): Promise<void> {
 				})(),
 
 				coAuthors: c.authors
+					.slice(1)
 					.map((id) => users.find((u) => u.id === id))
 					.filter((u) => !!u)
 					.map((u) => ({

From fc55703f8658ff53b9abe17873a6883da6e7b3bd Mon Sep 17 00:00:00 2001
From: Julien Constant <julienconstant190@gmail.com>
Date: Sat, 17 Aug 2024 02:51:50 +0200
Subject: [PATCH 03/30] feat : add default mod texture to the git repo

---
 package-lock.json           | 166 ++++++++++++++++++++++++++++++++++++
 package.json                |   1 +
 src/auth.config.ts          |   5 ++
 src/lib/constants.ts        |   5 +-
 src/server/actions/files.ts |  31 +++++--
 src/server/actions/git.ts   | 122 ++++++++++++++++++++++++++
 src/types/index.d.ts        |   2 +
 7 files changed, 323 insertions(+), 9 deletions(-)
 create mode 100644 src/server/actions/git.ts

diff --git a/package-lock.json b/package-lock.json
index 342f0faa..82fc62eb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,6 +20,7 @@
 				"@mantine/hooks": "^7.12.0",
 				"@mantine/modals": "^7.12.0",
 				"@mantine/notifications": "^7.12.0",
+				"@octokit/rest": "^21.0.2",
 				"@types/unzipper": "^0.10.9",
 				"class-variance-authority": "^0.7.0",
 				"clsx": "^2.0.0",
@@ -1320,6 +1321,159 @@
 				"node": ">= 8"
 			}
 		},
+		"node_modules/@octokit/auth-token": {
+			"version": "5.1.1",
+			"resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-5.1.1.tgz",
+			"integrity": "sha512-rh3G3wDO8J9wSjfI436JUKzHIxq8NaiL0tVeB2aXmG6p/9859aUOAjA9pmSPNGGZxfwmaJ9ozOJImuNVJdpvbA==",
+			"license": "MIT",
+			"engines": {
+				"node": ">= 18"
+			}
+		},
+		"node_modules/@octokit/core": {
+			"version": "6.1.2",
+			"resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.2.tgz",
+			"integrity": "sha512-hEb7Ma4cGJGEUNOAVmyfdB/3WirWMg5hDuNFVejGEDFqupeOysLc2sG6HJxY2etBp5YQu5Wtxwi020jS9xlUwg==",
+			"license": "MIT",
+			"dependencies": {
+				"@octokit/auth-token": "^5.0.0",
+				"@octokit/graphql": "^8.0.0",
+				"@octokit/request": "^9.0.0",
+				"@octokit/request-error": "^6.0.1",
+				"@octokit/types": "^13.0.0",
+				"before-after-hook": "^3.0.2",
+				"universal-user-agent": "^7.0.0"
+			},
+			"engines": {
+				"node": ">= 18"
+			}
+		},
+		"node_modules/@octokit/endpoint": {
+			"version": "10.1.1",
+			"resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.1.tgz",
+			"integrity": "sha512-JYjh5rMOwXMJyUpj028cu0Gbp7qe/ihxfJMLc8VZBMMqSwLgOxDI1911gV4Enl1QSavAQNJcwmwBF9M0VvLh6Q==",
+			"license": "MIT",
+			"dependencies": {
+				"@octokit/types": "^13.0.0",
+				"universal-user-agent": "^7.0.2"
+			},
+			"engines": {
+				"node": ">= 18"
+			}
+		},
+		"node_modules/@octokit/graphql": {
+			"version": "8.1.1",
+			"resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-8.1.1.tgz",
+			"integrity": "sha512-ukiRmuHTi6ebQx/HFRCXKbDlOh/7xEV6QUXaE7MJEKGNAncGI/STSbOkl12qVXZrfZdpXctx5O9X1AIaebiDBg==",
+			"license": "MIT",
+			"dependencies": {
+				"@octokit/request": "^9.0.0",
+				"@octokit/types": "^13.0.0",
+				"universal-user-agent": "^7.0.0"
+			},
+			"engines": {
+				"node": ">= 18"
+			}
+		},
+		"node_modules/@octokit/openapi-types": {
+			"version": "22.2.0",
+			"resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-22.2.0.tgz",
+			"integrity": "sha512-QBhVjcUa9W7Wwhm6DBFu6ZZ+1/t/oYxqc2tp81Pi41YNuJinbFRx8B133qVOrAaBbF7D/m0Et6f9/pZt9Rc+tg==",
+			"license": "MIT"
+		},
+		"node_modules/@octokit/plugin-paginate-rest": {
+			"version": "11.3.3",
+			"resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.3.3.tgz",
+			"integrity": "sha512-o4WRoOJZlKqEEgj+i9CpcmnByvtzoUYC6I8PD2SA95M+BJ2x8h7oLcVOg9qcowWXBOdcTRsMZiwvM3EyLm9AfA==",
+			"license": "MIT",
+			"dependencies": {
+				"@octokit/types": "^13.5.0"
+			},
+			"engines": {
+				"node": ">= 18"
+			},
+			"peerDependencies": {
+				"@octokit/core": ">=6"
+			}
+		},
+		"node_modules/@octokit/plugin-request-log": {
+			"version": "5.3.1",
+			"resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-5.3.1.tgz",
+			"integrity": "sha512-n/lNeCtq+9ofhC15xzmJCNKP2BWTv8Ih2TTy+jatNCCq/gQP/V7rK3fjIfuz0pDWDALO/o/4QY4hyOF6TQQFUw==",
+			"license": "MIT",
+			"engines": {
+				"node": ">= 18"
+			},
+			"peerDependencies": {
+				"@octokit/core": ">=6"
+			}
+		},
+		"node_modules/@octokit/plugin-rest-endpoint-methods": {
+			"version": "13.2.4",
+			"resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-13.2.4.tgz",
+			"integrity": "sha512-gusyAVgTrPiuXOdfqOySMDztQHv6928PQ3E4dqVGEtOvRXAKRbJR4b1zQyniIT9waqaWk/UDaoJ2dyPr7Bk7Iw==",
+			"license": "MIT",
+			"dependencies": {
+				"@octokit/types": "^13.5.0"
+			},
+			"engines": {
+				"node": ">= 18"
+			},
+			"peerDependencies": {
+				"@octokit/core": ">=6"
+			}
+		},
+		"node_modules/@octokit/request": {
+			"version": "9.1.3",
+			"resolved": "https://registry.npmjs.org/@octokit/request/-/request-9.1.3.tgz",
+			"integrity": "sha512-V+TFhu5fdF3K58rs1pGUJIDH5RZLbZm5BI+MNF+6o/ssFNT4vWlCh/tVpF3NxGtP15HUxTTMUbsG5llAuU2CZA==",
+			"license": "MIT",
+			"dependencies": {
+				"@octokit/endpoint": "^10.0.0",
+				"@octokit/request-error": "^6.0.1",
+				"@octokit/types": "^13.1.0",
+				"universal-user-agent": "^7.0.2"
+			},
+			"engines": {
+				"node": ">= 18"
+			}
+		},
+		"node_modules/@octokit/request-error": {
+			"version": "6.1.4",
+			"resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.4.tgz",
+			"integrity": "sha512-VpAhIUxwhWZQImo/dWAN/NpPqqojR6PSLgLYAituLM6U+ddx9hCioFGwBr5Mi+oi5CLeJkcAs3gJ0PYYzU6wUg==",
+			"license": "MIT",
+			"dependencies": {
+				"@octokit/types": "^13.0.0"
+			},
+			"engines": {
+				"node": ">= 18"
+			}
+		},
+		"node_modules/@octokit/rest": {
+			"version": "21.0.2",
+			"resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-21.0.2.tgz",
+			"integrity": "sha512-+CiLisCoyWmYicH25y1cDfCrv41kRSvTq6pPWtRroRJzhsCZWZyCqGyI8foJT5LmScADSwRAnr/xo+eewL04wQ==",
+			"license": "MIT",
+			"dependencies": {
+				"@octokit/core": "^6.1.2",
+				"@octokit/plugin-paginate-rest": "^11.0.0",
+				"@octokit/plugin-request-log": "^5.3.1",
+				"@octokit/plugin-rest-endpoint-methods": "^13.0.0"
+			},
+			"engines": {
+				"node": ">= 18"
+			}
+		},
+		"node_modules/@octokit/types": {
+			"version": "13.5.0",
+			"resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.5.0.tgz",
+			"integrity": "sha512-HdqWTf5Z3qwDVlzCrP8UJquMwunpDiMPt5er+QjGzL4hqr/vBVY/MauQgS1xWxCDT1oMx1EULyqxncdCY/NVSQ==",
+			"license": "MIT",
+			"dependencies": {
+				"@octokit/openapi-types": "^22.2.0"
+			}
+		},
 		"node_modules/@panva/hkdf": {
 			"version": "1.1.1",
 			"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.1.1.tgz",
@@ -2000,6 +2154,12 @@
 				"node": "^4.5.0 || >= 5.9"
 			}
 		},
+		"node_modules/before-after-hook": {
+			"version": "3.0.2",
+			"resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-3.0.2.tgz",
+			"integrity": "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==",
+			"license": "Apache-2.0"
+		},
 		"node_modules/binary-extensions": {
 			"version": "2.3.0",
 			"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -7234,6 +7394,12 @@
 			"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
 			"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="
 		},
+		"node_modules/universal-user-agent": {
+			"version": "7.0.2",
+			"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.2.tgz",
+			"integrity": "sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q==",
+			"license": "ISC"
+		},
 		"node_modules/universalify": {
 			"version": "2.0.1",
 			"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
diff --git a/package.json b/package.json
index 6b624aea..9c4895ad 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
 		"@mantine/hooks": "^7.12.0",
 		"@mantine/modals": "^7.12.0",
 		"@mantine/notifications": "^7.12.0",
+		"@octokit/rest": "^21.0.2",
 		"@types/unzipper": "^0.10.9",
 		"class-variance-authority": "^0.7.0",
 		"clsx": "^2.0.0",
diff --git a/src/auth.config.ts b/src/auth.config.ts
index 96f4112f..7d85b1a2 100644
--- a/src/auth.config.ts
+++ b/src/auth.config.ts
@@ -10,6 +10,11 @@ export default {
 		Github({
 			clientId: process.env.GITHUB_CLIENT_ID,
 			clientSecret: process.env.GITHUB_CLIENT_SECRET,
+			authorization: {
+				params: {
+					scope: 'read:user user:email public_repo',
+				},
+			},
 		}),
 	],
 } satisfies NextAuthConfig;
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index d0511810..220b90b0 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -105,5 +105,6 @@ export const COLORS: Record<Status, MantineColor> = {
 	[Status.ARCHIVED]: 'gainsboro',
 };
 
-// export const DRAFT_COLOR: MantineColor = 'gray';
-// export const PENDING
+export const GITHUB_ORG_NAME = 'faithful-mods';
+export const GITHUB_DEFAULT_REPO_NAME = 'resources-default';
+export const FILE_GIT = `https://raw.githubusercontent.com/${GITHUB_ORG_NAME}/${GITHUB_DEFAULT_REPO_NAME}/${process.env.NODE_ENV === 'production' ? 'main' : 'dev'}`;
diff --git a/src/server/actions/files.ts b/src/server/actions/files.ts
index e1750149..83650953 100644
--- a/src/server/actions/files.ts
+++ b/src/server/actions/files.ts
@@ -8,12 +8,13 @@ import { join } from 'path';
 import TOML from '@ltd/j-toml';
 import unzipper from 'unzipper';
 
-import { FILE_DIR, FILE_PATH } from '~/lib/constants';
+import { FILE_DIR, FILE_GIT, FILE_PATH } from '~/lib/constants';
 import { db } from '~/lib/db';
 import { calculateHash } from '~/lib/hash';
 import { socket } from '~/lib/serversocket';
-import { bufferToFile, sortBySemver } from '~/lib/utils';
+import { sortBySemver } from '~/lib/utils';
 
+import { uploadToRepository } from './git';
 import { createMod, updateModPicture } from '../data/mods';
 import { createModVersion } from '../data/mods-version';
 import { linkTextureToResource, createResource, getResource } from '../data/resource';
@@ -21,7 +22,7 @@ import { createTexture, findTexture } from '../data/texture';
 
 import type { ModVersion } from '@prisma/client';
 import type { CentralDirectory } from 'unzipper';
-import type { MCModInfoData, ModData, ModFabricJson, ModFabricJsonPerson, ModsToml, SocketModUpload } from '~/types';
+import type { base64, MCModInfoData, ModData, ModFabricJson, ModFabricJsonPerson, ModsToml, SocketModUpload } from '~/types';
 
 /**
  * Uploads a file to the server
@@ -245,7 +246,6 @@ export async function extractModVersionsFromJAR(jar: File, socketId: string, sta
 			});
 		}
 
-		console.log(modInfo.picture, mod.image);
 		if (modInfo.picture && !mod.image) {
 			const formData = new FormData();
 			formData.append('file', new Blob([modInfo.picture]), 'picture.png');
@@ -305,7 +305,10 @@ export async function extractDefaultResourcePack(jar: File, modVersion: ModVersi
 	const fileDirPrv = join(FILE_PATH, 'textures', 'default');
 	if (!existsSync(fileDirPrv)) mkdirSync(fileDirPrv, { recursive: true });
 
-	// Check if the extracted file already exists in the public dir
+	const filesToCommit: base64[] = [];
+	const filesNamesOnGit: string[] = [];
+
+	// Check if the extracted file already exists in the database
 	for (const textureAsset of textureAssets) {
 		const textureName = textureAsset.path.split('/').pop()!.split('.')[0] ?? 'unknown';
 		const asset = textureAsset.path.split('/')[1] ?? 'unknown';
@@ -316,7 +319,6 @@ export async function extractDefaultResourcePack(jar: File, modVersion: ModVersi
 		let texture = await findTexture({ hash });
 
 		if (!texture) {
-			const filepath = await upload(bufferToFile(buffer, `${textureName}.png`, 'image/png'), 'textures/default/');
 			const mcmetaFile = mcmetaAssets.find((mcmeta) => `${textureAsset.path}.mcmeta` === mcmeta.path);
 
 			let mcmeta = undefined;
@@ -337,11 +339,17 @@ export async function extractDefaultResourcePack(jar: File, modVersion: ModVersi
 			}
 
 			texture = await createTexture({
-				filepath,
+				filepath: '',
 				hash,
 				name: textureName,
 				mcmeta,
 			});
+
+			const filepath = `${FILE_GIT}/${texture.id}.png` as const;
+			await db.texture.update({ where: { id: texture.id }, data: { filepath } });
+
+			filesToCommit.push(buffer.toString('base64') as base64);
+			filesNamesOnGit.push(`${texture.id}.png`);
 		}
 		else {
 			if (texture.name !== textureName && !texture.aliases.includes(textureName)) {
@@ -360,5 +368,14 @@ export async function extractDefaultResourcePack(jar: File, modVersion: ModVersi
 		socket?.emit(socketId, status);
 	}
 
+	const mod = await db.mod.findFirstOrThrow({ where: { id: modVersion.modId } });
+
+	// push new files to git
+	await uploadToRepository(
+		filesToCommit,
+		filesNamesOnGit,
+		`textures: add default textures for ${mod.name} \`${modVersion.version}\``
+	);
+
 	return status;
 }
diff --git a/src/server/actions/git.ts b/src/server/actions/git.ts
new file mode 100644
index 00000000..f52dc4f3
--- /dev/null
+++ b/src/server/actions/git.ts
@@ -0,0 +1,122 @@
+'use server';
+import 'server-only';
+
+import { Octokit } from '@octokit/rest';
+import { UserRole } from '@prisma/client';
+
+import { auth } from '~/auth';
+import { canAccess } from '~/lib/auth';
+import { GITHUB_DEFAULT_REPO_NAME, GITHUB_ORG_NAME } from '~/lib/constants';
+import { db } from '~/lib/db';
+
+import type { base64 } from '~/types';
+
+export async function uploadToRepository(files: base64[], filenames: string[], commitMessage: string): Promise<void> {
+	await canAccess(UserRole.COUNCIL);
+
+	const session = await auth();
+	const user = session?.user!; // We know the user is logged in because of the canAccess check
+
+	// Authenticate with GitHub API using current logged user's access token
+	const userToken = await db.account.findFirstOrThrow({ where: { userId: user.id }, select: { access_token: true } });
+	const octokit = new Octokit({
+		auth: userToken.access_token,
+	});
+
+	const branch = process.env.NODE_ENV === 'production' ? 'main' : 'dev';
+
+	// get latest commit
+	const currentCommit = await getCurrentCommit(octokit, branch);
+
+	// create blobs for each file
+	const filesBlobs = await Promise.all(files.map((file) => createBlobFile(octokit, file)));
+
+	// create new tree with the blobs
+	const newTree = await createNewTree(octokit, filenames, filesBlobs, currentCommit.tree_sha);
+
+	// create a new commit with the new tree
+	const newCommit = await createCommit(octokit, commitMessage, newTree.sha, currentCommit.commit_sha);
+
+	// update the branch to point to the new commit
+	await setBranchToCommit(octokit, newCommit.sha, branch);
+}
+
+async function setBranchToCommit(octokit: Octokit, commitSha: string, branch: string) {
+	await octokit.git.updateRef({
+		owner: GITHUB_ORG_NAME,
+		repo: GITHUB_DEFAULT_REPO_NAME,
+		ref: `heads/${branch}`,
+		sha: commitSha,
+	});
+}
+
+async function createCommit(octokit: Octokit, message: string, treeSha: string, parentCommitSha: string) {
+	const { data } = await octokit.git.createCommit({
+		owner: GITHUB_ORG_NAME,
+		repo: GITHUB_DEFAULT_REPO_NAME,
+		message,
+		tree: treeSha,
+		parents: [parentCommitSha],
+	});
+
+	return data;
+}
+
+/**
+ * Create a new tree with the given blobs
+ */
+async function createNewTree(octokit: Octokit, filenames: string[], blobs: { url: string, sha: string }[], parentTreeSha: string) {
+	const tree = blobs.map(({ sha }, index) => {
+		return {
+			path: filenames[index],
+			mode: '100644',
+			type: 'blob',
+			sha,
+		} as const;
+	});
+
+	const { data } = await octokit.git.createTree({
+		owner: GITHUB_ORG_NAME,
+		repo: GITHUB_DEFAULT_REPO_NAME,
+		base_tree: parentTreeSha,
+		tree,
+	});
+
+	return data;
+}
+
+/**
+ * Prepare a github blob for a file
+ */
+async function createBlobFile(octokit: Octokit, file: base64) {
+	const blobData = await octokit.git.createBlob({
+		owner: GITHUB_ORG_NAME,
+		repo: GITHUB_DEFAULT_REPO_NAME,
+		content: file,
+		encoding: 'base64',
+	});
+
+	return blobData.data;
+}
+
+/**
+ * Fetch the current commit of the given repository and branch
+ */
+async function getCurrentCommit(octokit: Octokit, branch: string) {
+	const { data: refData } = await octokit.git.getRef({
+		owner: GITHUB_ORG_NAME,
+		repo: GITHUB_DEFAULT_REPO_NAME,
+		ref: `heads/${branch}`,
+	});
+
+	const { data: commitData } = await octokit.git.getCommit({
+		owner: GITHUB_ORG_NAME,
+		repo: GITHUB_DEFAULT_REPO_NAME,
+		commit_sha: refData.object.sha,
+	});
+
+	return {
+		commit_sha: commitData.sha,
+		tree_sha: commitData.tree.sha,
+	};
+}
diff --git a/src/types/index.d.ts b/src/types/index.d.ts
index 6cec9b9d..3582832b 100644
--- a/src/types/index.d.ts
+++ b/src/types/index.d.ts
@@ -355,3 +355,5 @@ export type FPUser = {
 	username?: string;
 	uuid?: string;
 }
+
+export type base64 = `base64:${string}`;

From 962504fee5a5800000dbe52a4231dd6410583674 Mon Sep 17 00:00:00 2001
From: Julien Constant <julienconstant190@gmail.com>
Date: Sat, 17 Aug 2024 02:59:55 +0200
Subject: [PATCH 04/30] feat : archive contributions who's texture has been
 deleted

---
 src/server/data/resource.ts | 4 +++-
 src/server/data/texture.ts  | 7 ++++++-
 2 files changed, 9 insertions(+), 2 deletions(-)

diff --git a/src/server/data/resource.ts b/src/server/data/resource.ts
index 0e4bbc64..ea41984b 100644
--- a/src/server/data/resource.ts
+++ b/src/server/data/resource.ts
@@ -6,6 +6,7 @@ import { UserRole } from '@prisma/client';
 import { canAccess } from '~/lib/auth';
 import { db } from '~/lib/db';
 
+import { deleteLinkedTexture } from './linked-textures';
 import { deleteTexture } from './texture';
 
 import type { Resource } from '@prisma/client';
@@ -79,12 +80,13 @@ export async function deleteResource(id: string): Promise<Resource> {
 
 	const linkedTextures = await db.linkedTexture.findMany({ where: { resourceId: id } });
 	for (const linkedTexture of linkedTextures) {
-		await db.linkedTexture.delete({ where: { id: linkedTexture.id } });
+		await deleteLinkedTexture(linkedTexture.id);
 
 		const texture = await db.texture.findUnique({
 			where: { id: linkedTexture.textureId },
 			include: { linkedTextures: true },
 		});
+
 		if (texture && texture.linkedTextures.length === 0) await deleteTexture(texture.id);
 	}
 
diff --git a/src/server/data/texture.ts b/src/server/data/texture.ts
index c686a8ad..2b2dac01 100644
--- a/src/server/data/texture.ts
+++ b/src/server/data/texture.ts
@@ -1,7 +1,7 @@
 'use server';
 import 'server-only';
 
-import { Resolution, UserRole } from '@prisma/client';
+import { Resolution, Status, UserRole } from '@prisma/client';
 
 import { canAccess } from '~/lib/auth';
 import { db } from '~/lib/db';
@@ -220,11 +220,16 @@ export async function deleteTexture(id: number): Promise<Texture> {
 	await canAccess(UserRole.COUNCIL);
 
 	// Delete on disk
+	// @deprecated (moved to git repository)
 	const textureFile = await db.texture.findUnique({ where: { id } }).then((texture) => texture?.filepath);
 	if (textureFile) await remove(textureFile as `/files/${string}`);
 
 	// Contributions
 	await db.contributionDeactivation.deleteMany({ where: { textureId: id } });
+	await db.contribution.updateMany({
+		where: { textureId: id },
+		data: { textureId: null, status: Status.ARCHIVED },
+	});
 
 	// Delete in database
 	return db.texture.delete({ where: { id } });

From 640c882773b5e72c6e883a7666b694db3530cb3e Mon Sep 17 00:00:00 2001
From: Julien Constant <julienconstant190@gmail.com>
Date: Sun, 18 Aug 2024 00:49:57 +0200
Subject: [PATCH 05/30] feat : (part 1) new contribution system using git

---
 .../(protected)/contribute/about/page.tsx     |  52 +-
 .../(pages)/(protected)/contribute/layout.tsx |  62 +--
 .../(pages)/(protected)/contribute/page.tsx   |   2 +-
 .../(protected)/contribute/settings/page.tsx  | 127 +++++
 .../contribute/submissions/page.tsx           | 312 ++++++++++++
 .../submit.scss => submissions/styles.scss}   |   0
 .../contribute/submit/co-authors-select.tsx   |  63 ---
 .../contribute/submit/contribution-item.tsx   | 124 -----
 .../contribute/submit/contribution-modal.tsx  | 455 ------------------
 .../contribute/submit/contribution-tools.tsx  | 172 -------
 .../contribute/submit/delete-modal.tsx        |  66 ---
 .../(protected)/contribute/submit/page.tsx    | 278 -----------
 .../(protected)/council/submissions/page.tsx  |  11 +-
 src/app/(pages)/mods/[modId]/gallery/page.tsx |   4 +-
 src/auth.config.ts                            |   2 +-
 src/components/small-tile.tsx                 |   2 +-
 src/components/texture-contribution.tsx       |  10 +-
 src/lib/constants.ts                          |  37 +-
 src/server/actions/files.ts                   |   4 +-
 src/server/actions/git.ts                     | 293 ++++++++++-
 src/server/data/contributions.ts              | 266 ++++------
 src/types/index.d.ts                          |  13 +-
 22 files changed, 868 insertions(+), 1487 deletions(-)
 create mode 100644 src/app/(pages)/(protected)/contribute/settings/page.tsx
 create mode 100644 src/app/(pages)/(protected)/contribute/submissions/page.tsx
 rename src/app/(pages)/(protected)/contribute/{submit/submit.scss => submissions/styles.scss} (100%)
 delete mode 100644 src/app/(pages)/(protected)/contribute/submit/co-authors-select.tsx
 delete mode 100644 src/app/(pages)/(protected)/contribute/submit/contribution-item.tsx
 delete mode 100644 src/app/(pages)/(protected)/contribute/submit/contribution-modal.tsx
 delete mode 100644 src/app/(pages)/(protected)/contribute/submit/contribution-tools.tsx
 delete mode 100644 src/app/(pages)/(protected)/contribute/submit/delete-modal.tsx
 delete mode 100644 src/app/(pages)/(protected)/contribute/submit/page.tsx

diff --git a/src/app/(pages)/(protected)/contribute/about/page.tsx b/src/app/(pages)/(protected)/contribute/about/page.tsx
index c336556e..089a1d45 100644
--- a/src/app/(pages)/(protected)/contribute/about/page.tsx
+++ b/src/app/(pages)/(protected)/contribute/about/page.tsx
@@ -1,52 +1,6 @@
-'use client';
-
-import { Button, Badge, Text, Checkbox } from '@mantine/core';
-import { useLocalStorage } from '@mantine/hooks';
-
-import { Tile } from '~/components/tile';
-import { useDeviceSize } from '~/hooks/use-device-size';
-import { BREAKPOINT_MOBILE_LARGE, COLORS } from '~/lib/constants';
-
-const ContributeAboutPage = () => {
-	const [windowWidth] = useDeviceSize();
-
-	const [showAboutPage, setShowAboutPage] = useLocalStorage({
-		key: 'faithful-modded-show-contribute-about-page',
-		defaultValue: true,
-	});
 
+export default function ContributeAboutPage() {
 	return (
-		<>
-			{/* <Text  size="md" fw={700} mb="sm">Submission Process</Text> */}
-			<Tile>
-				{windowWidth > BREAKPOINT_MOBILE_LARGE && <Button pos="absolute" right="var(--mantine-spacing-md)" disabled>Apply for Council</Button>}
-				<Text size="sm">
-				Once submitted, your submissions are subject to a voting process by the council and their decision is final.<br />
-				When all counselors have voted, the following will happen:
-				</Text>
-				<ul>
-					<Text size="sm" component="li">
-					If the contribution has more upvotes than downvotes, it will be <Badge component="span" color="teal">accepted</Badge>
-					</Text>
-					<Text size="sm" component="li">
-					If there is more downvotes or the same amount of upvotes and downvotes, it will be <Badge component="span" color={COLORS.REJECTED}>rejected</Badge> and deleted after a 6-month period.
-					</Text>
-				</ul>
-				<Text size="sm">
-				You can edit your submissions as many times as you like. <br/>
-				Note that if you edit your contribution (even when rejected), its status will be reset to <Badge component="span" color={COLORS.DRAFT}>draft</Badge> and will need to be re-submitted and re-voted on.
-				</Text>
-				{windowWidth <= BREAKPOINT_MOBILE_LARGE && <Button mt="sm" disabled>Apply for Council</Button>}
-
-				<Checkbox
-					mt="md"
-					checked={!showAboutPage}
-					onChange={(e) => setShowAboutPage(!e.target.checked)}
-					label={<Text size="sm" c="dimmed">I have read and understood the submission process, don&apos;t show this page again</Text>}
-				/>
-			</Tile>
-		</>
+		'WIP'
 	);
-};
-
-export default ContributeAboutPage;
+}
diff --git a/src/app/(pages)/(protected)/contribute/layout.tsx b/src/app/(pages)/(protected)/contribute/layout.tsx
index 8c287aff..d3712e59 100644
--- a/src/app/(pages)/(protected)/contribute/layout.tsx
+++ b/src/app/(pages)/(protected)/contribute/layout.tsx
@@ -1,58 +1,22 @@
 'use client';
 
-import { GoAlert } from 'react-icons/go';
-
-import { CloseButton, Group, Text } from '@mantine/core';
-import { useLocalStorage } from '@mantine/hooks';
-
 import { TabsLayout } from '~/components/tabs';
-import { Tile } from '~/components/tile';
 
-interface ProtectedLayoutProps {
+interface Props {
 	children: React.ReactNode;
-};
-
-const ContributeLayout = ({ children }: ProtectedLayoutProps) => {
-	const [isTOSShown, setShown] = useLocalStorage({
-		key: 'faithful-mods-contribute-tos',
-		defaultValue: true,
-	});
-
-	const tabs = [
-		{ value: 'about', label: 'About' },
-		{ value: 'submit', label: 'Submit' },
-	];
+}
 
+export default function ContributeLayout({ children }: Props) {
 	return (
-		<>
-			{isTOSShown && (
-				<Tile color="yellow" mb="md" p="xs" pl="md">
-					<Group justify="space-between">
-						<Group gap="xs" wrap="nowrap">
-							<GoAlert color="black" />
-							<Text size="sm" c="black" >
-								By contributing to the platform, you agree to the <Text component="a" href="/docs/tos" c="brown" target="_blank">Terms of Service</Text>.<br />
-							</Text>
-						</Group>
-						<CloseButton
-							variant="transparent"
-							style={{ color: 'black' }}
-							onClick={() => setShown(false)}
-						/>
-					</Group>
-				</Tile>
-			)}
-
-			<TabsLayout
-				tabs={tabs}
-				defaultValue="about"
-				variant="filled"
-				noMargin
-			>
-				{children}
-			</TabsLayout>
-		</>
+		<TabsLayout
+			tabs={[
+				{ value: 'about', label: 'About' },
+				{ value: 'submissions', label: 'Submissions' },
+				{ value: 'settings', label: 'Settings' },
+			]}
+		>
+			{children}
+		</TabsLayout>
 	);
-};
 
-export default ContributeLayout;
+}
diff --git a/src/app/(pages)/(protected)/contribute/page.tsx b/src/app/(pages)/(protected)/contribute/page.tsx
index 8aac30c4..8194e6a9 100644
--- a/src/app/(pages)/(protected)/contribute/page.tsx
+++ b/src/app/(pages)/(protected)/contribute/page.tsx
@@ -12,7 +12,7 @@ const CouncilPage = () => {
 	});
 
 	if (isAboutShown) redirect('/contribute/about');
-	redirect('/contribute/submit');
+	redirect('/contribute/submissions');
 };
 
 export default CouncilPage;
diff --git a/src/app/(pages)/(protected)/contribute/settings/page.tsx b/src/app/(pages)/(protected)/contribute/settings/page.tsx
new file mode 100644
index 00000000..71abb7dc
--- /dev/null
+++ b/src/app/(pages)/(protected)/contribute/settings/page.tsx
@@ -0,0 +1,127 @@
+'use client';
+
+import Link from 'next/link';
+
+import { useState, useTransition } from 'react';
+
+import { GoCheckCircle, GoStop } from 'react-icons/go';
+
+import { Button, Group, Stack, Text } from '@mantine/core';
+
+import { Tile } from '~/components/tile';
+import { useEffectOnce } from '~/hooks/use-effect-once';
+import { getFork, deleteFork, forkRepository } from '~/server/actions/git';
+
+export default function ContributeSettingsPage() {
+	const [forked, setHasFork] = useState<string | null>(null);
+	const [loading, startTransition] = useTransition();
+
+	useEffectOnce(() => {
+		reload();
+	});
+
+	const reload = async () => {
+		startTransition(async () => {
+			setHasFork(await getFork());
+		});
+	};
+
+	const handleSetupForkedRepository = async () => {
+		startTransition(async () => {
+			await forkRepository();
+			await reload();
+		});
+	};
+
+	const handleForkDelete = async () => {
+		startTransition(async () => {
+			await deleteFork();
+			await reload();
+		});
+	};
+
+	const forkedInfo = () => {
+		if (forked) {
+			return (
+				<Tile p="xs" pl="md" color="teal">
+					<Group justify="space-between">
+						<Group>
+							<GoCheckCircle size={20} color="white"/>
+							<Group gap={3}>
+								<Text size="sm" c="white">Default textures repository forked: </Text>
+								<Text size="sm" c="white"><Link href={forked} style={{ color: 'white' }}>{forked}</Link></Text>
+							</Group>
+						</Group>
+
+						<Group gap="xs">
+							<Button variant="outline" color="white">Sync Fork</Button>
+						</Group>
+					</Group>
+				</Tile>
+			);
+		}
+
+		return (
+			<Tile p="xs" pl="md" color="yellow">
+				<Group justify="space-between">
+					<Group>
+						<GoStop color="black" size={20} />
+						<Group gap="xs">
+							<Text size="sm" c="black">Default textures repository not forked</Text>
+						</Group>
+					</Group>
+
+					<Group gap="xs">
+						<Button
+							variant="outline"
+							color="black"
+							onClick={handleSetupForkedRepository}
+							disabled={!!forked}
+							loading={loading}
+						>
+								Create Fork
+						</Button>
+					</Group>
+				</Group>
+			</Tile>
+		);
+	};
+
+	return (
+		<Stack gap="xl">
+			<Stack gap="xs">
+				<Text fw={700}>General</Text>
+				{forkedInfo()}
+			</Stack>
+
+			<Stack gap="xs">
+				<Text fw={700}>Danger Zone</Text>
+				<Tile
+					p="xs"
+					pl="md"
+					withBorder
+					style={{
+						backgroundColor: 'transparent',
+						borderColor: 'var(--mantine-color-red-filled)',
+					}}
+				>
+					<Group justify="space-between">
+						<Stack gap={0}>
+							<Text>Delete the forked repository</Text>
+							<Text c="dimmed" size="xs">This action is irreversible, all contributions will be lost.</Text>
+						</Stack>
+						<Button
+							variant="default"
+							style={{ color: 'var(--mantine-color-red-text)' }}
+							onClick={handleForkDelete}
+							disabled={!forked}
+							loading={loading}
+						>
+							Delete Fork
+						</Button>
+					</Group>
+				</Tile>
+			</Stack>
+		</Stack>
+	);
+}
diff --git a/src/app/(pages)/(protected)/contribute/submissions/page.tsx b/src/app/(pages)/(protected)/contribute/submissions/page.tsx
new file mode 100644
index 00000000..cc6f1edc
--- /dev/null
+++ b/src/app/(pages)/(protected)/contribute/submissions/page.tsx
@@ -0,0 +1,312 @@
+'use client';
+
+import { useCallback, useEffect, useState, useTransition } from 'react';
+
+import { GoCommit, GoHash, GoHourglass, GoRelFilePath } from 'react-icons/go';
+import { IoReload } from 'react-icons/io5';
+import { LuArrowUpDown } from 'react-icons/lu';
+
+import { ActionIcon, Button, FloatingIndicator, Group, Indicator, Select, Stack, Text } from '@mantine/core';
+import { usePrevious } from '@mantine/hooks';
+import { Resolution, Status } from '@prisma/client';
+
+import { SmallTile } from '~/components/small-tile';
+import { TextureImage } from '~/components/texture-img';
+import { useCurrentUser } from '~/hooks/use-current-user';
+import { useDeviceSize } from '~/hooks/use-device-size';
+import { useEffectOnce } from '~/hooks/use-effect-once';
+import { BREAKPOINT_MOBILE_LARGE, COLORS, gitBlobUrl, gitCommitUrl } from '~/lib/constants';
+import { getContributionsOfFork, getFork } from '~/server/actions/git';
+import { createContributionsFromGitFiles, deleteContributionsOrArchive, getContributionsOfUser } from '~/server/data/contributions';
+import { getTextures } from '~/server/data/texture';
+
+import type { Texture } from '@prisma/client';
+import type { GitFile } from '~/server/actions/git';
+import type { GetContributionsOfUser } from '~/server/data/contributions';
+
+import './styles.scss';
+
+export default function ContributeSubmissionsPage() {
+	const user = useCurrentUser()!; // the user is guaranteed to be logged in (per the layout)
+
+	const [loading, startTransition] = useTransition();
+	const [hasFork, setHasFork] = useState<string | null>(null);
+
+	const [resolution, setResolution] = useState<Resolution>(Resolution.x32);
+	const prevRes = usePrevious(resolution);
+
+	const [contributions, setContributions] = useState<GetContributionsOfUser[]>([]);
+	const [textures, setTextures] = useState<Texture[]>([]);
+
+	const [groupRef, setGroupRef] = useState<HTMLDivElement | null>(null);
+	const [controlsRefs, setControlsRefs] = useState<Record<string, HTMLButtonElement | null>>({});
+	const [activeTab, setActiveTab] = useState(0);
+	const [windowWidth] = useDeviceSize();
+
+	const setControlRef = (index: number) => (node: HTMLButtonElement) => {
+		controlsRefs[index] = node;
+		setControlsRefs(controlsRefs);
+	};
+
+	const reload = async () => {
+		startTransition(async () => {
+			const fork = await getFork();
+			if (!fork) return;
+
+			setHasFork(fork);
+			getContributionsOfFork(resolution).then(updateForkContributions);
+		});
+	};
+
+	const updateForkContributions = useCallback(async (files: GitFile[]) => {
+		const contributions = await getContributionsOfUser(user.id!, resolution);
+		const contributedSha = contributions.map((contribution) => contribution.hash);
+
+		// delete contributions that are not in the fork but are in the database
+		const missingFiles = contributions.filter((contribution) => !files.some((file) => file.sha === contribution.hash));
+		await deleteContributionsOrArchive(user.id!, missingFiles.map((contribution) => contribution.id));
+
+		// add contributions that are not yet in the database
+		const newFiles = files.filter((file) => !contributedSha.includes(file.sha));
+		await createContributionsFromGitFiles(user.id!, resolution, newFiles);
+
+		const contributionsAfter = await getContributionsOfUser(user.id!, resolution);
+		setContributions(contributionsAfter);
+
+	}, [user, resolution]);
+
+	useEffectOnce(() => {
+		reload();
+		getTextures().then(setTextures);
+	});
+
+	useEffect(() => {
+		if (prevRes === resolution) return;
+
+		startTransition(() => {
+			getContributionsOfFork(resolution).then(updateForkContributions);
+		});
+	}, [resolution, prevRes, updateForkContributions]);
+
+	const controls = Object.entries(Status).map(([key, value], index) => {
+		const isLast = index === Object.keys(Status).length - 1;
+
+		return (
+			<Button
+				key={key}
+				ref={setControlRef(index)}
+				onClick={() => setActiveTab(index)}
+				mod={{ active: activeTab === index }}
+
+				pl="md"
+				pr="sm"
+				fullWidth
+				variant="filled"
+				leftSection={
+					<>
+						<Indicator color={COLORS[value]} mr="md" />
+						{value === Status.DRAFT
+							? 'Drafted'
+							: value === Status.PENDING
+								? 'Reviewed'
+								: value.charAt(0).toUpperCase() + value.slice(1).toLowerCase()
+						}
+					</>
+				}
+				rightSection={0}
+				justify="space-between"
+				className="slider-button"
+				style={windowWidth > BREAKPOINT_MOBILE_LARGE
+					? {
+						borderRight: !isLast && activeTab !== index + 1 && activeTab !== index
+							? 'calc(0.0625rem * var(--mantine-scale)) solid var(--mantine-color-default-border)'
+							: undefined,
+						borderRadius: isLast ? '0 calc(0.25rem * var(--mantine-scale)) calc(0.25rem * var(--mantine-scale)) 0' : undefined,
+					}
+					: undefined
+				}
+			/>
+		);
+	});
+
+	return (
+		<Stack gap="xs">
+			<Group
+				wrap="nowrap"
+				gap="xs"
+			>
+				<ActionIcon
+					variant="default"
+					className="navbar-icon-fix"
+					onClick={reload}
+					loading={loading}
+				>
+					<IoReload />
+				</ActionIcon>
+				<Select
+					w={120}
+					data={Object.keys(Resolution)}
+					checkIconPosition="right"
+					value={resolution}
+					onChange={(e) => e ? setResolution(e as Resolution) : null}
+					clearable={false}
+				/>
+				<Button.Group
+					w="calc(100% - 120px - var(--mantine-spacing-xs))"
+					ref={setGroupRef}
+					style={{
+						position: 'relative',
+					}}
+					orientation={windowWidth <= BREAKPOINT_MOBILE_LARGE ? 'vertical' : 'horizontal'}
+				>
+					{controls}
+
+					<FloatingIndicator
+						target={controlsRefs[activeTab]}
+						parent={groupRef}
+						style={{
+							border: 'calc(0.0625rem * var(--mantine-scale)) solid #fff3',
+							borderRadius: (() => {
+								if (activeTab === 0) return 'calc(0.25rem * var(--mantine-scale)) 0 0 calc(0.25rem * var(--mantine-scale))';
+								if (activeTab === Object.keys(Status).length - 1) return '0 calc(0.25rem * var(--mantine-scale)) calc(0.25rem * var(--mantine-scale)) 0';
+								return '0';
+							})(),
+							backgroundColor: '#0002',
+							cursor: 'pointer',
+							zIndex: 200,
+						}}
+					/>
+
+				</Button.Group>
+			</Group>
+
+			{hasFork === null && (
+				<Group
+					align="center"
+					justify="center"
+					h="100px"
+					w="100%"
+					style={{ height: 'calc(81% - (2 * var(--mantine-spacing-sm) - 62px))' }}
+				>
+					<Text c="dimmed">You need to fork the default repository first</Text>
+				</Group>
+			)}
+
+			{hasFork && (
+				<Group gap="xs">
+					{contributions.map((contribution) => {
+						const texture = textures.find((t) => t.id === contribution.textureId);
+						const orgOrUser = contribution.filepath.split('/')[3]!;
+						const repository = contribution.filepath.split('/')[4]!;
+						const commitSha = contribution.filepath.split('/')[5]!;
+
+						return (
+							(
+								<TextureImage
+									key={contribution.id}
+									src={contribution.filepath}
+									alt={contribution.filename}
+									popupStyles={{
+										backgroundColor: 'transparent',
+										padding: 0,
+										border: 'none',
+										boxShadow: 'none',
+									}}
+								>
+									<Group gap={2}>
+										{texture && (
+											<SmallTile color="gray" w={125} h="100%">
+												<Group w="100%" h="100%" justify="center" align="center">
+													<TextureImage
+														src={texture.filepath}
+														alt={texture.name}
+														mcmeta={texture.mcmeta}
+														size={115}
+													/>
+												</Group>
+											</SmallTile>
+										)}
+										<Stack gap={2} align="start" miw={400} maw={400}>
+											<SmallTile color="gray">
+												<Text fw={500} ta="center">{texture?.name}</Text>
+											</SmallTile>
+
+											<Group gap={2} w="100%" wrap="nowrap" align="start">
+												<SmallTile color="gray" className="navbar-icon-fix" style={{ '--size': '28px' }}>
+													<GoCommit />
+												</SmallTile>
+												<SmallTile color="gray">
+													<Text size="xs">
+														<a
+															href={gitCommitUrl({ orgOrUser, repository, commitSha })}
+															target="_blank"
+															rel="noreferrer"
+														>
+															{commitSha}
+														</a>
+													</Text>
+												</SmallTile>
+											</Group>
+
+											<Group gap={2} w="100%" wrap="nowrap" align="start">
+												<SmallTile color="gray" className="navbar-icon-fix" style={{ '--size': '28px' }}>
+													<GoRelFilePath />
+												</SmallTile>
+												<SmallTile color="gray">
+													<Text size="xs">
+														<a
+															href={gitBlobUrl({ orgOrUser, repository, branchOrCommit: commitSha, path: contribution.filename })}
+															target="_blank"
+															rel="noreferrer"
+														>
+															{contribution.filename}
+														</a>
+													</Text>
+												</SmallTile>
+											</Group>
+
+											<Group gap={2} w="100%">
+												<Group gap={2} w="calc((100% - 4px) / 3)" wrap="nowrap" align="start">
+													<SmallTile color="gray" className="navbar-icon-fix" style={{ '--size': '28px' }}>
+														<LuArrowUpDown />
+													</SmallTile>
+													<SmallTile color="gray">
+														<Text size="xs">
+															{contribution.poll.upvotes.length - contribution.poll.downvotes.length}
+														</Text>
+													</SmallTile>
+												</Group>
+												<Group gap={2} w="calc((100% - 4px) / 3)" wrap="nowrap" align="start">
+													<SmallTile color="gray" className="navbar-icon-fix" style={{ '--size': '28px' }}>
+														<GoHash />
+													</SmallTile>
+													<SmallTile color="gray">
+														<Text size="xs">
+															{contribution.textureId}
+														</Text>
+													</SmallTile>
+												</Group>
+												<Group gap={2} w="calc((100% - 4px) / 3)" wrap="nowrap" align="start">
+													<SmallTile color="gray" className="navbar-icon-fix" style={{ '--size': '28px' }}>
+														<GoHourglass />
+													</SmallTile>
+													<SmallTile color="gray">
+														<Text size="xs">
+															{contribution.status}
+														</Text>
+													</SmallTile>
+												</Group>
+											</Group>
+										</Stack>
+									</Group>
+								</TextureImage>
+							)
+						);
+					})}
+				</Group>
+			)}
+
+		</Stack>
+	);
+}
+
diff --git a/src/app/(pages)/(protected)/contribute/submit/submit.scss b/src/app/(pages)/(protected)/contribute/submissions/styles.scss
similarity index 100%
rename from src/app/(pages)/(protected)/contribute/submit/submit.scss
rename to src/app/(pages)/(protected)/contribute/submissions/styles.scss
diff --git a/src/app/(pages)/(protected)/contribute/submit/co-authors-select.tsx b/src/app/(pages)/(protected)/contribute/submit/co-authors-select.tsx
deleted file mode 100644
index b2c88add..00000000
--- a/src/app/(pages)/(protected)/contribute/submit/co-authors-select.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-import { useState } from 'react';
-
-import { Avatar, Group, MultiSelect, Text } from '@mantine/core';
-
-import { useEffectOnce } from '~/hooks/use-effect-once';
-import { notify } from '~/lib/utils';
-import { getPublicUsers } from '~/server/data/user';
-
-import type { MultiSelectProps } from '@mantine/core';
-import type { useCurrentUser } from '~/hooks/use-current-user';
-import type { PublicUser } from '~/types';
-
-/**
- * Select co-authors for a contribution.
- * The current user is excluded from the list of selectable co-authors.
- */
-export interface CoAuthorsSelectorProps extends MultiSelectProps {
-	author: NonNullable<ReturnType<typeof useCurrentUser>>;
-	onCoAuthorsSelect: (coAuthors: PublicUser[]) => void;
-}
-
-export function CoAuthorsSelector({ author, onCoAuthorsSelect, ...props }: CoAuthorsSelectorProps) {
-	const [users, setUsers] = useState<PublicUser[]>([]);
-
-	useEffectOnce(() => {
-		getPublicUsers()
-			.then(setUsers)
-			.catch((err) => {
-				console.error(err);
-				notify('Error', 'Failed to fetch users', 'red');
-			});
-	});
-
-	const renderMultiSelectOption: MultiSelectProps['renderOption'] = ({ option }) => {
-		const user = users.find((u) => u.id === option.value)!;
-
-		return (
-			<Group gap="sm" wrap="nowrap">
-				<Avatar src={user.image} size={30} radius="xl" />
-				<div>
-					<Text size="sm">{option.label}</Text>
-					{option.disabled && <Text size="xs" c="dimmed">That&apos;s you!</Text>}
-				</div>
-			</Group>
-		);
-	};
-
-	return (
-		<MultiSelect
-			limit={10}
-			label="Co-authors"
-			placeholder="Select or search co-authors..."
-			data={users.map((u) => ({ value: u.id, label: u.name ?? 'Unknown', disabled: u.id === author.id }))}
-			renderOption={renderMultiSelectOption}
-			defaultValue={[]}
-			onChange={(userIds: string[]) => onCoAuthorsSelect(userIds.map((u) => users.find((user) => user.id === u)!))}
-			hidePickedOptions
-			searchable
-			clearable
-			{...props}
-		/>
-	);
-}
diff --git a/src/app/(pages)/(protected)/contribute/submit/contribution-item.tsx b/src/app/(pages)/(protected)/contribute/submit/contribution-item.tsx
deleted file mode 100644
index 16592665..00000000
--- a/src/app/(pages)/(protected)/contribute/submit/contribution-item.tsx
+++ /dev/null
@@ -1,124 +0,0 @@
-
-import { useState } from 'react';
-
-import { LuArrowUpDown } from 'react-icons/lu';
-import { RxCross2 } from 'react-icons/rx';
-
-import { Button, Code, Group, Stack, Text } from '@mantine/core';
-import { Status } from '@prisma/client';
-
-import { TextureImage } from '~/components/texture-img';
-import { useCurrentUser } from '~/hooks/use-current-user';
-import { useDeviceSize } from '~/hooks/use-device-size';
-import { useEffectOnce } from '~/hooks/use-effect-once';
-import { BREAKPOINT_MOBILE_LARGE } from '~/lib/constants';
-import { removeCoAuthor } from '~/server/data/contributions';
-import { getPollResult } from '~/server/data/polls';
-
-import type { CSSProperties } from '@mantine/core';
-import type { ContributionWithCoAuthorsAndPoll, PollResults } from '~/types';
-
-export interface ContributionPanelItemProps {
-	contribution: ContributionWithCoAuthorsAndPoll;
-	isCoAuthorContribution?: boolean;
-	onClick?: (c: ContributionWithCoAuthorsAndPoll) => void;
-	onUpdate?: () => void;
-	styles?: CSSProperties;
-}
-
-export function ContributionPanelItem({ contribution, onClick, onUpdate, styles, isCoAuthorContribution }: ContributionPanelItemProps) {
-	const [windowWidth] = useDeviceSize();
-	const imgWidth = windowWidth <= BREAKPOINT_MOBILE_LARGE ? 60 : 90;
-	const user = useCurrentUser()!; // the user is guaranteed to be logged in (per the layout)
-
-	const [poll, setPoll] = useState<PollResults | null>(null);
-	const isDraft = contribution.status === Status.DRAFT;
-
-	useEffectOnce(() => {
-		getPollResult(contribution.pollId).then(setPoll);
-	});
-
-	return (
-		<Stack gap="sm" align="center">
-
-			<TextureImage
-				src={contribution.filepath}
-				alt=""
-				size={imgWidth}
-				mcmeta={contribution.mcmeta}
-				onClick={() => onClick?.(contribution)}
-				styles={styles}
-			>
-				<Group align="baseline" justify="space-between" >
-					<Group>
-						<Text size="sm" mb="sm" fw={700}>{contribution.filename}</Text>
-					</Group>
-					{!isDraft && poll && (
-						<Group gap="xs" justify="center">
-							<Text component="span" size="xs">{poll.upvotes - poll.downvotes}</Text>
-							<Text size="xs" style={{ display: 'flex' }}><LuArrowUpDown /></Text>
-						</Group>
-					)}
-				</Group>
-				<Stack gap={2} align="flex-start" mt="0">
-					{isCoAuthorContribution && (
-						<Group>
-							<Text w={70} size="xs">Author: </Text>
-							<Text size="xs"><Code component="span">{contribution.owner.name}</Code></Text>
-						</Group>
-					)}
-					{contribution.coAuthors.length > 0 && (<Group>
-						<Text w={70} size="xs">Co-authors:</Text>
-						<Text size="xs">{contribution.coAuthors.map((ca) => {
-							if (ca.id === user.id) {
-								return(
-									<Button
-										key={ca.id}
-										component="span"
-										color="red"
-										leftSection={<RxCross2 />}
-										h={18}
-										mr={2}
-										mt={-1}
-										style={{
-											fontSize: 'var(--mantine-font-size-xs)',
-											fontFamily: 'var(--mantine-font-family-monospace)',
-											lineHeight: 'var(--mantine-line-height)',
-											padding: '2px calc(var(--mantine-spacing-xs) / 2)',
-										}}
-										onClick={() => {
-											removeCoAuthor(user.id!, ca.id, contribution.id);
-											onUpdate?.();
-										}}
-									>
-										{ca.name}
-									</Button>
-								);
-							}
-							return <Code component="span" key={ca.id} mr={2}>{ca.name}</Code>;
-						})}</Text>
-					</Group>)}
-					<Group>
-						<Text w={70} size="xs">Texture ID:</Text>
-						<Text size="xs">
-							{contribution.textureId && <Code component="span">{contribution.textureId}</Code>}
-							{!contribution.textureId && '-'}
-						</Text>
-					</Group>
-					<Group>
-						<Text w={70} size="xs">Resolution:</Text>
-						<Text size="xs"><Code component="span">{contribution.resolution}</Code></Text>
-					</Group>
-					<Group>
-						<Text w={70} size="xs">Drafted:</Text>
-						<Text size="xs"><Code component="span">{contribution.createdAt.toLocaleString()}</Code></Text>
-					</Group>
-					<Group>
-						<Text w={70} size="xs">Updated:</Text>
-						<Text size="xs"><Code component="span">{contribution.updatedAt.toLocaleString()}</Code></Text>
-					</Group>
-				</Stack>
-			</TextureImage>
-		</Stack>
-	);
-}
diff --git a/src/app/(pages)/(protected)/contribute/submit/contribution-modal.tsx b/src/app/(pages)/(protected)/contribute/submit/contribution-modal.tsx
deleted file mode 100644
index ec864d54..00000000
--- a/src/app/(pages)/(protected)/contribute/submit/contribution-modal.tsx
+++ /dev/null
@@ -1,455 +0,0 @@
-import { useMemo, useRef, useState, useTransition } from 'react';
-
-import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
-import { PiMagicWandBold, PiFileArrowUpBold } from 'react-icons/pi';
-
-import { Button, Code, Container, Divider, FileButton, Group, JsonInput, Select, Stack, Text, TextInput, Title } from '@mantine/core';
-import { Resolution } from '@prisma/client';
-
-import { TextureImage } from '~/components/texture-img';
-import { useCurrentUser } from '~/hooks/use-current-user';
-import { useDeviceSize } from '~/hooks/use-device-size';
-import { useEffectOnce } from '~/hooks/use-effect-once';
-import { BREAKPOINT_MOBILE_LARGE, GRADIENT, GRADIENT_DANGER } from '~/lib/constants';
-import { getContributionsOfTexture, updateContributionPicture, updateDraftContribution } from '~/server/data/contributions';
-
-import { CoAuthorsSelector } from './co-authors-select';
-
-import type { MultiSelectProps } from '@mantine/core';
-import type { Texture } from '@prisma/client';
-import type { GetTexturesWithUsePaths } from '~/server/data/texture';
-import type { ContributionWithCoAuthors, ContributionWithCoAuthorsAndPoll, PublicUser } from '~/types';
-
-export interface ContributionModalProps {
-	contribution: ContributionWithCoAuthors;
-	textures: GetTexturesWithUsePaths[];
-	onClose: (editedContribution: ContributionWithCoAuthors) => void;
-}
-
-export function ContributionModal({ contribution, textures, onClose }: ContributionModalProps) {
-	const [isPending, startTransition] = useTransition();
-	const [selectedTexture, setSelectedTexture] = useState<Texture | null>(null);
-	const [selectedCoAuthors, setSelectedCoAuthors] = useState<PublicUser[]>(contribution.coAuthors);
-	const [selectedResolution, setSelectedResolution] = useState<Resolution>(contribution.resolution);
-	const [windowWidth] = useDeviceSize();
-
-	const stackRef = useRef<HTMLDivElement>(null);
-
-	const colWidth = useMemo(() => {
-		const width = stackRef.current?.parentElement?.clientWidth ?? 0;
-		return windowWidth <= BREAKPOINT_MOBILE_LARGE ? `${width * .93}px` : `calc((${width}px - (2 * var(--mantine-spacing-md))) / 3.25)`;
-	},
-	[stackRef, windowWidth]);
-
-	const columnStyle = {
-		width: colWidth,
-	};
-
-	const [selectedTextureId, setSelectedTextureId] = useState<number | null>(null);
-	const [selectedTextureContributions, setSelectedTextureContributions] = useState<ContributionWithCoAuthorsAndPoll[]>([]);
-	const [selectedTextureContributionsIndex, setSelectedTextureContributionsIndex] = useState<number>(0);
-	const [displayedSelectedTextureContributions, setDisplayedSelectedTextureContributions] = useState<ContributionWithCoAuthorsAndPoll | undefined>();
-
-	const [disabledResolution, setDisabledResolution] = useState<(Resolution | null)[]>([]);
-
-	const [contributionMCMETA, setContributionMCMETA] = useState<string>(contribution.mcmeta ? JSON.stringify(contribution.mcmeta, null, 2) : '');
-	const parsedMCMETA = useMemo(() => {
-		try {
-			return JSON.parse(contributionMCMETA);
-		} catch {
-			return null;
-		}
-	}, [contributionMCMETA]);
-
-	const author = useCurrentUser()!;
-
-	useEffectOnce(() => {
-		if (contribution.textureId) handleTextureSelected(contribution.textureId);
-	});
-
-	const handleTextureSelected = (textureId: number | null) => {
-		if (textureId === null) {
-			setSelectedTexture(null);
-			setSelectedTextureId(null);
-			setSelectedTextureContributions([]);
-			setSelectedTextureContributionsIndex(0);
-			setDisplayedSelectedTextureContributions(undefined);
-			setContributionMCMETA('');
-			setDisabledResolution([]);
-			return;
-		}
-
-		const texture = textures.find((t) => t.id === textureId)!;
-		const disabled = texture.disabledContributions.map((d) => d.resolution);
-		setDisabledResolution(disabled);
-		setSelectedTexture(texture);
-		setSelectedTextureId(texture.id);
-
-		if (!contributionMCMETA && texture.mcmeta) setContributionMCMETA(JSON.stringify(texture.mcmeta, null, 2));
-
-		getContributionsOfTexture(textureId)
-			.then((res) => {
-				// remove the current contribution from the list
-				res = res.filter((c) => c.resolution === selectedResolution && c.id !== contribution.id);
-
-				setSelectedTextureContributions(res);
-				setSelectedTextureContributionsIndex(0);
-				setDisplayedSelectedTextureContributions(res[0]);
-			})
-			.catch(console.error);
-	};
-
-	const renderMultiSelectOption: MultiSelectProps['renderOption'] = ({ option }) => {
-		const texture = textures.find((u) => u.id === parseInt(option.value, 10))!;
-
-		return (
-			<Stack gap="sm" className="w-full">
-				<Group gap="sm" wrap="nowrap" align="start">
-					<TextureImage src={texture.filepath} alt="" size={windowWidth <= BREAKPOINT_MOBILE_LARGE ? 60 : 160} mcmeta={texture.mcmeta} />
-					<Stack gap={2} className="w-full">
-						<Group wrap="nowrap" className="w-full">
-							{windowWidth > BREAKPOINT_MOBILE_LARGE && (
-								<Text w={50} size="sm" fw={400}>Name:</Text>
-							)}
-							<Code>{texture.name}</Code>
-						</Group>
-						{texture.aliases.length > 0 && (
-							<Group wrap="nowrap" className="w-full">
-								{windowWidth > BREAKPOINT_MOBILE_LARGE && (
-									<Text w={50} size="sm" fw={400}>Aliases:</Text>
-								)}
-								<Group gap={2}>
-									{texture.aliases.map((a, index) => <Code key={index}>{a}</Code>)}
-								</Group>
-							</Group>
-						)}
-						{windowWidth > BREAKPOINT_MOBILE_LARGE && (
-							<Group align="start" wrap="nowrap" className="w-full">
-								<Text w={50} size="sm" fw={400}>Uses:</Text>
-								<Stack gap={2}>
-									{texture.linkedTextures.map((t, index) =>
-										<Code key={index}>{t.assetPath}</Code>
-									)}
-								</Stack>
-							</Group>
-						)}
-					</Stack>
-				</Group>
-
-				{windowWidth <= BREAKPOINT_MOBILE_LARGE && (
-					<Stack gap={2}>
-						{texture.linkedTextures.map((t, index) =>
-							<Code key={index}>{t.assetPath}</Code>
-						)}
-					</Stack>
-				)}
-			</Stack>
-		);
-	};
-
-	const handlePrevContribution = () => {
-		if (selectedTextureContributionsIndex === 0) return;
-		let index = selectedTextureContributionsIndex - 1;
-		setSelectedTextureContributionsIndex(index);
-		setDisplayedSelectedTextureContributions(selectedTextureContributions[index]);
-	};
-
-	const handleNextContribution = () => {
-		if (selectedTextureContributionsIndex === selectedTextureContributions.length - 1) return;
-		let index = selectedTextureContributionsIndex + 1;
-		setSelectedTextureContributionsIndex(index);
-		setDisplayedSelectedTextureContributions(selectedTextureContributions[index]);
-	};
-
-	const handleContributionFileChange = (file: File | null) => {
-		if (!file) return;
-
-		const formData = new FormData();
-		formData.append('file', file);
-
-		startTransition(() => {
-			updateContributionPicture(author.id!, contribution.id, formData)
-				.then(() => onClose(contribution));
-		});
-	};
-
-	const handleDraftUpdate = () => {
-		if (!selectedTexture) return;
-
-		startTransition(() => {
-			updateDraftContribution({
-				ownerId: author.id!,
-				contributionId: contribution.id,
-				coAuthors: selectedCoAuthors.map((c) => c.id),
-				resolution: selectedResolution,
-				textureId: selectedTexture.id,
-				mcmeta: parsedMCMETA,
-			})
-				.then(onClose)
-				.catch(console.error);
-		});
-	};
-
-	const handleCancel = () => {
-		onClose(contribution);
-	};
-
-	return (
-		<Stack gap="lg" className="w-full" ref={stackRef} align="center">
-			<Group gap="md" align="start">
-				{/* User contribution */}
-				<Stack gap="md" align="left" justify="space-between" style={columnStyle}>
-					<Stack gap="0">
-						<Title order={5}>Yours</Title>
-						<Text size="sm" c="dimmed">The file you&apos;re submitting.</Text>
-					</Stack>
-					<Group wrap="nowrap">
-						<FileButton accept="image/png" onChange={handleContributionFileChange}>
-							{(props) => (
-								<Button
-									variant="light"
-									color="blue"
-									p={0}
-									className="navbar-icon-fix"
-									loading={isPending}
-									{...props}
-								>
-									<PiFileArrowUpBold />
-								</Button>
-							)}
-						</FileButton>
-						<TextInput
-							value={contribution.filename}
-							disabled
-							className="w-full"
-						/>
-					</Group>
-					<TextureImage
-						src={contribution.filepath}
-						mcmeta={parsedMCMETA}
-						size={colWidth}
-						alt=""
-					/>
-					<JsonInput
-						label={windowWidth <= BREAKPOINT_MOBILE_LARGE ? 'Custom MCMETA' : <></>}
-						validationError="Invalid JSON"
-						formatOnBlur
-						autosize
-						minRows={4}
-
-						value={contributionMCMETA}
-						onChange={setContributionMCMETA}
-					/>
-				</Stack>
-				{/* Default texture */}
-				<Stack gap="md" align="left" justify="space-between" style={columnStyle}>
-					<Stack gap="0">
-						<Title order={5}>Default Texture</Title>
-						<Text size="sm" c="dimmed">The targeted texture.</Text>
-					</Stack>
-					<Group wrap="nowrap">
-						<Button
-							variant="light"
-							color="blue"
-							p={0}
-							className="navbar-icon-fix"
-							loading={isPending}
-							onClick={() => {
-								startTransition(() => {
-									const texture = textures.find((t) => t.name === contribution.filename.replace('.png', '') || t.aliases.includes(contribution.filename.replace('.png', '')));
-									if (texture) handleTextureSelected(texture.id);
-								});
-							}}
-						>
-							<PiMagicWandBold />
-						</Button>
-						<Select
-							limit={100}
-							data={textures.map((t) => ({ value: t.id.toString(), label: `${t.name} ${t.aliases.join(' ')} ${t.linkedTextures.map((l) => l.assetPath).join(' ')} ${t.id}`, disabled: t.id === selectedTextureId }))}
-							defaultValue={contribution.textureId?.toString()}
-							value={selectedTextureId?.toString()}
-							renderOption={renderMultiSelectOption}
-							className="w-full"
-							onChange={(e) => handleTextureSelected(e ? parseInt(e, 10) : null)}
-							onClear={() => setSelectedTexture(null)}
-							maxDropdownHeight={colWidth}
-							comboboxProps={windowWidth > BREAKPOINT_MOBILE_LARGE
-								? {
-									width: `calc((${colWidth} * 3) + 2 * var(--mantine-spacing-md))`,
-									offset: {
-										mainAxis: 5,
-										crossAxis: -27,
-									},
-								}
-								: {
-									width: colWidth,
-									offset: {
-										mainAxis: 5,
-										crossAxis: -27,
-									},
-								}
-							}
-							placeholder="Search a texture by its name/path..."
-							searchable
-							required
-							clearable
-						/>
-					</Group>
-					{selectedTexture &&
-						<TextureImage
-							src={selectedTexture.filepath}
-							size={colWidth}
-							mcmeta={selectedTexture?.mcmeta}
-							alt=""
-						/>
-					}
-					{!selectedTexture && <Container className="texture-background" pt="100%" pl="calc(100% - var(--mantine-spacing-md))" />}
-					<JsonInput
-						label={windowWidth <= BREAKPOINT_MOBILE_LARGE ? 'Default MCMETA' : <Title order={6} ta="center">MCMETA</Title>}
-						labelProps={{ style: { width: '100%' } }}
-						validationError="Invalid JSON"
-						formatOnBlur
-						autosize
-						minRows={4}
-
-						value={(selectedTexture?.mcmeta && JSON.stringify(selectedTexture?.mcmeta, null, 2)) ?? ''}
-						disabled
-					/>
-				</Stack>
-				{/* Existing contribution */}
-				<Stack gap="md" align="left" justify="space-between" style={columnStyle}>
-					<Stack gap="0">
-						<Title order={5}>Existing Contributions</Title>
-						<Text size="sm" c="dimmed">Of the targeted texture.</Text>
-					</Stack>
-					<Group gap="md" justify="center">
-						<Button
-							variant="light"
-							disabled={selectedTextureContributions.length === 0 || selectedTextureContributionsIndex === 0}
-							onClick={handlePrevContribution}
-						>
-							<FaChevronLeft/>
-						</Button>
-						{selectedTextureContributions.length > 0 &&
-							<Group w={50} align="center">
-								<Text w={50} ta="center">{selectedTextureContributionsIndex + 1} / {selectedTextureContributions.length}</Text>
-							</Group>
-						}
-						{selectedTextureContributions.length === 0 &&
-							<Group w={50} align="center">
-								<Text w={50} ta="center">- / -</Text>
-							</Group>
-						}
-						<Button
-							variant="light"
-							disabled={selectedTextureContributions.length === 0 || selectedTextureContributionsIndex === selectedTextureContributions.length - 1}
-							onClick={handleNextContribution}
-						>
-							<FaChevronRight/>
-						</Button>
-					</Group>
-					{!selectedTexture && selectedTextureContributions.length === 0 &&
-						<Container className="texture-background" pt="100%" pl="calc(100% - var(--mantine-spacing-md))" pos="relative">
-							<Text
-								size="sm"
-								pos="absolute"
-								left="0"
-								right="0"
-								top="calc(50% - (20.3px /2))" // text height / 2
-								style={{ textAlign: 'center' }}
-							>
-								Select a texture first!
-							</Text>
-						</Container>
-					}
-					{selectedTexture && selectedTextureContributions.length === 0 &&
-						<Container className="texture-background" pt="100%" pl="calc(100% - var(--mantine-spacing-md))" pos="relative">
-							<Text
-								size="sm"
-								pos="absolute"
-								left="0"
-								right="0"
-								top="calc(50% - (20.3px /2))" // text height / 2
-								style={{ textAlign: 'center' }}
-							>
-								No contributions for this texture.
-							</Text>
-						</Container>
-					}
-					{selectedTexture && selectedTextureContributions.length > 0 && displayedSelectedTextureContributions &&
-						<TextureImage
-							src={displayedSelectedTextureContributions.filepath}
-							size={colWidth}
-							mcmeta={displayedSelectedTextureContributions?.mcmeta}
-							alt=""
-						/>
-					}
-					<JsonInput
-						label={windowWidth <= BREAKPOINT_MOBILE_LARGE ? 'Contribution MCMETA' : <></>}
-						validationError="Invalid JSON"
-						formatOnBlur
-						autosize
-						minRows={4}
-
-						value={JSON.stringify(displayedSelectedTextureContributions?.mcmeta, null, 2)}
-						disabled
-					/>
-				</Stack>
-
-			</Group>
-
-			<Divider size="xs" w={windowWidth <= BREAKPOINT_MOBILE_LARGE ? '90%' : `calc(${colWidth} * 1.5)`}/>
-
-			<Group gap="md" justify="center" >
-				<Select
-					label="Resolution"
-					data={(Object.keys(Resolution) as Resolution[]).filter((r) => !disabledResolution.includes(r))}
-					disabled={disabledResolution.length === Object.keys(Resolution).length || disabledResolution.includes(null)}
-					allowDeselect={false}
-					defaultValue={contribution.resolution}
-					onChange={(value) => setSelectedResolution(value as Resolution)}
-					style={windowWidth <= BREAKPOINT_MOBILE_LARGE ? { width: '100%' } : { width: 'calc((100% - var(--mantine-spacing-md)) * .2)' }}
-					required
-				/>
-				<CoAuthorsSelector
-					author={author}
-					onCoAuthorsSelect={setSelectedCoAuthors}
-					defaultValue={selectedCoAuthors.map((c) => c.id)}
-					style={windowWidth <= BREAKPOINT_MOBILE_LARGE
-						? { width: '100%' }
-						: { width: 'calc((100% - var(--mantine-spacing-md)) * .8)' }
-					}
-				/>
-			</Group>
-			{(disabledResolution.length === Object.keys(Resolution).length || disabledResolution.includes(null)) && (
-				<Text size="xs" c="red" mt={-10}>
-					Contributions for all resolutions are deactivated for this texture.
-				</Text>
-			)}
-
-			<Group gap="md" justify="center" className="w-full" mt="xl">
-				<Button
-					fullWidth
-					maw="200px"
-					loading={isPending}
-					variant="gradient"
-					gradient={GRADIENT_DANGER}
-					onClick={handleCancel}
-				>
-					Cancel
-				</Button>
-				<Button
-					fullWidth
-					maw="200px"
-					loading={isPending}
-					disabled={!selectedTexture || disabledResolution.length === Object.keys(Resolution).length || disabledResolution.includes(null)}
-					variant="gradient"
-					gradient={GRADIENT}
-					onClick={handleDraftUpdate}
-				>
-					Save
-				</Button>
-			</Group>
-		</Stack>
-	);
-}
diff --git a/src/app/(pages)/(protected)/contribute/submit/contribution-tools.tsx b/src/app/(pages)/(protected)/contribute/submit/contribution-tools.tsx
deleted file mode 100644
index af740a28..00000000
--- a/src/app/(pages)/(protected)/contribute/submit/contribution-tools.tsx
+++ /dev/null
@@ -1,172 +0,0 @@
-import { useEffect, useMemo, useRef, useState } from 'react';
-
-import { GoArrowLeft, GoDownload, GoTrash, GoUpload } from 'react-icons/go';
-
-import { Button, Group, Stack } from '@mantine/core';
-import { useDisclosure } from '@mantine/hooks';
-import { Status } from '@prisma/client';
-
-import { Modal } from '~/components/modal';
-import { Tile } from '~/components/tile';
-import { useCurrentUser } from '~/hooks/use-current-user';
-import { useDeviceSize } from '~/hooks/use-device-size';
-import { BREAKPOINT_MOBILE_LARGE, GRADIENT, GRADIENT_DANGER } from '~/lib/constants';
-import { submitContributions } from '~/server/data/contributions';
-
-import { ContributionDeleteModal } from './delete-modal';
-
-import type { ContributionWithCoAuthorsAndPoll } from '~/types';
-
-interface Props {
-	activeTab: number,
-	contributions: ContributionWithCoAuthorsAndPoll[];
-	onUpdate: () => void;
-	onSubmitHover: (on: boolean) => void;
-	onDeleteMode: (on: boolean) => void;
-
-	contributionToDelete: string[];
-	setContributionToDelete: (ids: string[]) => void;
-}
-
-export function ContributionTools({
-	activeTab,
-	contributions,
-	onUpdate,
-	onDeleteMode,
-	onSubmitHover,
-	contributionToDelete,
-	setContributionToDelete,
-}: Props) {
-	const [windowWidth] = useDeviceSize();
-
-	const user = useCurrentUser()!; // the user is guaranteed to be logged in (per the layout)
-	const linkRef = useRef<HTMLAnchorElement>(null);
-
-	const ready = useMemo(() => contributions.filter((c) => c.textureId !== null && c.status === Status.DRAFT), [contributions]);
-
-	const [isDeletionMode, setDeletionMode] = useState(false);
-	const [isDeleteModalOpened, { open: openDeleteModal, close: closeDeleteModal }] = useDisclosure(false);
-
-	useEffect(() => {
-		if (!isDeletionMode) setContributionToDelete([]);
-		onDeleteMode(isDeletionMode);
-	}, [isDeletionMode, onDeleteMode, setContributionToDelete]);
-
-	const handleContributionsSubmit = () => {
-		submitContributions(user.id!, contributions.filter((c) => c.textureId !== null && c.status === Status.DRAFT).map((c) => c.id))
-			.then(() => onUpdate());
-	};
-
-	const handleContributionsDownload = async () => {
-		fetch(`/api/download/contributions/${user.id}`, { method: 'GET' })
-			.then((response) => response.blob())
-			.then((blob) => {
-				const url = window.URL.createObjectURL(blob);
-				const link = linkRef.current;
-
-				if (!link) return;
-
-				link.href = url;
-				link.download = 'contributions.zip';
-				link.click();
-				window.URL.revokeObjectURL(url);
-			});
-	};
-
-	return (
-		<Tile
-			maw={windowWidth <= BREAKPOINT_MOBILE_LARGE ? '100%' : 274}
-			w="100%"
-			radius={5}
-			p="xs"
-		>
-			<Modal
-				opened={isDeleteModalOpened}
-				onClose={closeDeleteModal}
-				title="Confirmation"
-				popup
-			>
-				<ContributionDeleteModal
-					contributions={contributions}
-					contributionToDelete={contributionToDelete}
-					closeModal={(decision) => {
-						if (decision === 'yes') onUpdate();
-
-						setDeletionMode(false);
-						closeDeleteModal();
-					}}
-				/>
-			</Modal>
-
-			<Stack gap="xs">
-				<Button
-					variant="gradient"
-					gradient={GRADIENT}
-					disabled={contributions.length === 0}
-					className={contributions.length === 0 ? 'button-disabled-with-bg' : undefined}
-					onClick={handleContributionsDownload}
-					justify="space-between"
-					rightSection={<GoDownload />}
-				>
-					Download all contributions
-					<a ref={linkRef} style={{ display: 'none' }} />
-				</Button>
-
-				{!isDeletionMode && (
-					<Button
-						fullWidth
-						variant="gradient"
-						gradient={GRADIENT_DANGER}
-						onClick={() => setDeletionMode(true)}
-						disabled={contributions.length === 0}
-						className={contributions.length === 0 ? 'button-disabled-with-bg' : undefined}
-						justify="space-between"
-						rightSection={<GoTrash />}
-					>
-						Enable deletion mode
-					</Button>
-				)}
-
-				{isDeletionMode && (
-					<Group gap="xs" wrap="nowrap">
-						<Button
-							variant="default"
-							className="navbar-icon-fix"
-							onClick={() => setDeletionMode(false)}
-							p={0}
-						>
-							<GoArrowLeft />
-						</Button>
-						<Button
-							fullWidth
-							variant="gradient"
-							gradient={GRADIENT_DANGER}
-							onClick={openDeleteModal}
-							disabled={contributionToDelete.length === 0}
-							className={contributionToDelete.length === 0 ? 'button-disabled-with-bg' : undefined}
-						>
-							Delete {contributionToDelete.length > 1 ? contributionToDelete.length : ''} contribution{contributionToDelete.length > 1 ? 's' : ''}
-						</Button>
-					</Group>
-				)}
-
-				{activeTab === 0 && (
-					<Button
-						variant="gradient"
-						gradient={GRADIENT}
-						disabled={ready.length === 0 || isDeletionMode}
-						className={(ready.length === 0 || isDeletionMode) ? 'button-disabled-with-bg' : undefined}
-						fullWidth
-						onClick={() => handleContributionsSubmit()}
-						onMouseEnter={() => onSubmitHover(true)}
-						onMouseLeave={() => onSubmitHover(false)}
-						justify="space-between"
-						rightSection={<GoUpload />}
-					>
-						Submit {ready.length > 1 ? ready.length : ''} draft{ready.length > 1 ? 's' : ''}
-					</Button>
-				)}
-			</Stack>
-		</Tile>
-	);
-}
diff --git a/src/app/(pages)/(protected)/contribute/submit/delete-modal.tsx b/src/app/(pages)/(protected)/contribute/submit/delete-modal.tsx
deleted file mode 100644
index d453092d..00000000
--- a/src/app/(pages)/(protected)/contribute/submit/delete-modal.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-import { Button, Group, Stack, Text } from '@mantine/core';
-
-import { useCurrentUser } from '~/hooks/use-current-user';
-import { GRADIENT, GRADIENT_DANGER } from '~/lib/constants';
-import { deleteContributions } from '~/server/data/contributions';
-
-import { ContributionPanelItem } from './contribution-item';
-
-import type { ContributionWithCoAuthorsAndPoll } from '~/types';
-
-export interface ContributionDeleteModalProps {
-	contributions: ContributionWithCoAuthorsAndPoll[];
-	contributionToDelete: string[];
-	closeModal: (decision: 'yes' | 'no') => void;
-}
-
-export function ContributionDeleteModal({ contributions, contributionToDelete, closeModal }: ContributionDeleteModalProps) {
-	const userId = useCurrentUser()!.id!;
-
-	const handleContributionsDelete = async () => {
-		await deleteContributions(userId, contributionToDelete);
-		closeModal('yes');
-	};
-
-	return (
-		<Stack gap="md">
-			<Stack gap={0}>
-				<Text size="sm">Are you sure you want to delete {contributionToDelete.length} contribution{contributionToDelete.length > 1 ? 's' : ''} ?</Text>
-				<Text size="sm" c="red">
-					Please note that this action is irreversible and the contribution{contributionToDelete.length > 1 ? 's' : ''} will be permanently deleted.
-				</Text>
-			</Stack>
-			<Stack>
-				<Text>
-					Contribution{contributionToDelete.length > 1 ? 's' : ''} to delete :
-				</Text>
-				<Group gap="sm">
-					{contributionToDelete.map((id) =>
-						<ContributionPanelItem
-							key={id}
-							contribution={contributions.find((c) => c.id === id)!}
-						/>
-					)}
-				</Group>
-			</Stack>
-			<Group gap="sm" wrap="nowrap">
-				<Button
-					variant="gradient"
-					gradient={GRADIENT}
-					onClick={() => closeModal('no')}
-					fullWidth
-				>
-					No
-				</Button>
-				<Button
-					variant="gradient"
-					gradient={GRADIENT_DANGER}
-					onClick={handleContributionsDelete}
-					fullWidth
-				>
-					Yes
-				</Button>
-			</Group>
-		</Stack>
-	);
-}
diff --git a/src/app/(pages)/(protected)/contribute/submit/page.tsx b/src/app/(pages)/(protected)/contribute/submit/page.tsx
deleted file mode 100644
index a7e1b940..00000000
--- a/src/app/(pages)/(protected)/contribute/submit/page.tsx
+++ /dev/null
@@ -1,278 +0,0 @@
-'use client';
-
-import { useState, useTransition } from 'react';
-
-import { Button, Code, FloatingIndicator, Group, Indicator, Select, Stack, Text } from '@mantine/core';
-import { Dropzone } from '@mantine/dropzone';
-import { useDisclosure } from '@mantine/hooks';
-import { Resolution, Status } from '@prisma/client';
-
-import { Modal } from '~/components/modal';
-import { useCurrentUser } from '~/hooks/use-current-user';
-import { useDeviceSize } from '~/hooks/use-device-size';
-import { useEffectOnce } from '~/hooks/use-effect-once';
-import { BREAKPOINT_MOBILE_LARGE, COLORS } from '~/lib/constants';
-import { createRawContributions, getContributionsOfUser, getCoSubmittedContributions } from '~/server/data/contributions';
-import { getTexturesWithUsePaths } from '~/server/data/texture';
-
-import { CoAuthorsSelector } from './co-authors-select';
-import { ContributionPanelItem } from './contribution-item';
-import { ContributionModal } from './contribution-modal';
-import { ContributionTools } from './contribution-tools';
-
-import type { GetTexturesWithUsePaths } from '~/server/data/texture';
-import type { ContributionWithCoAuthorsAndPoll, PublicUser } from '~/types';
-
-import './submit.scss';
-
-const SubmitPage = () => {
-	const user = useCurrentUser()!; // the user is guaranteed to be logged in (per the layout)
-
-	const [isPending, startTransition] = useTransition();
-	const [windowWidth] = useDeviceSize();
-
-	const [resolution, setResolution] = useState<Resolution>(Resolution.x32);
-	const [selectedCoAuthors, setSelectedCoAuthors] = useState<PublicUser[]>([]);
-
-	const [contributions, setContributions] = useState<ContributionWithCoAuthorsAndPoll[]>([]);
-	const [coContributions, setCoContributions] = useState<ContributionWithCoAuthorsAndPoll[]>([]);
-
-	const [isModalContributionOpened, { open: openContributionModal, close: closeContributionModal }] = useDisclosure(false);
-	const [modalContribution, setModalContribution] = useState<ContributionWithCoAuthorsAndPoll | null>(null);
-
-	const [isHoveringSubmit, setHoveringSubmit] = useState(false);
-	const [isDeletionMode, setDeletionMode] = useState(false);
-
-	const [contributionToDelete, setContributionToDelete] = useState<string[]>([]);
-
-	const [textures, setTextures] = useState<GetTexturesWithUsePaths[]>([]);
-
-	useEffectOnce(() => {
-		reload();
-	});
-
-	const reload = () => {
-		startTransition(() => {
-			getContributionsOfUser(user.id!).then(setContributions);
-			getCoSubmittedContributions(user.id!).then(setCoContributions);
-			getTexturesWithUsePaths().then(setTextures);
-		});
-	};
-
-	const handleFilesDrop = (files: File[]) => {
-		startTransition(async () => {
-			const data = new FormData();
-			files.forEach((file) => data.append('files', file));
-
-			await createRawContributions(user.id!, selectedCoAuthors.map((u) => u.id), resolution, data)
-				.then((duplicates) => {
-					if (duplicates.length > 0) console.error(duplicates.join('\n'));
-
-					getContributionsOfUser(user.id!).then(setContributions);
-					setActiveTab(0);
-				});
-		});
-	};
-
-	const handleContributionClick = (c: ContributionWithCoAuthorsAndPoll) => {
-		if (isDeletionMode) {
-			if (contributionToDelete.includes(c.id)) setContributionToDelete(contributionToDelete.filter((id) => id !== c.id));
-			else setContributionToDelete([...contributionToDelete, c.id]);
-		}
-		else {
-			setModalContribution(c);
-			openContributionModal();
-		}
-	};
-
-	const getBorderStyles = (c: ContributionWithCoAuthorsAndPoll) => {
-		if (contributionToDelete.includes(c.id))
-			return { boxShadow: '0 0 0 2px var(--mantine-color-red-filled)' };
-
-		if (c.textureId !== null && isHoveringSubmit && c.status === Status.DRAFT)
-			return { boxShadow: '0 0 0 2px var(--mantine-color-teal-filled)' };
-	};
-
-	const [groupRef, setGroupRef] = useState<HTMLDivElement | null>(null);
-	const [controlsRefs, setControlsRefs] = useState<Record<string, HTMLButtonElement | null>>({});
-	const [activeTab, setActiveTab] = useState(0);
-
-	const setControlRef = (index: number) => (node: HTMLButtonElement) => {
-		controlsRefs[index] = node;
-		setControlsRefs(controlsRefs);
-	};
-
-	const controls = Object.entries(Status).map(([key, value], index) => {
-		const isLast = index === Object.keys(Status).length - 1;
-
-		return (
-			<Button
-				key={key}
-				ref={setControlRef(index)}
-				onClick={() => setActiveTab(index)}
-				mod={{ active: activeTab === index }}
-
-				pl="md"
-				pr="sm"
-				fullWidth
-				variant="filled"
-				leftSection={<>
-					<Indicator color={COLORS[value]} mr="md" />
-					{value === Status.DRAFT
-						? 'Drafted'
-						: value === Status.PENDING
-							? 'Reviewed'
-							: value.charAt(0).toUpperCase() + value.slice(1).toLowerCase()
-					}
-				</>}
-				rightSection={contributions?.filter((c) => c.status === value).length ?? 0}
-				justify="space-between"
-				className="slider-button"
-				style={windowWidth > BREAKPOINT_MOBILE_LARGE
-					? {
-						borderRight: !isLast && activeTab !== index + 1 && activeTab !== index
-							? 'calc(0.0625rem * var(--mantine-scale)) solid var(--mantine-color-default-border)'
-							: undefined,
-						borderRadius: isLast ? '0 calc(0.25rem * var(--mantine-scale)) calc(0.25rem * var(--mantine-scale)) 0' : undefined,
-					}
-					: undefined
-				}
-			/>
-		);
-	});
-
-	return (
-		<Stack gap="md">
-			<Modal
-				forceFullScreen
-				opened={isModalContributionOpened}
-				onClose={closeContributionModal}
-			>
-				{modalContribution && (
-					<ContributionModal
-						contribution={modalContribution}
-						textures={textures}
-						onClose={reload}
-					/>
-				)}
-			</Modal>
-
-			<Stack gap="md">
-				<Group gap="sm">
-					<Select
-						label="Resolution"
-						data={Object.keys(Resolution)}
-						checkIconPosition="right"
-						allowDeselect={false}
-						defaultValue={Resolution.x32}
-						onChange={(value) => setResolution(value as Resolution)}
-						style={windowWidth <= BREAKPOINT_MOBILE_LARGE ? { width: '100%' } : { width: 'calc((100% - var(--mantine-spacing-md)) * .2)' }}
-						required
-					/>
-					<CoAuthorsSelector
-						author={user}
-						onCoAuthorsSelect={setSelectedCoAuthors}
-						mb={windowWidth <= BREAKPOINT_MOBILE_LARGE ? 'xs' : '0'}
-						style={windowWidth <= BREAKPOINT_MOBILE_LARGE
-							? { width: '100%' }
-							: { width: 'calc((100% - var(--mantine-spacing-md)) * .8)' }
-						}
-					/>
-				</Group>
-				<Stack gap="2">
-					 <Text size="sm" fw={500}>Files</Text>
-					<Dropzone
-						onDrop={handleFilesDrop}
-						accept={['image/png']}
-						loading={isPending}
-						mt="0"
-					>
-						<div>
-							<Text size="l" inline>
-								Drag <Code>.PNG</Code> files here or click to select files
-							</Text>
-							<Text size="sm" c="dimmed" inline mt={7}>
-								Attach as many files as you like, each file will be added as a separate contribution
-								based on the settings above.
-							</Text>
-						</div>
-					</Dropzone>
-					<Text size="xs" c="dimmed" fs="italic">
-						Please do not submit textures for unsupported mod/modpack. Ask a council member to add them first.
-					</Text>
-				</Stack>
-			</Stack>
-
-			<Group wrap={windowWidth <= BREAKPOINT_MOBILE_LARGE ? 'wrap' : 'nowrap'} align="start">
-				<ContributionTools
-					activeTab={activeTab}
-					contributions={contributions}
-					onUpdate={reload}
-					onSubmitHover={setHoveringSubmit}
-					onDeleteMode={setDeletionMode}
-
-					contributionToDelete={contributionToDelete}
-					setContributionToDelete={setContributionToDelete}
-				/>
-
-				<Group w="100%">
-					<Stack w="100%">
-						<Button.Group
-							ref={setGroupRef}
-							style={{
-								position: 'relative',
-							}}
-							orientation={windowWidth <= BREAKPOINT_MOBILE_LARGE ? 'vertical' : 'horizontal'}
-						>
-							{controls}
-
-							<FloatingIndicator
-								target={controlsRefs[activeTab]}
-								parent={groupRef}
-								style={{
-									border: 'calc(0.0625rem * var(--mantine-scale)) solid #fff3',
-									borderRadius: (() => {
-										if (activeTab === 0) return 'calc(0.25rem * var(--mantine-scale)) 0 0 calc(0.25rem * var(--mantine-scale))';
-										if (activeTab === Object.keys(Status).length - 1) return '0 calc(0.25rem * var(--mantine-scale)) calc(0.25rem * var(--mantine-scale)) 0';
-										return '0';
-									})(),
-									backgroundColor: '#0002',
-									cursor: 'pointer',
-									zIndex: 200,
-								}}
-							/>
-
-						</Button.Group>
-
-						<Group mb="sm">
-							{contributions.filter((c) => c.status === Object
-								.values(Status)[activeTab])
-								.map((contribution) => (
-									<ContributionPanelItem
-										key={contribution.id}
-										contribution={contribution}
-										onClick={handleContributionClick}
-										styles={getBorderStyles(contribution)}
-									/>
-								))
-							}
-							{coContributions.filter((c) => c.status === Object
-								.values(Status)[activeTab])
-								.map((contribution) => (
-									<ContributionPanelItem
-										key={contribution.id}
-										contribution={contribution}
-										onClick={handleContributionClick}
-										styles={getBorderStyles(contribution)}
-									/>
-								))
-							}
-						</Group>
-					</Stack>
-				</Group>
-			</Group>
-		</Stack>
-	);
-};
-
-export default SubmitPage;
diff --git a/src/app/(pages)/(protected)/council/submissions/page.tsx b/src/app/(pages)/(protected)/council/submissions/page.tsx
index 5a93b6be..797464ab 100644
--- a/src/app/(pages)/(protected)/council/submissions/page.tsx
+++ b/src/app/(pages)/(protected)/council/submissions/page.tsx
@@ -17,7 +17,8 @@ import { getCounselors } from '~/server/data/user';
 import { CouncilContributionItem } from './contribution-item';
 
 import type { Texture } from '@prisma/client';
-import type { ContributionWithCoAuthorsAndFullPoll, PublicUser } from '~/types';
+import type { GetPendingContributions } from '~/server/data/contributions';
+import type { PublicUser } from '~/types';
 
 const CouncilContributionsPanel = () => {
 	const [textures, setTextures] = useState<Texture[]>([]);
@@ -27,8 +28,8 @@ const CouncilContributionsPanel = () => {
 	const [showVoted, setShowVoted] = useState(false);
 
 	const [counselors, setCounselors] = useState<PublicUser[]>([]);
-	const [counselorVoted, setCounselorVoted] = useState<ContributionWithCoAuthorsAndFullPoll[]>([]);
-	const [counselorUnvoted, setCounselorUnvoted] = useState<ContributionWithCoAuthorsAndFullPoll[]>([]);
+	const [counselorVoted, setCounselorVoted] = useState<GetPendingContributions[]>([]);
+	const [counselorUnvoted, setCounselorUnvoted] = useState<GetPendingContributions[]>([]);
 
 	const counselor = useCurrentUser()!;
 
@@ -50,8 +51,8 @@ const CouncilContributionsPanel = () => {
 	const loadContributions = () => {
 		getPendingContributions()
 			.then((res) => {
-				const unvoted: ContributionWithCoAuthorsAndFullPoll[] = [];
-				const voted: ContributionWithCoAuthorsAndFullPoll[] = [];
+				const unvoted: GetPendingContributions[] = [];
+				const voted: GetPendingContributions[] = [];
 
 				res.forEach((c) => {
 					if (c.poll.downvotes.find((dv) => dv.id === counselor.id) || c.poll.upvotes.find((uv) => uv.id === counselor.id))
diff --git a/src/app/(pages)/mods/[modId]/gallery/page.tsx b/src/app/(pages)/mods/[modId]/gallery/page.tsx
index 4c878fb0..13ded730 100644
--- a/src/app/(pages)/mods/[modId]/gallery/page.tsx
+++ b/src/app/(pages)/mods/[modId]/gallery/page.tsx
@@ -18,7 +18,7 @@ import { getModVersionFromMod } from '~/server/data/mods-version';
 import { getTexturesFromModVersion } from '~/server/data/texture';
 
 import type { ModVersion, Texture } from '@prisma/client';
-import type { ContributionWithCoAuthors } from '~/types';
+import type { GetLatestContributionsOfModVersion } from '~/server/data/contributions';
 
 export default function ModGalleryPage() {
 	const [resolution, setResolution] = useState<Resolution | 'x16'>(Resolution['x32']);
@@ -32,7 +32,7 @@ export default function ModGalleryPage() {
 	const [texturesFiltered, setTexturesFiltered] = useState<Texture[]>([]);
 	const [texturesShown, setTexturesShown] = useState<Texture[][]>([[]]);
 
-	const [contributions, setContributions] = useState<ContributionWithCoAuthors[]>([]);
+	const [contributions, setContributions] = useState<GetLatestContributionsOfModVersion[]>([]);
 
 	const [texturesShownPerPage, setTexturesShownPerPage] = useState<string>('96');
 	const [texturesShownPerRow, setTexturesShownPerRow] = useState<number>(12);
diff --git a/src/auth.config.ts b/src/auth.config.ts
index 7d85b1a2..d90d5897 100644
--- a/src/auth.config.ts
+++ b/src/auth.config.ts
@@ -12,7 +12,7 @@ export default {
 			clientSecret: process.env.GITHUB_CLIENT_SECRET,
 			authorization: {
 				params: {
-					scope: 'read:user user:email public_repo',
+					scope: 'read:user user:email public_repo delete_repo',
 				},
 			},
 		}),
diff --git a/src/components/small-tile.tsx b/src/components/small-tile.tsx
index 3e3b758d..d4311d20 100644
--- a/src/components/small-tile.tsx
+++ b/src/components/small-tile.tsx
@@ -9,7 +9,7 @@ export function SmallTile({ children, style, ...props }: PolymorphicComponentPro
 			mih={28}
 			style={{
 				padding: '5px 8px 6px 8px',
-				borderRadius: 5,
+				borderRadius: 0,
 				...style,
 			}}
 			{...props}
diff --git a/src/components/texture-contribution.tsx b/src/components/texture-contribution.tsx
index 96c39d79..24d5a44c 100644
--- a/src/components/texture-contribution.tsx
+++ b/src/components/texture-contribution.tsx
@@ -12,16 +12,16 @@ import { useEffectOnce } from '~/hooks/use-effect-once';
 import { getVanillaTextureSrc } from '~/lib/utils';
 import { getLatestVanillaTextureContribution } from '~/server/actions/faithful-pack';
 
-import type { Resolution, Texture } from '@prisma/client';
-import type { ContributionWithCoAuthors, FPStoredContribution } from '~/types';
+import type { Contribution, Resolution, Texture } from '@prisma/client';
+import type { FPStoredContribution, Prettify, PublicUser } from '~/types';
 
-export interface GalleryTextureWithContributionProps {
+interface Props {
 	container: RefObject<HTMLDivElement>;
 	rowItemsGap: number;
 	rowItemsLength: number;
 	texture: Texture;
 	resolution: Resolution | 'x16';
-	contribution?: ContributionWithCoAuthors;
+	contribution?: Prettify<Contribution & { owner: PublicUser, coAuthors: PublicUser[] }>;
 }
 
 export function GalleryTextureWithContribution({
@@ -31,7 +31,7 @@ export function GalleryTextureWithContribution({
 	resolution,
 	texture,
 	contribution,
-}: GalleryTextureWithContributionProps) {
+}: Props) {
 
 	const src = useMemo(() => {
 		if (resolution === 'x16') return texture.filepath;
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 220b90b0..04e9d4c5 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -107,4 +107,39 @@ export const COLORS: Record<Status, MantineColor> = {
 
 export const GITHUB_ORG_NAME = 'faithful-mods';
 export const GITHUB_DEFAULT_REPO_NAME = 'resources-default';
-export const FILE_GIT = `https://raw.githubusercontent.com/${GITHUB_ORG_NAME}/${GITHUB_DEFAULT_REPO_NAME}/${process.env.NODE_ENV === 'production' ? 'main' : 'dev'}`;
+
+export type RawUrl =
+ | `https://raw.githubusercontent.com/${string}/${string}/${string}`
+ | `https://raw.githubusercontent.com/${string}/${string}/${string}/${string}`
+
+export type FileGitParams = {
+	orgOrUser: string;
+	repository: string;
+	branchOrCommit?: string;
+	path?: string;
+}
+
+export const gitRawUrl = ({ orgOrUser, repository, branchOrCommit, path }: FileGitParams): RawUrl => {
+	if (branchOrCommit) return `https://raw.githubusercontent.com/${orgOrUser}/${repository}/${branchOrCommit}/${path}`;
+	const branch = process.env.NODE_ENV === 'production' ? 'main' : 'dev';
+
+	return `https://raw.githubusercontent.com/${orgOrUser}/${repository}/${branch}/${path}`;
+};
+
+export type CommitUrl = `https://github.com/${string}/${string}/commit/${string}`;
+
+export type GitCommitUrlParams = {
+	orgOrUser: string;
+	repository: string;
+	commitSha: string;
+}
+
+export const gitCommitUrl = ({ orgOrUser, repository, commitSha }: GitCommitUrlParams): CommitUrl => {
+	return `https://github.com/${orgOrUser}/${repository}/commit/${commitSha}`;
+};
+
+export type BlobUrl = `https://github.com/${string}/${string}/blob/${string}/${string}`;
+
+export const gitBlobUrl = ({ orgOrUser, repository, branchOrCommit, path }: FileGitParams): BlobUrl => {
+	return `https://github.com/${orgOrUser}/${repository}/blob/${branchOrCommit}/${path}`;
+};
diff --git a/src/server/actions/files.ts b/src/server/actions/files.ts
index 83650953..8eca11b8 100644
--- a/src/server/actions/files.ts
+++ b/src/server/actions/files.ts
@@ -8,7 +8,7 @@ import { join } from 'path';
 import TOML from '@ltd/j-toml';
 import unzipper from 'unzipper';
 
-import { FILE_DIR, FILE_GIT, FILE_PATH } from '~/lib/constants';
+import { FILE_DIR, gitRawUrl, FILE_PATH, GITHUB_ORG_NAME, GITHUB_DEFAULT_REPO_NAME } from '~/lib/constants';
 import { db } from '~/lib/db';
 import { calculateHash } from '~/lib/hash';
 import { socket } from '~/lib/serversocket';
@@ -345,7 +345,7 @@ export async function extractDefaultResourcePack(jar: File, modVersion: ModVersi
 				mcmeta,
 			});
 
-			const filepath = `${FILE_GIT}/${texture.id}.png` as const;
+			const filepath = `${gitRawUrl({ orgOrUser: GITHUB_ORG_NAME, repository: GITHUB_DEFAULT_REPO_NAME })}/${texture.id}.png` as const;
 			await db.texture.update({ where: { id: texture.id }, data: { filepath } });
 
 			filesToCommit.push(buffer.toString('base64') as base64);
diff --git a/src/server/actions/git.ts b/src/server/actions/git.ts
index f52dc4f3..95ea9619 100644
--- a/src/server/actions/git.ts
+++ b/src/server/actions/git.ts
@@ -2,46 +2,268 @@
 import 'server-only';
 
 import { Octokit } from '@octokit/rest';
-import { UserRole } from '@prisma/client';
+import { Resolution, UserRole } from '@prisma/client';
 
 import { auth } from '~/auth';
 import { canAccess } from '~/lib/auth';
-import { GITHUB_DEFAULT_REPO_NAME, GITHUB_ORG_NAME } from '~/lib/constants';
+import { gitRawUrl, GITHUB_DEFAULT_REPO_NAME, GITHUB_ORG_NAME } from '~/lib/constants';
 import { db } from '~/lib/db';
 
+import type { RestEndpointMethodTypes } from '@octokit/rest';
+import type { RawUrl } from '~/lib/constants';
 import type { base64 } from '~/types';
 
-export async function uploadToRepository(files: base64[], filenames: string[], commitMessage: string): Promise<void> {
-	await canAccess(UserRole.COUNCIL);
+// Singleton instance of octokit
+
+declare global {
+  var octokit: Octokit | undefined;
+}
+
+let octokit = globalThis.octokit || undefined;
+if (process.env.NODE_ENV !== 'production') globalThis.octokit = octokit;
+
+async function getOctokit() {
+	if (octokit) return octokit;
 
 	const session = await auth();
 	const user = session?.user!; // We know the user is logged in because of the canAccess check
 
 	// Authenticate with GitHub API using current logged user's access token
 	const userToken = await db.account.findFirstOrThrow({ where: { userId: user.id }, select: { access_token: true } });
-	const octokit = new Octokit({
+	octokit = new Octokit({
 		auth: userToken.access_token,
 	});
 
+	return octokit;
+}
+
+// --- End singletons ---
+
+/**
+ * Check if the user has a fork of the default repository
+ */
+export async function getFork() {
+	const octokit = await getOctokit();
+	const username = await getUserGitHubUsername();
+
+	let fork: RestEndpointMethodTypes['repos']['get']['response'];
+	try {
+		fork = await octokit.repos.get({
+			owner: username,
+			repo: GITHUB_DEFAULT_REPO_NAME,
+		});
+	} catch {
+		return null;
+	}
+
+	return fork.data.html_url;
+}
+
+/**
+ * Delete the forked repository from the logged user's account
+ */
+export async function deleteFork() {
+	const octokit = await getOctokit();
+	const username = await getUserGitHubUsername();
+
+	await octokit.repos.delete({
+		owner: username,
+		repo: GITHUB_DEFAULT_REPO_NAME,
+	});
+}
+
+/**
+ * Fork the default textures repository to the user's account and wait for the fork to be ready
+ */
+export async function forkRepository() {
+	const octokit = await getOctokit();
+
+	// Fork the repository
+	await octokit.repos.createFork({
+		owner: GITHUB_ORG_NAME,
+		repo: GITHUB_DEFAULT_REPO_NAME,
+	});
+
+	let forkReady = false;
+	const username = await getUserGitHubUsername();
+
+	// wait for the fork to be ready before doing anything else
+	while (!forkReady) {
+		const fork = await octokit.repos.get({
+			owner: username,
+			repo: GITHUB_DEFAULT_REPO_NAME,
+		});
+
+		if (fork.status === 200) forkReady = true;
+
+		await new Promise((resolve) => setTimeout(resolve, 5000));
+	}
+
+	// create empty branches for each resolution
+	const resolutions = Object.keys(Resolution) as Resolution[];
+	const firstCommitSha = await getFirstCommit(username, GITHUB_DEFAULT_REPO_NAME);
+
+	for (const resolution of resolutions) {
+		await createBranchFromCommit(username, GITHUB_DEFAULT_REPO_NAME, resolution, firstCommitSha);
+	}
+
+	// rename the dev/main branch to x16 (if prod => main => x16 and DEL dev, if dev => dev => x16 and DEL main)
+	if (process.env.NODE_ENV !== 'production') {
+		await setDefaultBranch(username, GITHUB_DEFAULT_REPO_NAME, 'dev');
+		await deleteBranch(username, GITHUB_DEFAULT_REPO_NAME, 'main');
+		await renameBranch(username, GITHUB_DEFAULT_REPO_NAME, 'dev', 'x16');
+	}
+	else {
+		await deleteBranch(username, GITHUB_DEFAULT_REPO_NAME, 'dev');
+		await renameBranch(username, GITHUB_DEFAULT_REPO_NAME, 'main', 'x16');
+	}
+}
+
+/**
+ * Upload the given files to the default textures repository
+ * @param files the files to upload as base64
+ * @param filenames the names of the files (must match the order of the files)
+ * @param commitMessage the commit message to use
+ */
+export async function uploadToRepository(files: base64[], filenames: string[], commitMessage: string): Promise<void> {
+	await canAccess(UserRole.COUNCIL);
+
 	const branch = process.env.NODE_ENV === 'production' ? 'main' : 'dev';
 
 	// get latest commit
-	const currentCommit = await getCurrentCommit(octokit, branch);
+	const currentCommit = await getCurrentCommit(branch);
 
 	// create blobs for each file
-	const filesBlobs = await Promise.all(files.map((file) => createBlobFile(octokit, file)));
+	const blobs = await Promise.all(files.map((file) => createBlobFile(file)));
 
 	// create new tree with the blobs
-	const newTree = await createNewTree(octokit, filenames, filesBlobs, currentCommit.tree_sha);
+	const newTree = await createNewTree({ filenames, blobs, parentTreeSha: currentCommit.tree_sha });
 
 	// create a new commit with the new tree
-	const newCommit = await createCommit(octokit, commitMessage, newTree.sha, currentCommit.commit_sha);
+	const newCommit = await createCommit({ message: commitMessage, treeSha: newTree.sha, parentCommitSha: currentCommit.commit_sha });
 
 	// update the branch to point to the new commit
-	await setBranchToCommit(octokit, newCommit.sha, branch);
+	await setBranchToCommit(newCommit.sha, branch);
+}
+
+export interface GitFile {
+	url: RawUrl;
+	path: string;
+	mode: string;
+	type: string;
+	sha: string;
+	size: number;
+}
+
+export async function getContributionsOfFork(resolution: Resolution): Promise<GitFile[]> {
+	const username = await getUserGitHubUsername();
+	const files = await listFilesInBranch(username, GITHUB_DEFAULT_REPO_NAME, resolution);
+	console.log(files, resolution);
+
+	return files as GitFile[];
 }
 
-async function setBranchToCommit(octokit: Octokit, commitSha: string, branch: string) {
+async function listFilesInBranch(owner: string, repo: string, branch: string) {
+	const octokit = await getOctokit();
+	const { data } = await octokit.git.getRef({
+		owner,
+		repo,
+		ref: `heads/${branch}`,
+	});
+
+	const commitSha = data.object.sha;
+
+	// Get the tree structure of the commit
+	try {
+		const treeResponse = await octokit.git.getTree({
+			owner,
+			repo,
+			tree_sha: commitSha,
+			recursive: 'true', // Set to "true" to list all files recursively
+		});
+
+		return treeResponse.data.tree
+			.filter((item) => item.type === 'blob')
+			.map((item) => ({
+				...item,
+				url: gitRawUrl({ orgOrUser: owner, repository: repo, branchOrCommit: commitSha, path: item.path }),
+			}));
+	}
+	catch {
+		return [];
+	}
+
+}
+
+/**
+ * Get the GitHub username of the currently logged user
+ */
+async function getUserGitHubUsername() {
+	const octokit = await getOctokit();
+	const { data } = await octokit.users.getAuthenticated();
+	return data.login;
+}
+
+async function setDefaultBranch(owner: string, repo: string, branch: string) {
+	const octokit = await getOctokit();
+	await octokit.repos.update({
+		owner,
+		repo,
+		default_branch: branch,
+	});
+}
+
+async function renameBranch(owner: string, repo: string, oldBranch: string, newBranch: string) {
+	const octokit = await getOctokit();
+	const { data } = await octokit.git.getRef({
+		owner,
+		repo,
+		ref: `heads/${oldBranch}`,
+	});
+
+	await octokit.git.createRef({
+		owner,
+		repo,
+		ref: `refs/heads/${newBranch}`,
+		sha: data.object.sha,
+	});
+
+	await deleteBranch(owner, repo, oldBranch);
+}
+
+async function deleteBranch(owner: string, repo: string, branch: string) {
+	const octokit = await getOctokit();
+	await octokit.git.deleteRef({
+		owner,
+		repo,
+		ref: `heads/${branch}`,
+	});
+}
+
+async function createBranchFromCommit(owner: string, repo: string, branch: string, commitSha: string) {
+	const octokit = await getOctokit();
+	await octokit.git.createRef({
+		owner,
+		repo,
+		ref: `refs/heads/${branch}`,
+		sha: commitSha,
+	});
+}
+
+async function getFirstCommit(owner: string, repo: string) {
+	const octokit = await getOctokit();
+	const { data } = await octokit.repos.listCommits({
+		owner, repo,
+		sha: 'main',
+		per_page: 1,
+		page: 1,
+	});
+
+	return data[0]!.sha;
+}
+
+async function setBranchToCommit(commitSha: string, branch: string) {
+	const octokit = await getOctokit();
 	await octokit.git.updateRef({
 		owner: GITHUB_ORG_NAME,
 		repo: GITHUB_DEFAULT_REPO_NAME,
@@ -50,22 +272,51 @@ async function setBranchToCommit(octokit: Octokit, commitSha: string, branch: st
 	});
 }
 
-async function createCommit(octokit: Octokit, message: string, treeSha: string, parentCommitSha: string) {
+interface CreateCommitOptions {
+	message: string;
+	treeSha: string;
+	parentCommitSha?: string;
+	owner?: string;
+	repo?: string;
+}
+
+async function createCommit({ message, treeSha, parentCommitSha, owner, repo }: CreateCommitOptions) {
+	const octokit = await getOctokit();
 	const { data } = await octokit.git.createCommit({
-		owner: GITHUB_ORG_NAME,
-		repo: GITHUB_DEFAULT_REPO_NAME,
+		owner: owner ?? GITHUB_ORG_NAME,
+		repo: repo ?? GITHUB_DEFAULT_REPO_NAME,
 		message,
 		tree: treeSha,
-		parents: [parentCommitSha],
+		parents: parentCommitSha ? [parentCommitSha] : [],
 	});
 
 	return data;
 }
 
+async function createEmptyTree(owner = GITHUB_ORG_NAME, repo = GITHUB_DEFAULT_REPO_NAME) {
+	const octokit = await getOctokit();
+	const { data } = await octokit.git.createTree({
+		owner,
+		repo,
+		tree: [],
+	});
+
+	return data.sha;
+}
+
+interface CreateNewTreeOptions {
+	filenames: string[],
+	blobs: { url: string, sha: string }[],
+	parentTreeSha: string
+	owner?: string;
+	repo?: string;
+}
+
 /**
  * Create a new tree with the given blobs
  */
-async function createNewTree(octokit: Octokit, filenames: string[], blobs: { url: string, sha: string }[], parentTreeSha: string) {
+async function createNewTree({ filenames, blobs, parentTreeSha, owner, repo }: CreateNewTreeOptions) {
+	const octokit = await getOctokit();
 	const tree = blobs.map(({ sha }, index) => {
 		return {
 			path: filenames[index],
@@ -76,8 +327,8 @@ async function createNewTree(octokit: Octokit, filenames: string[], blobs: { url
 	});
 
 	const { data } = await octokit.git.createTree({
-		owner: GITHUB_ORG_NAME,
-		repo: GITHUB_DEFAULT_REPO_NAME,
+		owner: owner ?? GITHUB_ORG_NAME,
+		repo: repo ?? GITHUB_DEFAULT_REPO_NAME,
 		base_tree: parentTreeSha,
 		tree,
 	});
@@ -88,7 +339,8 @@ async function createNewTree(octokit: Octokit, filenames: string[], blobs: { url
 /**
  * Prepare a github blob for a file
  */
-async function createBlobFile(octokit: Octokit, file: base64) {
+async function createBlobFile(file: base64) {
+	const octokit = await getOctokit();
 	const blobData = await octokit.git.createBlob({
 		owner: GITHUB_ORG_NAME,
 		repo: GITHUB_DEFAULT_REPO_NAME,
@@ -102,7 +354,8 @@ async function createBlobFile(octokit: Octokit, file: base64) {
 /**
  * Fetch the current commit of the given repository and branch
  */
-async function getCurrentCommit(octokit: Octokit, branch: string) {
+async function getCurrentCommit(branch: string) {
+	const octokit = await getOctokit();
 	const { data: refData } = await octokit.git.getRef({
 		owner: GITHUB_ORG_NAME,
 		repo: GITHUB_DEFAULT_REPO_NAME,
diff --git a/src/server/data/contributions.ts b/src/server/data/contributions.ts
index c5bcadeb..cae82b66 100644
--- a/src/server/data/contributions.ts
+++ b/src/server/data/contributions.ts
@@ -5,60 +5,51 @@ import { Status, UserRole } from '@prisma/client';
 
 import { canAccess } from '~/lib/auth';
 import { db } from '~/lib/db';
-import { calculateHash } from '~/lib/hash';
 
 import { getCounselors } from './user';
-import { remove, upload } from '../actions/files';
 
+import type { GitFile } from '../actions/git';
 import type { Contribution, Resolution } from '@prisma/client';
-import type {
-	ContributionWithCoAuthors,
-	ContributionWithCoAuthorsAndFullPoll,
-	ContributionWithCoAuthorsAndPoll,
-	TextureMCMETA,
-} from '~/types';
+import type { Prettify, PublicUser } from '~/types';
 
 // GET
 
-export async function getContributionsOfUser(ownerId: string): Promise<ContributionWithCoAuthorsAndPoll[]> {
-	await canAccess(UserRole.ADMIN, ownerId);
+export type GetContributionsOfUser = Prettify<Contribution & {
+	coAuthors: PublicUser[],
+	owner: PublicUser,
+	poll: {
+		upvotes: PublicUser[],
+		downvotes: PublicUser[],
+	}
+}>
 
-	return await db.contribution.findMany({
-		where: { ownerId },
-		include: {
-			coAuthors: { select: { id: true, name: true, image: true } },
-			owner: { select: { id: true, name: true, image: true } },
-			poll: true,
-		},
-	});
-}
+/**
+ * Get all contributions of a user, including the co-authors and the poll
+ */
+export async function getContributionsOfUser(ownerId: string, resolution: Resolution): Promise<GetContributionsOfUser[]> {
+	await canAccess(UserRole.ADMIN, ownerId);
 
-export async function getContributionsOfTexture(textureId: number): Promise<ContributionWithCoAuthorsAndPoll[]> {
 	return await db.contribution.findMany({
-		where: { textureId, status: Status.ACCEPTED },
+		where: { ownerId, resolution },
 		include: {
 			coAuthors: { select: { id: true, name: true, image: true } },
 			owner: { select: { id: true, name: true, image: true } },
-			poll: true,
+			poll: { select: { downvotes: true, upvotes: true } },
 		},
 	});
 }
 
-export async function getCoSubmittedContributions(coAuthorId: string): Promise<ContributionWithCoAuthorsAndPoll[]> {
-	return await db.contribution.findMany({
-		where: {
-			coAuthors: { some: { id: coAuthorId } },
-			status: { not: Status.DRAFT },
-		},
-		include: {
-			coAuthors: { select: { id: true, name: true, image: true } },
-			owner: { select: { id: true, name: true, image: true } },
-			poll: true,
-		},
-	});
-}
+export type GetPendingContributions = Prettify<Omit<Contribution, 'status'> & {
+	status: typeof Status.PENDING,
+	coAuthors: PublicUser[],
+	owner: PublicUser,
+	poll: {
+		upvotes: PublicUser[],
+		downvotes: PublicUser[],
+	}
+}>
 
-export async function getPendingContributions(): Promise<ContributionWithCoAuthorsAndFullPoll[]> {
+export async function getPendingContributions(): Promise<GetPendingContributions[]> {
 	await canAccess(UserRole.COUNCIL);
 
 	return await db.contribution.findMany({
@@ -76,16 +67,16 @@ export async function getPendingContributions(): Promise<ContributionWithCoAutho
 				},
 			},
 		},
-	});
+	}) as GetPendingContributions[];
 }
 
-export async function findContribution(hash: string): Promise<Contribution | null> {
-	return db.contribution.findFirst({
-		where: { hash },
-	});
-}
+export type GetLatestContributionsOfModVersion = Prettify<Omit<Contribution, 'status'> & {
+	status: typeof Status.ACCEPTED,
+	coAuthors: PublicUser[],
+	owner: PublicUser,
+}>
 
-export async function getLatestContributionsOfModVersion(modVersionId: string, res: Resolution): Promise<ContributionWithCoAuthors[]> {
+export async function getLatestContributionsOfModVersion(modVersionId: string, res: Resolution): Promise<GetLatestContributionsOfModVersion[]> {
 	return db.resource.findMany({
 		where: {
 			modVersionId,
@@ -119,135 +110,13 @@ export async function getLatestContributionsOfModVersion(modVersionId: string, r
 			.flatMap((r) => r.linkedTextures)
 			.map((linkedTexture) => linkedTexture.texture)
 			.unique((t1, t2) => t1.id === t2.id)
-			.map((texture) => texture.contributions[0])
+			.map((texture) => texture.contributions[0] as GetLatestContributionsOfModVersion)
 			.filter((c) => !!c)
 	);
 }
 
 // POST
 
-export async function createRawContributions(
-	ownerId: string,
-	coAuthors: string[],
-	resolution: Resolution,
-	data: FormData
-): Promise<string[]> {
-	await canAccess(UserRole.ADMIN, ownerId);
-
-	const duplicates: string[] = [];
-	const files = data.getAll('files') as File[];
-
-	const contributions: Contribution[] = [];
-	for (const file of files) {
-		const buffer = await file.arrayBuffer();
-		const hash = calculateHash(Buffer.from(buffer));
-
-		if (await findContribution(hash)) {
-			duplicates.push(`Contribution "${file.name}" has already been submitted`);
-			continue;
-		}
-
-		const filepath = await upload(file, `textures/contributions/${ownerId}/`);
-
-		const poll = await db.poll.create({ data: {} });
-		const contribution = await db.contribution.create({
-			data: {
-				ownerId,
-				coAuthors: { connect: coAuthors.map((id) => ({ id })) },
-				resolution,
-				filepath,
-				filename: file.name,
-				pollId: poll.id,
-				hash,
-			},
-		});
-
-		contributions.push(contribution);
-	}
-
-	return duplicates;
-}
-
-export async function updateContributionPicture(ownerId: string, contributionId: string, formData: FormData) {
-	await canAccess(UserRole.ADMIN, ownerId);
-
-	const file = formData.get('file') as File;
-	const buffer = await file.arrayBuffer();
-	const hash = calculateHash(Buffer.from(buffer));
-
-	if (await findContribution(hash)) throw new Error(`Contribution "${file.name}" has already been submitted`);
-	const filepath = await upload(file, `textures/contributions/${ownerId}/`);
-
-	const oldFile = await db.contribution.findFirst({ where: { id: contributionId }, select: { filepath: true } });
-	if (oldFile) await remove(oldFile.filepath as `/files/${string}`);
-
-	const contribution = await db.contribution.update({ where: { id: contributionId }, data: { filepath, filename: file.name, hash, status: Status.DRAFT } });
-
-	// reset poll
-	await db.poll.update({
-		where: { id: contribution.pollId },
-		data: {
-			upvotes: { set: [] },
-			downvotes: { set: [] },
-		},
-	});
-
-	return contribution;
-}
-
-export async function updateDraftContribution({
-	ownerId,
-	contributionId,
-	coAuthors,
-	resolution,
-	textureId,
-	mcmeta,
-}: {
-	ownerId: string;
-	contributionId: string;
-	coAuthors: string[];
-	resolution: Resolution;
-	textureId: number;
-	mcmeta: TextureMCMETA;
-}): Promise<ContributionWithCoAuthors> {
-	await canAccess(UserRole.ADMIN, ownerId);
-
-	const contribution = await db.contribution.update({
-		where: { id: contributionId },
-		data: {
-			coAuthors: { set: coAuthors.map((id) => ({ id })) },
-			resolution,
-			textureId,
-			mcmeta,
-			status: Status.DRAFT,
-		},
-		include: {
-			coAuthors: { select: { id: true, name: true, image: true } },
-			owner: { select: { id: true, name: true, image: true } },
-		},
-	});
-
-	// reset poll
-	await db.poll.update({
-		where: { id: contribution.pollId },
-		data: {
-			upvotes: { set: [] },
-			downvotes: { set: [] },
-		},
-	});
-
-	return contribution;
-}
-
-export async function submitContributions(ownerId: string, ids: string[]) {
-	await canAccess(UserRole.ADMIN, ownerId);
-
-	return await db.contribution.updateMany({
-		where: { id: { in: ids }, ownerId },
-		data: { status: Status.PENDING },
-	});
-}
-
 export async function checkContributionStatus(contributionId: string) {
 	await canAccess(UserRole.COUNCIL);
 
@@ -280,18 +149,52 @@ export async function checkContributionStatus(contributionId: string) {
 	}
 }
 
-export async function removeCoAuthor(ownerId: string, coAuthorId: string, contributionId: string) {
+export async function createContributionsFromGitFiles(ownerId: string, resolution: Resolution, files: GitFile[]) {
 	await canAccess(UserRole.ADMIN, ownerId);
 
-	return await db.contribution.update({
-		where: { id: contributionId },
-		data: { coAuthors: { disconnect: { id: coAuthorId } } },
-	});
+	for (const file of files) {
+		const existingContribution = await db.contribution.findFirst({ where: { hash: file.sha } });
+		if (existingContribution) continue;
+
+		let textureId: number;
+
+		try {
+			const filename = file.path.includes('/') ? file.path.split('/')[1] : file.path;
+			textureId = parseInt(filename?.replace('.png', '') ?? '', 10);
+			if (isNaN(textureId)) continue;
+		} catch (e) {
+			console.error(e);
+			continue;
+		}
+
+		const poll = await db.poll.create({ data: {} });
+		const contribution = await db.contribution.create({
+			data: {
+				ownerId,
+				filepath: file.url,
+				hash: file.sha,
+				status: Status.DRAFT,
+				pollId: poll.id,
+				filename: file.path,
+				resolution,
+				textureId,
+			},
+		});
+
+		// reset poll
+		await db.poll.update({
+			where: { id: contribution.pollId },
+			data: {
+				upvotes: { set: [] },
+				downvotes: { set: [] },
+			},
+		});
+	}
 }
 
 // DELETE
 
-export async function deleteContributions(
+export async function deleteContributionsOrArchive(
 	ownerId: string,
 	ids: string[]
 ): Promise<void> {
@@ -303,21 +206,16 @@ export async function deleteContributions(
 	});
 
 	for (const contribution of contributions) {
-		// Case co-author wants to be removed from the contribution
-		if (
-			contribution.ownerId !== ownerId &&
-			contribution.coAuthors.map((c) => c.id).includes(ownerId)
-		) {
+		// removed from git without being accepted
+		if (contribution.status !== Status.ACCEPTED && contribution.status !== Status.ARCHIVED) {
+			await db.contribution.delete({ where: { id: contribution.id } });
+			await db.poll.delete({ where: { id: contribution.pollId } });
+		}
+		else {
 			await db.contribution.update({
 				where: { id: contribution.id },
-				data: { coAuthors: { disconnect: { id: ownerId } } },
+				data: { status: Status.ARCHIVED },
 			});
 		}
-
-		// Base case: owner wants to delete the contribution
-		if (contribution.ownerId === ownerId) {
-			await remove(contribution.filepath as `/files/${string}`);
-			await db.contribution.delete({ where: { id: contribution.id } });
-		}
 	}
 }
diff --git a/src/types/index.d.ts b/src/types/index.d.ts
index 3582832b..08fdc16d 100644
--- a/src/types/index.d.ts
+++ b/src/types/index.d.ts
@@ -1,5 +1,4 @@
 import type {
-	Contribution,
 	Mod,
 	Modpack,
 	ModpackVersion,
@@ -38,8 +37,8 @@ export interface SocketModUpload {
 	};
 }
 
-export type ModpackVersionWithMods = ModpackVersion & { mods: ModVersion[] };
-export type ModVersionExtended = ModVersion & { modpacks: Modpack[], textures: number, linked: number };
+export type ModpackVersionWithMods = Prettify<ModpackVersion & { mods: ModVersion[] }>;
+export type ModVersionExtended = Prettify<ModVersion & { modpacks: Modpack[], textures: number, linked: number }>;
 
 export type PublicUser = {
 	id: string;
@@ -47,14 +46,10 @@ export type PublicUser = {
 	image: string | null;
 };
 
-export type FullPoll = Poll & {
+export type FullPoll = Prettify<Poll & {
 	downvotes: PublicUser[];
 	upvotes: PublicUser[];
-}
-
-export type ContributionWithCoAuthors = Contribution & { coAuthors: PublicUser[], owner: PublicUser };
-export type ContributionWithCoAuthorsAndPoll = ContributionWithCoAuthors & { poll: Poll };
-export type ContributionWithCoAuthorsAndFullPoll = ContributionWithCoAuthors & { poll: FullPoll };
+}>
 
 export type ContributionActivationStatus = {
 	/** null means any resolution */

From eb629f8eeb973e05fe13bf2411fce3b42408da43 Mon Sep 17 00:00:00 2001
From: Julien Constant <julienconstant190@gmail.com>
Date: Sun, 18 Aug 2024 02:21:50 +0200
Subject: [PATCH 06/30] feat : (part 2) move contribution settings to profile &
 remove reports

---
 .../(protected)/contribute/settings/page.tsx  | 127 ------
 .../(protected)/user/[userId]/page.tsx        | 289 ++++++++++---
 .../user/[userId]/reports-panel.tsx           | 380 ------------------
 .../user/[userId]/settings-panel.tsx          | 113 ------
 4 files changed, 244 insertions(+), 665 deletions(-)
 delete mode 100644 src/app/(pages)/(protected)/contribute/settings/page.tsx
 delete mode 100644 src/app/(pages)/(protected)/user/[userId]/reports-panel.tsx
 delete mode 100644 src/app/(pages)/(protected)/user/[userId]/settings-panel.tsx

diff --git a/src/app/(pages)/(protected)/contribute/settings/page.tsx b/src/app/(pages)/(protected)/contribute/settings/page.tsx
deleted file mode 100644
index 71abb7dc..00000000
--- a/src/app/(pages)/(protected)/contribute/settings/page.tsx
+++ /dev/null
@@ -1,127 +0,0 @@
-'use client';
-
-import Link from 'next/link';
-
-import { useState, useTransition } from 'react';
-
-import { GoCheckCircle, GoStop } from 'react-icons/go';
-
-import { Button, Group, Stack, Text } from '@mantine/core';
-
-import { Tile } from '~/components/tile';
-import { useEffectOnce } from '~/hooks/use-effect-once';
-import { getFork, deleteFork, forkRepository } from '~/server/actions/git';
-
-export default function ContributeSettingsPage() {
-	const [forked, setHasFork] = useState<string | null>(null);
-	const [loading, startTransition] = useTransition();
-
-	useEffectOnce(() => {
-		reload();
-	});
-
-	const reload = async () => {
-		startTransition(async () => {
-			setHasFork(await getFork());
-		});
-	};
-
-	const handleSetupForkedRepository = async () => {
-		startTransition(async () => {
-			await forkRepository();
-			await reload();
-		});
-	};
-
-	const handleForkDelete = async () => {
-		startTransition(async () => {
-			await deleteFork();
-			await reload();
-		});
-	};
-
-	const forkedInfo = () => {
-		if (forked) {
-			return (
-				<Tile p="xs" pl="md" color="teal">
-					<Group justify="space-between">
-						<Group>
-							<GoCheckCircle size={20} color="white"/>
-							<Group gap={3}>
-								<Text size="sm" c="white">Default textures repository forked: </Text>
-								<Text size="sm" c="white"><Link href={forked} style={{ color: 'white' }}>{forked}</Link></Text>
-							</Group>
-						</Group>
-
-						<Group gap="xs">
-							<Button variant="outline" color="white">Sync Fork</Button>
-						</Group>
-					</Group>
-				</Tile>
-			);
-		}
-
-		return (
-			<Tile p="xs" pl="md" color="yellow">
-				<Group justify="space-between">
-					<Group>
-						<GoStop color="black" size={20} />
-						<Group gap="xs">
-							<Text size="sm" c="black">Default textures repository not forked</Text>
-						</Group>
-					</Group>
-
-					<Group gap="xs">
-						<Button
-							variant="outline"
-							color="black"
-							onClick={handleSetupForkedRepository}
-							disabled={!!forked}
-							loading={loading}
-						>
-								Create Fork
-						</Button>
-					</Group>
-				</Group>
-			</Tile>
-		);
-	};
-
-	return (
-		<Stack gap="xl">
-			<Stack gap="xs">
-				<Text fw={700}>General</Text>
-				{forkedInfo()}
-			</Stack>
-
-			<Stack gap="xs">
-				<Text fw={700}>Danger Zone</Text>
-				<Tile
-					p="xs"
-					pl="md"
-					withBorder
-					style={{
-						backgroundColor: 'transparent',
-						borderColor: 'var(--mantine-color-red-filled)',
-					}}
-				>
-					<Group justify="space-between">
-						<Stack gap={0}>
-							<Text>Delete the forked repository</Text>
-							<Text c="dimmed" size="xs">This action is irreversible, all contributions will be lost.</Text>
-						</Stack>
-						<Button
-							variant="default"
-							style={{ color: 'var(--mantine-color-red-text)' }}
-							onClick={handleForkDelete}
-							disabled={!forked}
-							loading={loading}
-						>
-							Delete Fork
-						</Button>
-					</Group>
-				</Tile>
-			</Stack>
-		</Stack>
-	);
-}
diff --git a/src/app/(pages)/(protected)/user/[userId]/page.tsx b/src/app/(pages)/(protected)/user/[userId]/page.tsx
index bac32760..59e04f79 100644
--- a/src/app/(pages)/(protected)/user/[userId]/page.tsx
+++ b/src/app/(pages)/(protected)/user/[userId]/page.tsx
@@ -1,79 +1,278 @@
 'use client';
 
+import Link from 'next/link';
 import { useParams, useRouter } from 'next/navigation';
 
-import { useState } from 'react';
+import { useState, useTransition } from 'react';
 
-import { Badge, Button, Group, Tabs } from '@mantine/core';
+import { GoCheckCircle, GoStop } from 'react-icons/go';
+
+import { Button, Text, TextInput, Group, Stack, Badge } from '@mantine/core';
+import { useForm } from '@mantine/form';
+import { UserRole } from '@prisma/client';
 import { signOut } from 'next-auth/react';
+import { useSession } from 'next-auth/react';
 
+import { TextureImage } from '~/components/texture-img';
 import { Tile } from '~/components/tile';
 import { useCurrentUser } from '~/hooks/use-current-user';
+import { useDeviceSize } from '~/hooks/use-device-size';
 import { useEffectOnce } from '~/hooks/use-effect-once';
+import { BREAKPOINT_MOBILE_LARGE, GRADIENT, MAX_NAME_LENGTH, MIN_NAME_LENGTH } from '~/lib/constants';
 import { notify } from '~/lib/utils';
-import { getReportsOfUser } from '~/server/data/reports';
+import { deleteFork, forkRepository, getFork } from '~/server/actions/git';
 import { getUserById } from '~/server/data/user';
-
-import { UserReportsPanel } from './reports-panel';
-import { UserSettingsPanel } from './settings-panel';
+import { updateUser } from '~/server/data/user';
 
 import type { User } from '@prisma/client';
-import type { ReportWithReporter } from '~/types';
 
 const UserPage = () => {
 	const params = useParams();
-	const loggedUser = useCurrentUser();
+	const user = useCurrentUser()!;
 	const self = params.userId === 'me';
 
 	const [displayedUser, setDisplayedUser] = useState<User>();
-	const [reports, setReports] = useState<ReportWithReporter[]>([]);
 	const router = useRouter();
 
+	const { update } = useSession();
+	const [loading, startTransition] = useTransition();
+	const [windowWidth] = useDeviceSize();
+
+	const [hasFork, setHasFork] = useState<string | null>(null);
+
+	const form = useForm<Pick<User, 'name' | 'image'>>({
+		initialValues: { name: user.name!, image: user.image! },
+		validate: {
+			name: (value) => {
+				if (!value) return 'You must provide a name';
+				if (value.length < MIN_NAME_LENGTH) return `Your name should be at least ${MIN_NAME_LENGTH} characters long`;
+				if (value.length > MAX_NAME_LENGTH) return `Your name should be less than ${MAX_NAME_LENGTH} characters long`;
+			},
+			image: (value) => {
+				if (!value) return null;
+				if (!value?.startsWith('https://')) return 'Your personal picture should be a HTTPS URL';
+			},
+		},
+		onValuesChange: () => {
+			form.validate();
+		},
+	});
+
+	const onSubmit = (values: typeof form.values) => {
+		if (!user) return;
+
+		startTransition(() => {
+			updateUser({ ...values, id: user.id! })
+				.then((user) => {
+					update(user);
+					notify('Success', 'Profile updated', 'teal');
+				})
+				.catch((err) => {
+					console.error(err);
+					notify('Error', err.message, 'red');
+				});
+		});
+	};
+
+	const reload = async () => {
+		startTransition(async () => {
+			// Avoid logged users to access their own page with their id
+			if (params.userId === user?.id) router.push('/user/me');
+			const userId = self ? user?.id! : params.userId as string;
+
+			getFork().then(setHasFork);
+			getUserById(userId).then(setDisplayedUser);
+		});
+	};
+
+	const handleForkDelete = async () => {
+		startTransition(async () => {
+			await deleteFork();
+			await reload();
+		});
+	};
+
 	useEffectOnce(() => {
-		// Avoid logged users to access their own page with their id
-		if (params.userId === loggedUser?.id) router.push('/user/me');
-
-		const userId = self ? loggedUser?.id! : params.userId as string;
-
-		getUserById(userId)
-			.then(setDisplayedUser)
-			.catch((err: Error) => {
-				notify('Error', err.message, 'red');
-			});
-
-		getReportsOfUser(userId)
-			.then(setReports)
-			.catch((err) => {
-				console.error(err);
-				notify('Error', 'Failed to fetch reports', 'red');
-			});
+		reload();
 	});
 
-	return (displayedUser && (
-		<Tabs defaultValue="1" variant="pills" color="blue" >
-			<Tile mb="sm">
-				<Group justify="space-between">
-					<Tabs.List>
-						<Tabs.Tab value="1">Settings</Tabs.Tab>
-						<Tabs.Tab value="2" rightSection={reports.length > 0 ? <Badge color="orange">{reports.length}</Badge> : undefined}>
-							Reports
-						</Tabs.Tab>
-					</Tabs.List>
-					{self && (
+	const handleSetupForkedRepository = async () => {
+		startTransition(async () => {
+			await forkRepository();
+			await reload();
+		});
+	};
+
+	const forkedInfo = () => {
+		if (hasFork) {
+			return (
+				<Tile p="xs" pl={windowWidth <= BREAKPOINT_MOBILE_LARGE ? 'xs' : 'md'} color="teal">
+					<Group justify="space-between" gap="xs">
+						<Group gap="sm">
+							<GoCheckCircle size={20} color="white" />
+							<Group gap={3}>
+								<Text size="sm" c="white">Default textures repository forked: </Text>
+								<Text size="sm" c="white">
+									<Link href={hasFork} style={{ color: 'white' }}>
+										{windowWidth <= BREAKPOINT_MOBILE_LARGE ? 'link' : hasFork}
+									</Link>
+								</Text>
+							</Group>
+						</Group>
+
 						<Button
-							variant="transparent"
-							color="red"
-							onClick={() => signOut({ callbackUrl: '/' })}
+							variant="outline"
+							color="white"
+							fullWidth={windowWidth <= BREAKPOINT_MOBILE_LARGE}
 						>
-							Logout
+								Sync Fork
 						</Button>
-					)}
+					</Group>
+				</Tile>
+			);
+		}
+
+		return (
+			<Tile p="xs" pl={windowWidth <= BREAKPOINT_MOBILE_LARGE ? 'xs' : 'md'} color="yellow">
+				<Group justify="space-between" gap="xs">
+					<Group gap="sm">
+						<GoStop color="black" size={20} />
+						<Group gap="xs">
+							<Text size="sm" c="black">Default textures repository not forked</Text>
+						</Group>
+					</Group>
+
+					<Button
+						variant="outline"
+						color="black"
+						onClick={handleSetupForkedRepository}
+						disabled={!!hasFork}
+						loading={loading}
+						fullWidth={windowWidth <= BREAKPOINT_MOBILE_LARGE}
+					>
+						Create Fork
+					</Button>
 				</Group>
 			</Tile>
+		);
+	};
+
+	return (displayedUser && (
+		<Stack gap="xl">
+			<Group
+				wrap={windowWidth <= BREAKPOINT_MOBILE_LARGE ? 'wrap' : 'nowrap'}
+				gap="xs"
+				align="start"
+				justify="center"
+			>
+				<Stack w={160} align="center" gap="xs">
+					<TextureImage
+						src={form.values['image'] ?? ''}
+						alt="User avatar"
+						styles={{
+							borderRadius: 'var(--mantine-radius-default)',
+						}}
+						size={160}
+					/>
+
+					<Badge
+						color={user?.role === UserRole.BANNED ? 'red' : 'teal'}
+						variant="filled"
+					>
+						{user?.role ?? '?'}
+					</Badge>
+				</Stack>
+
+				<Tile w="100%" p="sm" h={windowWidth <= BREAKPOINT_MOBILE_LARGE ? 'auto' : 160}>
+					<Group
+						h="100%"
+						justify="space-between"
+						wrap={windowWidth <= BREAKPOINT_MOBILE_LARGE ? 'wrap' : 'nowrap'}
+					>
+						<Stack
+							h="100%"
+							w="100%"
+							justify="space-between"
+						>
+							<TextInput
+								label="Name"
+								required
+								w="100%"
+								{...form.getInputProps('name')}
+							/>
+							<TextInput
+								label="Picture URL"
+								w="100%"
+								{...form.getInputProps('image')}
+							/>
+						</Stack>
 
-			<Tabs.Panel value="1"><UserSettingsPanel user={displayedUser} self={self} /></Tabs.Panel>
-			<Tabs.Panel value="2"><UserReportsPanel user={displayedUser} self={self} reports={reports} /></Tabs.Panel>
-		</Tabs>
+						<Stack
+							h="100%"
+							w="100%"
+							justify="space-between"
+							align="flex-end"
+							style={{
+								flexDirection: windowWidth <= BREAKPOINT_MOBILE_LARGE ? 'column-reverse' : 'column',
+							}}
+						>
+							<Button
+								justify={windowWidth <= BREAKPOINT_MOBILE_LARGE ? 'center' : 'right'}
+								variant="transparent"
+								color="red"
+								onClick={() => signOut({ callbackUrl: '/' })}
+							>
+								Sign out
+							</Button>
+							<Button
+								variant="gradient"
+								gradient={GRADIENT}
+								onClick={() => onSubmit(form.values)}
+								disabled={loading || !form.isValid() || user === undefined}
+								loading={loading}
+							>
+								Save
+							</Button>
+						</Stack>
+					</Group>
+				</Tile>
+			</Group>
+
+			<Stack gap="xs">
+				<Text fw={700}>Contributions Repository</Text>
+				{forkedInfo()}
+			</Stack>
+
+			<Stack gap="xs" mb="sm">
+				<Text fw={700}>Danger Zone</Text>
+				<Tile
+					p="xs"
+					pl="md"
+					withBorder
+					style={{
+						backgroundColor: 'transparent',
+						borderColor: 'var(--mantine-color-red-filled)',
+					}}
+				>
+					<Group justify="space-between">
+						<Stack gap={0}>
+							<Text>Delete the forked repository</Text>
+							<Text c="dimmed" size="xs">This action is irreversible, all contributions will be lost.</Text>
+						</Stack>
+						<Button
+							variant="default"
+							style={{ color: 'var(--mantine-color-red-text)' }}
+							onClick={handleForkDelete}
+							disabled={!hasFork}
+							loading={loading}
+							fullWidth={windowWidth <= BREAKPOINT_MOBILE_LARGE}
+						>
+							Delete Fork
+						</Button>
+					</Group>
+				</Tile>
+			</Stack>
+		</Stack>
 	));
 };
 
diff --git a/src/app/(pages)/(protected)/user/[userId]/reports-panel.tsx b/src/app/(pages)/(protected)/user/[userId]/reports-panel.tsx
deleted file mode 100644
index 40f181c8..00000000
--- a/src/app/(pages)/(protected)/user/[userId]/reports-panel.tsx
+++ /dev/null
@@ -1,380 +0,0 @@
-
-import Link from 'next/link';
-
-import { Fragment, startTransition, useEffect, useState } from 'react';
-
-import { FaArrowRight } from 'react-icons/fa';
-import { IoCloseOutline, IoCheckmark } from 'react-icons/io5';
-
-import { Avatar, Button, Group, Select, Stack, Table, Text, Textarea } from '@mantine/core';
-import { useForm } from '@mantine/form';
-import { UserRole, Status } from '@prisma/client';
-
-import { Tile } from '~/components/tile';
-import { useDeviceSize } from '~/hooks/use-device-size';
-import { useEffectOnce } from '~/hooks/use-effect-once';
-import { BREAKPOINT_MOBILE_LARGE, GRADIENT_DANGER, GRADIENT_WARNING } from '~/lib/constants';
-import { notify } from '~/lib/utils';
-import { getReportsReasons, reportSomeone, updateReportStatus } from '~/server/data/reports';
-import { getPublicUsers, updateUserRole } from '~/server/data/user';
-
-import type { SelectProps } from '@mantine/core';
-import type { ReportReason, Report, User } from '@prisma/client';
-import type { PublicUser } from '~/types';
-
-export function UserReportsPanel({ user, self, reports }: { user: User, reports: Report[], self: boolean }) {
-	const [users, setUsers] = useState<PublicUser[]>([]);
-	const [reasons, setReasons] = useState<ReportReason[]>([]);
-
-	const [localUser, setUser] = useState<User>(user);
-	const [localReports, setReports] = useState<Report[]>(reports);
-
-	useEffect(() => {
-		setReports(reports);
-		setUser(user);
-	}, [reports, user]);
-
-	const [windowWidth] = useDeviceSize();
-
-	useEffectOnce(() => {
-		getPublicUsers()
-			.then((u) => {
-				setUsers(u.filter((uu) => uu.id !== localUser.id));
-			})
-			.catch((err) => {
-				console.error(err);
-				notify('Error', 'Failed to fetch users', 'red');
-			});
-
-		getReportsReasons()
-			.then(setReasons)
-			.catch((err) => {
-				console.error(err);
-				notify('Error', 'Failed to fetch report reasons', 'red');
-			});
-	});
-
-	const form = useForm<{ reportedId: string, reasonId: string, additionalInfo: string }>({
-		initialValues: { reportedId: '', reasonId: '', additionalInfo: '' },
-		validate: {
-			reportedId: (value) => !value && 'You must select a user to report',
-			reasonId: (value) => !value && 'You must select a reason to report',
-		},
-		onValuesChange: () => {
-			form.validate();
-		},
-	});
-
-	const renderUserSelectOption: SelectProps['renderOption'] = ({ option }) => {
-		const user = users.find((u) => u.id === option.value);
-
-		return (
-			<Group gap="sm" wrap="nowrap">
-				<Avatar src={user?.image} size={30} radius="xl" />
-				<div>
-					<Text size="sm">{user?.name ?? option.label}</Text>
-				</div>
-			</Group>
-		);
-	};
-
-	const renderReasonSelectOption: SelectProps['renderOption'] = ({ option }) => {
-		const reason = reasons.find((r) => r.id === option.value)!;
-
-		return (
-			<Stack gap={0}>
-				<Text size="sm" tt="capitalize">{reason.value}</Text>
-				<Text size="xs" c="dimmed">{reason.description}</Text>
-			</Stack>
-		);
-	};
-
-	const report = () => {
-		startTransition(() => {
-			reportSomeone({
-				reporterId: localUser.id,
-				reportedId: form.values.reportedId,
-				reasonId: form.values.reasonId,
-				additionalInfo: form.values.additionalInfo,
-			})
-				.then(() => {
-					form.setValues({ reportedId: '', reasonId: '', additionalInfo: '' });
-					notify('Success', 'Report sent successfully', 'green');
-				});
-		});
-	};
-
-	const updateReport = (reportId: string, status: Status) => {
-		startTransition(() => {
-			updateReportStatus(reportId, status)
-				.then(() => {
-					notify('Success', 'Report dismissed!', 'green');
-					setReports(localReports.map((r) => r.id === reportId ? { ...r, status: status } : r));
-				})
-				.catch((err) => {
-					console.error(err);
-					notify('Error', err.message, 'red');
-				});
-		});
-	};
-
-	const ban = () => {
-		startTransition(() => {
-			updateUserRole(localUser.id, UserRole.BANNED)
-				.then(() => {
-					notify('Success', 'User banned!', 'green');
-					setUser({ ...localUser, role: UserRole.BANNED });
-				})
-				.catch((err) => {
-					console.error(err);
-					notify('Error', err.message, 'red');
-				});
-		});
-	};
-
-	const pardon = () => {
-		startTransition(() => {
-			updateUserRole(localUser.id, UserRole.USER)
-				.then(() => {
-					notify('Success', 'User pardoned!', 'green');
-					setUser({ ...localUser, role: UserRole.USER });
-				})
-				.catch((err) => {
-					console.error(err);
-					notify('Error', err.message, 'red');
-				});
-		});
-	};
-
-	return (
-		<Stack gap="sm">
-			{self && (
-				<Tile>
-					<Stack gap="md">
-						<Stack gap={0}>
-							<Text size="md" fw={700}>Reports</Text>
-							<Text size="sm">
-								Here you can report someone for breaking our <Text component="a" href="/docs/tos" c="blue" target="_blank">ToS</Text>. If you have any issues, please report them here.
-							</Text>
-						</Stack>
-						<Group gap="md">
-							<Select
-								style={{ width: windowWidth <= BREAKPOINT_MOBILE_LARGE ? '100%' : 'calc((100% - var(--mantine-spacing-md)) * .5)' }}
-								limit={10}
-								label="User to report"
-								placeholder="Select or search a user..."
-								data={users.map((u) => ({ value: u.id, label: u.name ?? 'Unknown' }))}
-								renderOption={renderUserSelectOption}
-								defaultValue={''}
-								searchable
-								clearable
-								required
-								{...form.getInputProps('reportedId')}
-							/>
-							<Select
-								style={{ width: windowWidth <= BREAKPOINT_MOBILE_LARGE ? '100%' : 'calc((100% - var(--mantine-spacing-md)) * .5)' }}
-								label="Reason"
-								placeholder="Select a reason..."
-								data={reasons.map((r) => ({ value: r.id, label: r.value }))}
-								renderOption={renderReasonSelectOption}
-								defaultValue={''}
-								searchable
-								clearable
-								required
-								{...form.getInputProps('reasonId')}
-							/>
-							<Textarea
-								label="Additional information"
-								description="Please provide as much information as possible."
-								placeholder="Write here any additional information you think is relevant..."
-								className="w-full"
-								rows={5}
-								{...form.getInputProps('additionalInfo')}
-							/>
-						</Group>
-						<Group>
-							<Button
-								variant="gradient"
-								gradient={GRADIENT_DANGER}
-								className={!form.isValid() ? 'button-disabled-with-bg' : ''}
-								disabled={!form.isValid()}
-								fullWidth={windowWidth <= BREAKPOINT_MOBILE_LARGE}
-								onClick={report}
-							>
-								Report
-							</Button>
-							{form.isValid() && (
-								<Text size="sm" ta={windowWidth <= BREAKPOINT_MOBILE_LARGE ? 'center' : 'left'}>
-									By reporting someone, you agree to our <Text component="a" href="/docs/tos" c="blue" target="_blank">Terms of Service</Text>.
-								</Text>
-							)}
-						</Group>
-					</Stack>
-				</Tile>
-			)}
-
-			<Tile>
-				<Stack gap="md">
-					<Stack gap={0}>
-						<Text size="md" fw={700}>Reports against {self ? 'you' : 'that user'}</Text>
-						{localReports.length === 0 && (
-							<Text size="sm">
-								{self
-									? 'You don\'t have any reports against you. Keep it up!'
-									: 'This user doesn\'t have any reports against them.'
-								}
-							</Text>
-						)}
-						{localReports.length > 0 && (
-							<Table striped highlightOnHover withColumnBorders withTableBorder mt="xs">
-								<Table.Thead>
-									{windowWidth > BREAKPOINT_MOBILE_LARGE && (
-										<Table.Tr>
-											{!self && <Table.Th>Reporter</Table.Th>}
-											<Table.Th>Reason</Table.Th>
-											<Table.Th>Reported</Table.Th>
-											<Table.Th>Updated</Table.Th>
-											<Table.Th>Status</Table.Th>
-										</Table.Tr>
-									)}
-								</Table.Thead>
-								<Table.Tbody>
-									{localReports.map((report, i) => (
-										<Fragment key={report.id}>
-											{windowWidth > BREAKPOINT_MOBILE_LARGE && (
-												<Table.Tr>
-													{!self && (
-														<Table.Td className="w-[200px]">
-															<Group justify="space-between">
-																{renderUserSelectOption({ option: { value: report.reporterId, label: '' } })}
-																<Link href={'/user/' + report.reporterId}>
-																	<Button
-																		variant='transparent'
-																		className="navbar-icon-fix"
-																	>
-																		<FaArrowRight />
-																	</Button>
-																</Link>
-															</Group>
-														</Table.Td>
-													)}
-													<Table.Td className="w-[160px]">{reasons.find((r) => r.id === report.reportReasonId)?.value}</Table.Td>
-													<Table.Td align="center" className="w-[160px]">{report.createdAt.toLocaleString()}</Table.Td>
-													<Table.Td align="center" className="w-[160px]">{report.updatedAt.toLocaleString()}</Table.Td>
-													<Table.Td className="w-[160px]" align="center">
-														{!self && report.status === Status.PENDING && (
-															<Group gap="xs" justify="center">
-																<Button size="xs" className="navbar-icon-fix" onClick={() => updateReport(report.id, Status.REJECTED)}><IoCloseOutline className="w-5 h-5" /></Button>
-																<Button size="xs" className="navbar-icon-fix" onClick={() => updateReport(report.id, Status.ACCEPTED)}><IoCheckmark className="w-5 h-5" /></Button>
-															</Group>
-														)}
-														{!self && report.status !== Status.PENDING && <Text c="dimmed">{report.status}</Text>}
-														{self && <Text c="dimmed">{report.status}</Text>}
-													</Table.Td>
-												</Table.Tr>
-											)}
-											{windowWidth <= BREAKPOINT_MOBILE_LARGE && (
-												<>
-													<Table.Tr>
-														<Table.Td colSpan={2}>
-															<Group justify="space-between">
-																<Stack gap={0}>
-																	<Text c="dimmed" size="sm">Reason: </Text>
-																	<Text size="sm">{reasons.find((r) => r.id === report.reportReasonId)?.value}</Text>
-																</Stack>
-																{!self && report.status === Status.PENDING && (
-																	<Group gap="xs" justify="center">
-																		<Button size="xs" className="navbar-icon-fix" onClick={() => updateReport(report.id, Status.REJECTED)}><IoCloseOutline className="w-5 h-5" /></Button>
-																		<Button size="xs" className="navbar-icon-fix" onClick={() => updateReport(report.id, Status.ACCEPTED)}><IoCheckmark className="w-5 h-5" /></Button>
-																	</Group>
-																)}
-																{!self && report.status !== Status.PENDING && <Text c="dimmed" size="sm">{report.status}</Text>}
-																{self && <Text c="dimmed" size="sm">{report.status}</Text>}
-															</Group>
-														</Table.Td>
-													</Table.Tr>
-													{!self && (
-														<Table.Tr>
-															<Table.Td colSpan={2}>
-																<Group justify="space-between">
-																	{renderUserSelectOption({ option: { value: report.reporterId, label: '' } })}
-																	<Link href={'/user/' + report.reporterId}>
-																		<Button
-																			variant='transparent'
-																			className="navbar-icon-fix"
-																		>
-																			<FaArrowRight />
-																		</Button>
-																	</Link>
-																</Group>
-															</Table.Td>
-														</Table.Tr>
-													)}
-													<Table.Tr>
-														<Table.Td align="center">
-															<Text c="dimmed" size="sm">Reported:</Text>
-															<Text size="sm">{report.createdAt.toLocaleString()}</Text>
-														</Table.Td>
-														<Table.Td align="center">
-															<Text c="dimmed" size="sm">Updated:</Text>
-															<Text size="sm">{report.updatedAt.toLocaleString()}</Text>
-														</Table.Td>
-													</Table.Tr>
-												</>
-											)}
-											{!self && (
-												<Table.Tr>
-													<Table.Td colSpan={5}>
-														{report.context.length > 0
-															? (
-																<>
-																	<Text size="sm" c="dimmed">Additional information:</Text>
-																	<Text size="sm">{report.context}</Text>
-																</>
-															)
-															: <Text size="sm" ta="center" c="dimmed">No additional information provided.</Text>}
-													</Table.Td>
-												</Table.Tr>
-											)}
-										</Fragment>
-									))}
-								</Table.Tbody>
-							</Table>
-						)}
-
-						{!self && (
-							<Group justify="end">
-								{localUser.role !== UserRole.BANNED && (
-									<Button
-										mt="sm"
-										variant="gradient"
-										gradient={GRADIENT_DANGER}
-										disabled={localReports.length === 0}
-										className={localReports.length === 0 ? 'button-disabled-with-bg' : ''}
-										onClick={ban}
-										w={windowWidth <= BREAKPOINT_MOBILE_LARGE ? '100%' : 'auto'}
-									>
-										Ban
-									</Button>
-								)}
-								{localUser.role === UserRole.BANNED && (
-									<Button
-										mt="sm"
-										variant="gradient"
-										gradient={GRADIENT_WARNING}
-										disabled={localUser.role !== UserRole.BANNED}
-										className={localUser.role !== UserRole.BANNED ? 'button-disabled-with-bg' : ''}
-										onClick={pardon}
-										w={windowWidth <= BREAKPOINT_MOBILE_LARGE ? '100%' : 'auto'}
-									>
-										Pardon
-									</Button>
-								)}
-							</Group>
-						)}
-					</Stack>
-				</Stack>
-			</Tile>
-		</Stack>
-	);
-}
diff --git a/src/app/(pages)/(protected)/user/[userId]/settings-panel.tsx b/src/app/(pages)/(protected)/user/[userId]/settings-panel.tsx
deleted file mode 100644
index c8ac7251..00000000
--- a/src/app/(pages)/(protected)/user/[userId]/settings-panel.tsx
+++ /dev/null
@@ -1,113 +0,0 @@
-'use client';
-
-import { useTransition } from 'react';
-
-import { Button, Text, TextInput, Group, Stack, Badge } from '@mantine/core';
-import { useForm } from '@mantine/form';
-import { UserRole } from '@prisma/client';
-import { useSession } from 'next-auth/react';
-
-import { TextureImage } from '~/components/texture-img';
-import { Tile } from '~/components/tile';
-import { useDeviceSize } from '~/hooks/use-device-size';
-import { BREAKPOINT_MOBILE_LARGE, GRADIENT, MAX_NAME_LENGTH, MIN_NAME_LENGTH } from '~/lib/constants';
-import { notify } from '~/lib/utils';
-import { updateUser } from '~/server/data/user';
-
-import type { User } from '@prisma/client';
-
-export function UserSettingsPanel({ user, self }: { user: User, self: boolean }) {
-	const { update } = useSession();
-	const [isPending, startTransition] = useTransition();
-	const [windowWidth] = useDeviceSize();
-
-	const pictureWidth = windowWidth <= BREAKPOINT_MOBILE_LARGE ? `calc(${windowWidth - 2}px - (2 * var(--mantine-spacing-md)) - (2 * var(--mantine-spacing-sm)) )` : '120px';
-
-	const form = useForm<Pick<User, 'name' | 'image'>>({
-		initialValues: { name: user.name, image: user.image },
-		validate: {
-			name: (value) => {
-				if (!value) return 'You must provide a name';
-				if (value.length < MIN_NAME_LENGTH) return `Your name should be at least ${MIN_NAME_LENGTH} characters long`;
-				if (value.length > MAX_NAME_LENGTH) return `Your name should be less than ${MAX_NAME_LENGTH} characters long`;
-			},
-			image: (value) => {
-				if (!value) return null;
-				if (!value?.startsWith('https://')) return 'Your personal picture should be a HTTPS URL';
-			},
-		},
-		onValuesChange: () => {
-			form.validate();
-		},
-	});
-
-	const onSubmit = (values: typeof form.values) => {
-		if (!user) return;
-
-		startTransition(() => {
-			updateUser({ ...values, id: user.id })
-				.then((user) => {
-					update(user);
-					notify('Success', 'Profile updated', 'teal');
-				})
-				.catch((err) => {
-					console.error(err);
-					notify('Error', err.message, 'red');
-				});
-		});
-	};
-
-	return (
-		<Tile mb="sm">
-			<Group>
-				<TextureImage
-					src={form.values['image'] ?? ''}
-					alt="User avatar"
-					size={pictureWidth}
-				/>
-
-				<Stack
-					justify="space-between"
-					style={{ width:  windowWidth > BREAKPOINT_MOBILE_LARGE ? `calc(100% - ${pictureWidth} - var(--mantine-spacing-md))` : pictureWidth }}
-				>
-					<Group
-						gap="sm"
-						align="flex-start"
-						justify="space-between"
-					>
-						<Stack gap={0}>
-							<Text size="md" fw={700}>Profile Settings</Text>
-							<Text size="sm">Update {self ? 'your profile' : `${user?.name} profile's` } information</Text>
-						</Stack>
-						<Badge color={user?.role === UserRole.BANNED ? 'red' : 'teal'} variant="filled">{user?.role ?? '?'}</Badge>
-					</Group>
-
-					<Group gap="sm">
-						<TextInput
-							label="Name"
-							required
-							style={{ width: windowWidth <= BREAKPOINT_MOBILE_LARGE ? '100%' : 'calc((100% - var(--mantine-spacing-sm)) * .3)' }}
-							{...form.getInputProps('name')}
-						/>
-						<TextInput
-							label="Picture URL"
-							style={{ width: windowWidth <= BREAKPOINT_MOBILE_LARGE ? '100%' : 'calc((100% - var(--mantine-spacing-sm)) * .7)' }}
-							{...form.getInputProps('image')}
-						/>
-					</Group>
-				</Stack>
-			</Group>
-
-			<Button
-				variant="gradient"
-				gradient={GRADIENT}
-				onClick={() => onSubmit(form.values)}
-				disabled={isPending || !form.isValid() || user === undefined}
-				loading={isPending}
-				mt="md"
-			>
-        Save
-			</Button>
-		</Tile>
-	);
-}

From 26e97e16e0d737b7c67f703cd3a7d1fe1d1d512e Mon Sep 17 00:00:00 2001
From: Julien Constant <julienconstant190@gmail.com>
Date: Sun, 18 Aug 2024 03:50:27 +0200
Subject: [PATCH 07/30] feat : (part 3) almost finished new contribution system

---
 .../(protected)/contribute/about/page.tsx     |   6 -
 .../(pages)/(protected)/contribute/layout.tsx |  22 -
 .../(pages)/(protected)/contribute/page.tsx   | 422 +++++++++++++++++-
 .../contribute/{submissions => }/styles.scss  |   0
 .../contribute/submissions/page.tsx           | 312 -------------
 src/server/data/contributions.ts              |   9 +
 6 files changed, 420 insertions(+), 351 deletions(-)
 delete mode 100644 src/app/(pages)/(protected)/contribute/about/page.tsx
 delete mode 100644 src/app/(pages)/(protected)/contribute/layout.tsx
 rename src/app/(pages)/(protected)/contribute/{submissions => }/styles.scss (100%)
 delete mode 100644 src/app/(pages)/(protected)/contribute/submissions/page.tsx

diff --git a/src/app/(pages)/(protected)/contribute/about/page.tsx b/src/app/(pages)/(protected)/contribute/about/page.tsx
deleted file mode 100644
index 089a1d45..00000000
--- a/src/app/(pages)/(protected)/contribute/about/page.tsx
+++ /dev/null
@@ -1,6 +0,0 @@
-
-export default function ContributeAboutPage() {
-	return (
-		'WIP'
-	);
-}
diff --git a/src/app/(pages)/(protected)/contribute/layout.tsx b/src/app/(pages)/(protected)/contribute/layout.tsx
deleted file mode 100644
index d3712e59..00000000
--- a/src/app/(pages)/(protected)/contribute/layout.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-'use client';
-
-import { TabsLayout } from '~/components/tabs';
-
-interface Props {
-	children: React.ReactNode;
-}
-
-export default function ContributeLayout({ children }: Props) {
-	return (
-		<TabsLayout
-			tabs={[
-				{ value: 'about', label: 'About' },
-				{ value: 'submissions', label: 'Submissions' },
-				{ value: 'settings', label: 'Settings' },
-			]}
-		>
-			{children}
-		</TabsLayout>
-	);
-
-}
diff --git a/src/app/(pages)/(protected)/contribute/page.tsx b/src/app/(pages)/(protected)/contribute/page.tsx
index 8194e6a9..82cd8a33 100644
--- a/src/app/(pages)/(protected)/contribute/page.tsx
+++ b/src/app/(pages)/(protected)/contribute/page.tsx
@@ -1,18 +1,418 @@
 'use client';
 
-import { redirect } from 'next/navigation';
+import Link from 'next/link';
 
-import { useLocalStorage } from '@mantine/hooks';
+import { useCallback, useEffect, useState, useTransition } from 'react';
 
-const CouncilPage = () => {
-	const [isAboutShown] = useLocalStorage({
-		key: 'faithful-modded-show-contribute-about-page',
-		defaultValue: true,
-		getInitialValueInEffect: false,
+import { GoCommit, GoHash, GoHourglass, GoQuestion, GoRelFilePath } from 'react-icons/go';
+import { IoReload } from 'react-icons/io5';
+import { LuArrowUpDown } from 'react-icons/lu';
+
+import { ActionIcon, Badge, Button, FloatingIndicator, Group, Indicator, Kbd, List, Select, Stack, Text } from '@mantine/core';
+import { useHotkeys, useOs, usePrevious } from '@mantine/hooks';
+import { Resolution, Status } from '@prisma/client';
+
+import { SmallTile } from '~/components/small-tile';
+import { TextureImage } from '~/components/texture-img';
+import { Tile } from '~/components/tile';
+import { useCurrentUser } from '~/hooks/use-current-user';
+import { useDeviceSize } from '~/hooks/use-device-size';
+import { useEffectOnce } from '~/hooks/use-effect-once';
+import { BREAKPOINT_MOBILE_LARGE, COLORS, gitBlobUrl, gitCommitUrl, GRADIENT, GRADIENT_DANGER } from '~/lib/constants';
+import { getContributionsOfFork, getFork } from '~/server/actions/git';
+import { createContributionsFromGitFiles, deleteContributionsOrArchive, getContributionsOfUser, submitContributions } from '~/server/data/contributions';
+import { getTextures } from '~/server/data/texture';
+
+import type { Texture } from '@prisma/client';
+import type { GitFile } from '~/server/actions/git';
+import type { GetContributionsOfUser } from '~/server/data/contributions';
+
+import './styles.scss';
+
+export default function ContributeSubmissionsPage() {
+	const user = useCurrentUser()!; // the user is guaranteed to be logged in (per the layout)
+	const os = useOs();
+
+	const [loading, startTransition] = useTransition();
+	const [hasFork, setHasFork] = useState<string | null>(null);
+	const [showHelp, helpShown] = useState(true);
+
+	const [selectedContributions, setSelectedContributions] = useState<string[]>([]);
+
+	const [resolution, setResolution] = useState<Resolution>(Resolution.x32);
+	const prevRes = usePrevious(resolution);
+
+	const [contributions, setContributions] = useState<GetContributionsOfUser[]>([]);
+	const [textures, setTextures] = useState<Texture[]>([]);
+
+	const [groupRef, setGroupRef] = useState<HTMLDivElement | null>(null);
+	const [controlsRefs, setControlsRefs] = useState<Record<string, HTMLButtonElement | null>>({});
+	const [activeTab, setActiveTab] = useState(0);
+	const [windowWidth] = useDeviceSize();
+
+	useHotkeys([
+		['mod+a', () => setSelectedContributions(contributions.filter((c) => c.status === Object.keys(Status)[activeTab]).map((c) => c.id))],
+	]);
+
+	const setControlRef = (index: number) => (node: HTMLButtonElement) => {
+		controlsRefs[index] = node;
+		setControlsRefs(controlsRefs);
+	};
+
+	const reload = async () => {
+		startTransition(async () => {
+			const fork = await getFork();
+			if (!fork) return;
+
+			setHasFork(fork);
+			getContributionsOfFork(resolution).then(updateForkContributions);
+		});
+	};
+
+	const updateForkContributions = useCallback(async (files: GitFile[]) => {
+		const contributions = await getContributionsOfUser(user.id!, resolution);
+		const contributedSha = contributions.map((contribution) => contribution.hash);
+
+		// delete contributions that are not in the fork but are in the database
+		const missingFiles = contributions.filter((contribution) => !files.some((file) => file.sha === contribution.hash));
+		await deleteContributionsOrArchive(user.id!, missingFiles.map((contribution) => contribution.id));
+
+		// add contributions that are not yet in the database
+		const newFiles = files.filter((file) => !contributedSha.includes(file.sha));
+		await createContributionsFromGitFiles(user.id!, resolution, newFiles);
+
+		const contributionsAfter = await getContributionsOfUser(user.id!, resolution);
+		setContributions(contributionsAfter);
+
+	}, [user, resolution]);
+
+	useEffectOnce(() => {
+		reload();
+		getTextures().then(setTextures);
 	});
 
-	if (isAboutShown) redirect('/contribute/about');
-	redirect('/contribute/submissions');
-};
+	useEffect(() => {
+		if (prevRes === resolution) return;
+
+		startTransition(() => {
+			setSelectedContributions([]);
+			getContributionsOfFork(resolution).then(updateForkContributions);
+		});
+	}, [resolution, prevRes, updateForkContributions]);
+
+	useEffect(() => {
+		setSelectedContributions([]);
+	}, [activeTab]);
+
+	const controls = Object.entries(Status).map(([key, value], index) => {
+		const isLast = index === Object.keys(Status).length - 1;
+
+		return (
+			<Button
+				key={key}
+				ref={setControlRef(index)}
+				onClick={() => setActiveTab(index)}
+				mod={{ active: activeTab === index }}
+
+				pl="md"
+				pr="sm"
+				fullWidth
+				variant="filled"
+				leftSection={
+					<>
+						<Indicator color={COLORS[value]} mr="md" />
+						{value === Status.DRAFT
+							? 'Drafted'
+							: value === Status.PENDING
+								? 'Reviewed'
+								: value.charAt(0).toUpperCase() + value.slice(1).toLowerCase()
+						}
+					</>
+				}
+				rightSection={contributions.filter((c) => c.status === Object.keys(Status)[index]).length ?? 0}
+				justify="space-between"
+				className="slider-button"
+				style={windowWidth > BREAKPOINT_MOBILE_LARGE
+					? {
+						borderRight: !isLast && activeTab !== index + 1 && activeTab !== index
+							? 'calc(0.0625rem * var(--mantine-scale)) solid var(--mantine-color-default-border)'
+							: undefined,
+						borderRadius: isLast ? '0 calc(0.25rem * var(--mantine-scale)) calc(0.25rem * var(--mantine-scale)) 0' : undefined,
+					}
+					: undefined
+				}
+			/>
+		);
+	});
+
+	const handleSubmitSelectedDraftContribution = () => {
+		startTransition(async () => {
+			await submitContributions(user.id!, selectedContributions);
+			await reload();
+		});
+	};
+
+	return (
+		<Stack gap="xs">
+			<Group
+				wrap="nowrap"
+				gap="xs"
+			>
+				<ActionIcon
+					variant="default"
+					className="navbar-icon-fix"
+					onClick={() => helpShown(!showHelp)}
+				>
+					<GoQuestion />
+				</ActionIcon>
+
+				<ActionIcon
+					variant="default"
+					className="navbar-icon-fix"
+					onClick={reload}
+					loading={loading}
+				>
+					<IoReload />
+				</ActionIcon>
+
+				<Select
+					w={120}
+					data={Object.keys(Resolution)}
+					checkIconPosition="right"
+					value={resolution}
+					onChange={(e) => e ? setResolution(e as Resolution) : null}
+					clearable={false}
+				/>
+
+				<Button.Group
+					w="calc(100% - 34px - 34px - 120px - 180px - (3 * var(--mantine-spacing-xs)))"
+					ref={setGroupRef}
+					style={{
+						position: 'relative',
+					}}
+					orientation={windowWidth <= BREAKPOINT_MOBILE_LARGE ? 'vertical' : 'horizontal'}
+				>
+					{controls}
+					<FloatingIndicator
+						target={controlsRefs[activeTab]}
+						parent={groupRef}
+						style={{
+							border: 'calc(0.0625rem * var(--mantine-scale)) solid #fff3',
+							borderRadius: (() => {
+								if (activeTab === 0) return 'calc(0.25rem * var(--mantine-scale)) 0 0 calc(0.25rem * var(--mantine-scale))';
+								if (activeTab === Object.keys(Status).length - 1) return '0 calc(0.25rem * var(--mantine-scale)) calc(0.25rem * var(--mantine-scale)) 0';
+								return '0';
+							})(),
+							backgroundColor: '#0002',
+							cursor: 'pointer',
+							zIndex: 200,
+						}}
+					/>
+				</Button.Group>
+
+				{activeTab === 0 && (
+					<Button
+						w={180}
+						variant="gradient"
+						gradient={GRADIENT}
+						disabled={selectedContributions.length === 0}
+						onClick={handleSubmitSelectedDraftContribution}
+					>
+						Submit {selectedContributions.length} draft{selectedContributions.length > 1 ? 's' : ''}
+					</Button>
+				)}
+
+				{activeTab !== 0 && activeTab !== 4 && (
+					<Button
+						w={180}
+						variant="gradient"
+						gradient={GRADIENT_DANGER}
+						disabled={selectedContributions.length === 0}
+					>
+						Unlist {selectedContributions.length} contribution{selectedContributions.length > 1 ? 's' : ''}
+					</Button>
+				)}
+
+				{activeTab === 4 && (
+					<Button
+						w={180}
+						variant="gradient"
+						gradient={GRADIENT_DANGER}
+						disabled={selectedContributions.length === 0}
+					>
+						Delete {selectedContributions.length} contribution{selectedContributions.length > 1 ? 's' : ''}
+					</Button>
+				)}
+
+			</Group>
+
+			{showHelp && (
+				<Tile style={{ borderRadius: 'var(--mantine-radius-default)' }}>
+					<Text>
+						To contribute to the resource pack, you need to:
+					</Text>
+					<List ml="sm">
+						<List.Item><Text fw={360}>Fork the default repository using the settings in your <Link href='/user/me'>account page</Link>;</Text></List.Item>
+						<List.Item><Text fw={360}>Clone it to your local machine using <Link href="https://git-scm.com/" target="_blank">Git</Link> or <Link href="https://desktop.github.com/download/" target="_blank">GitHub Desktop</Link>;</Text></List.Item>
+						<List.Item><Text fw={360}>Switch to the branch corresponding to the resolution you want to contribute to;</Text></List.Item>
+						<List.Item><Text fw={360}>Add textures to the repository, <Text component="span" fw={700}>each texture should have the same name as the contributed texture ID</Text>;</Text></List.Item>
+						<List.Item><Text fw={360}>Commit your changes and push them to your fork;</Text></List.Item>
+						<List.Item><Text fw={360}>Click the reload button to see your contributions here.</Text></List.Item>
+					</List>
+
+					<Text mt="md">
+						When reloading:
+					</Text>
+					<List ml="sm">
+						<List.Item>New contributions will be added to the <Badge component="span" color={COLORS.DRAFT}>Drafted</Badge> tab;</List.Item>
+						<List.Item>Missing contributions that are either <Badge component="span" color={COLORS.PENDING}>Reviewed</Badge> or <Badge component="span" color={COLORS.REJECTED}>Rejected</Badge> will be deleted from the database;</List.Item>
+						<List.Item>Missing contributions that are <Badge component="span" color={COLORS.ACCEPTED}>Accepted</Badge> will be <Badge component="span" color={COLORS.ARCHIVED} style={{ color: 'black' }}>Archived</Badge>.</List.Item>
+					</List>
+
+					<Text mt="md">
+						A few tips:
+					</Text>
+					<List ml="sm">
+						<List.Item>You have to click on contributions before submitting/unlisting/deleting them.</List.Item>
+						<List.Item>You can hit <Kbd component="span">{os === 'macos' ? '⌘' : 'Ctrl'}</Kbd> + <Kbd component="span">A</Kbd> to select them all</List.Item>
+					</List>
+				</Tile>
+			)}
+
+			{hasFork === null && (
+				<Group
+					align="center"
+					justify="center"
+					h="100px"
+					w="100%"
+					style={{ height: 'calc(81% - (2 * var(--mantine-spacing-sm) - 62px))' }}
+				>
+					<Text c="dimmed">You need to fork the default repository first</Text>
+				</Group>
+			)}
+
+			{hasFork && (
+				<Group gap="xs">
+					{contributions.filter((c) => c.status === Object.keys(Status)[activeTab]).map((contribution, index) => {
+						const texture = textures.find((t) => t.id === contribution.textureId);
+						const orgOrUser = contribution.filepath.split('/')[3]!;
+						const repository = contribution.filepath.split('/')[4]!;
+						const commitSha = contribution.filepath.split('/')[5]!;
+
+						return (
+							(
+								<TextureImage
+									key={index}
+									src={contribution.filepath}
+									alt={contribution.filename}
+									onClick={() => setSelectedContributions((prev) => {
+										if (prev.includes(contribution.id)) return prev.filter((id) => id !== contribution.id);
+										return [...prev, contribution.id];
+									})}
+									styles={{
+										boxShadow: selectedContributions.includes(contribution.id) ? '0 0 0 2px var(--mantine-color-teal-filled)' : 'none',
+									}}
+									popupStyles={{
+										backgroundColor: 'transparent',
+										padding: 0,
+										border: 'none',
+										boxShadow: 'none',
+									}}
+								>
+									<Group gap={2}>
+										{texture && (
+											<SmallTile color="gray" w={125} h="100%">
+												<Group w="100%" h="100%" justify="center" align="center">
+													<TextureImage
+														src={texture.filepath}
+														alt={texture.name}
+														mcmeta={texture.mcmeta}
+														size={115}
+													/>
+												</Group>
+											</SmallTile>
+										)}
+										<Stack gap={2} align="start" miw={400} maw={400}>
+											<SmallTile color="gray">
+												<Text fw={500} ta="center">{texture?.name}</Text>
+											</SmallTile>
+
+											<Group gap={2} w="100%" wrap="nowrap" align="start">
+												<SmallTile color="gray" className="navbar-icon-fix" style={{ '--size': '28px' }}>
+													<GoCommit />
+												</SmallTile>
+												<SmallTile color="gray">
+													<Text size="xs">
+														<a
+															href={gitCommitUrl({ orgOrUser, repository, commitSha })}
+															target="_blank"
+															rel="noreferrer"
+														>
+															{commitSha}
+														</a>
+													</Text>
+												</SmallTile>
+											</Group>
+
+											<Group gap={2} w="100%" wrap="nowrap" align="start">
+												<SmallTile color="gray" className="navbar-icon-fix" style={{ '--size': '28px' }}>
+													<GoRelFilePath />
+												</SmallTile>
+												<SmallTile color="gray">
+													<Text size="xs">
+														<a
+															href={gitBlobUrl({ orgOrUser, repository, branchOrCommit: commitSha, path: contribution.filename })}
+															target="_blank"
+															rel="noreferrer"
+														>
+															{contribution.filename}
+														</a>
+													</Text>
+												</SmallTile>
+											</Group>
+
+											<Group gap={2} w="100%">
+												<Group gap={2} w="calc((100% - 4px) / 3)" wrap="nowrap" align="start">
+													<SmallTile color="gray" className="navbar-icon-fix" style={{ '--size': '28px' }}>
+														<LuArrowUpDown />
+													</SmallTile>
+													<SmallTile color="gray">
+														<Text size="xs">
+															{contribution.poll.upvotes.length - contribution.poll.downvotes.length}
+														</Text>
+													</SmallTile>
+												</Group>
+												<Group gap={2} w="calc((100% - 4px) / 3)" wrap="nowrap" align="start">
+													<SmallTile color="gray" className="navbar-icon-fix" style={{ '--size': '28px' }}>
+														<GoHash />
+													</SmallTile>
+													<SmallTile color="gray">
+														<Text size="xs">
+															{contribution.textureId}
+														</Text>
+													</SmallTile>
+												</Group>
+												<Group gap={2} w="calc((100% - 4px) / 3)" wrap="nowrap" align="start">
+													<SmallTile color="gray" className="navbar-icon-fix" style={{ '--size': '28px' }}>
+														<GoHourglass />
+													</SmallTile>
+													<SmallTile color="gray">
+														<Text size="xs">
+															{contribution.status}
+														</Text>
+													</SmallTile>
+												</Group>
+											</Group>
+										</Stack>
+									</Group>
+								</TextureImage>
+							)
+						);
+					})}
+				</Group>
+			)}
+
+		</Stack>
+	);
+}
 
-export default CouncilPage;
diff --git a/src/app/(pages)/(protected)/contribute/submissions/styles.scss b/src/app/(pages)/(protected)/contribute/styles.scss
similarity index 100%
rename from src/app/(pages)/(protected)/contribute/submissions/styles.scss
rename to src/app/(pages)/(protected)/contribute/styles.scss
diff --git a/src/app/(pages)/(protected)/contribute/submissions/page.tsx b/src/app/(pages)/(protected)/contribute/submissions/page.tsx
deleted file mode 100644
index cc6f1edc..00000000
--- a/src/app/(pages)/(protected)/contribute/submissions/page.tsx
+++ /dev/null
@@ -1,312 +0,0 @@
-'use client';
-
-import { useCallback, useEffect, useState, useTransition } from 'react';
-
-import { GoCommit, GoHash, GoHourglass, GoRelFilePath } from 'react-icons/go';
-import { IoReload } from 'react-icons/io5';
-import { LuArrowUpDown } from 'react-icons/lu';
-
-import { ActionIcon, Button, FloatingIndicator, Group, Indicator, Select, Stack, Text } from '@mantine/core';
-import { usePrevious } from '@mantine/hooks';
-import { Resolution, Status } from '@prisma/client';
-
-import { SmallTile } from '~/components/small-tile';
-import { TextureImage } from '~/components/texture-img';
-import { useCurrentUser } from '~/hooks/use-current-user';
-import { useDeviceSize } from '~/hooks/use-device-size';
-import { useEffectOnce } from '~/hooks/use-effect-once';
-import { BREAKPOINT_MOBILE_LARGE, COLORS, gitBlobUrl, gitCommitUrl } from '~/lib/constants';
-import { getContributionsOfFork, getFork } from '~/server/actions/git';
-import { createContributionsFromGitFiles, deleteContributionsOrArchive, getContributionsOfUser } from '~/server/data/contributions';
-import { getTextures } from '~/server/data/texture';
-
-import type { Texture } from '@prisma/client';
-import type { GitFile } from '~/server/actions/git';
-import type { GetContributionsOfUser } from '~/server/data/contributions';
-
-import './styles.scss';
-
-export default function ContributeSubmissionsPage() {
-	const user = useCurrentUser()!; // the user is guaranteed to be logged in (per the layout)
-
-	const [loading, startTransition] = useTransition();
-	const [hasFork, setHasFork] = useState<string | null>(null);
-
-	const [resolution, setResolution] = useState<Resolution>(Resolution.x32);
-	const prevRes = usePrevious(resolution);
-
-	const [contributions, setContributions] = useState<GetContributionsOfUser[]>([]);
-	const [textures, setTextures] = useState<Texture[]>([]);
-
-	const [groupRef, setGroupRef] = useState<HTMLDivElement | null>(null);
-	const [controlsRefs, setControlsRefs] = useState<Record<string, HTMLButtonElement | null>>({});
-	const [activeTab, setActiveTab] = useState(0);
-	const [windowWidth] = useDeviceSize();
-
-	const setControlRef = (index: number) => (node: HTMLButtonElement) => {
-		controlsRefs[index] = node;
-		setControlsRefs(controlsRefs);
-	};
-
-	const reload = async () => {
-		startTransition(async () => {
-			const fork = await getFork();
-			if (!fork) return;
-
-			setHasFork(fork);
-			getContributionsOfFork(resolution).then(updateForkContributions);
-		});
-	};
-
-	const updateForkContributions = useCallback(async (files: GitFile[]) => {
-		const contributions = await getContributionsOfUser(user.id!, resolution);
-		const contributedSha = contributions.map((contribution) => contribution.hash);
-
-		// delete contributions that are not in the fork but are in the database
-		const missingFiles = contributions.filter((contribution) => !files.some((file) => file.sha === contribution.hash));
-		await deleteContributionsOrArchive(user.id!, missingFiles.map((contribution) => contribution.id));
-
-		// add contributions that are not yet in the database
-		const newFiles = files.filter((file) => !contributedSha.includes(file.sha));
-		await createContributionsFromGitFiles(user.id!, resolution, newFiles);
-
-		const contributionsAfter = await getContributionsOfUser(user.id!, resolution);
-		setContributions(contributionsAfter);
-
-	}, [user, resolution]);
-
-	useEffectOnce(() => {
-		reload();
-		getTextures().then(setTextures);
-	});
-
-	useEffect(() => {
-		if (prevRes === resolution) return;
-
-		startTransition(() => {
-			getContributionsOfFork(resolution).then(updateForkContributions);
-		});
-	}, [resolution, prevRes, updateForkContributions]);
-
-	const controls = Object.entries(Status).map(([key, value], index) => {
-		const isLast = index === Object.keys(Status).length - 1;
-
-		return (
-			<Button
-				key={key}
-				ref={setControlRef(index)}
-				onClick={() => setActiveTab(index)}
-				mod={{ active: activeTab === index }}
-
-				pl="md"
-				pr="sm"
-				fullWidth
-				variant="filled"
-				leftSection={
-					<>
-						<Indicator color={COLORS[value]} mr="md" />
-						{value === Status.DRAFT
-							? 'Drafted'
-							: value === Status.PENDING
-								? 'Reviewed'
-								: value.charAt(0).toUpperCase() + value.slice(1).toLowerCase()
-						}
-					</>
-				}
-				rightSection={0}
-				justify="space-between"
-				className="slider-button"
-				style={windowWidth > BREAKPOINT_MOBILE_LARGE
-					? {
-						borderRight: !isLast && activeTab !== index + 1 && activeTab !== index
-							? 'calc(0.0625rem * var(--mantine-scale)) solid var(--mantine-color-default-border)'
-							: undefined,
-						borderRadius: isLast ? '0 calc(0.25rem * var(--mantine-scale)) calc(0.25rem * var(--mantine-scale)) 0' : undefined,
-					}
-					: undefined
-				}
-			/>
-		);
-	});
-
-	return (
-		<Stack gap="xs">
-			<Group
-				wrap="nowrap"
-				gap="xs"
-			>
-				<ActionIcon
-					variant="default"
-					className="navbar-icon-fix"
-					onClick={reload}
-					loading={loading}
-				>
-					<IoReload />
-				</ActionIcon>
-				<Select
-					w={120}
-					data={Object.keys(Resolution)}
-					checkIconPosition="right"
-					value={resolution}
-					onChange={(e) => e ? setResolution(e as Resolution) : null}
-					clearable={false}
-				/>
-				<Button.Group
-					w="calc(100% - 120px - var(--mantine-spacing-xs))"
-					ref={setGroupRef}
-					style={{
-						position: 'relative',
-					}}
-					orientation={windowWidth <= BREAKPOINT_MOBILE_LARGE ? 'vertical' : 'horizontal'}
-				>
-					{controls}
-
-					<FloatingIndicator
-						target={controlsRefs[activeTab]}
-						parent={groupRef}
-						style={{
-							border: 'calc(0.0625rem * var(--mantine-scale)) solid #fff3',
-							borderRadius: (() => {
-								if (activeTab === 0) return 'calc(0.25rem * var(--mantine-scale)) 0 0 calc(0.25rem * var(--mantine-scale))';
-								if (activeTab === Object.keys(Status).length - 1) return '0 calc(0.25rem * var(--mantine-scale)) calc(0.25rem * var(--mantine-scale)) 0';
-								return '0';
-							})(),
-							backgroundColor: '#0002',
-							cursor: 'pointer',
-							zIndex: 200,
-						}}
-					/>
-
-				</Button.Group>
-			</Group>
-
-			{hasFork === null && (
-				<Group
-					align="center"
-					justify="center"
-					h="100px"
-					w="100%"
-					style={{ height: 'calc(81% - (2 * var(--mantine-spacing-sm) - 62px))' }}
-				>
-					<Text c="dimmed">You need to fork the default repository first</Text>
-				</Group>
-			)}
-
-			{hasFork && (
-				<Group gap="xs">
-					{contributions.map((contribution) => {
-						const texture = textures.find((t) => t.id === contribution.textureId);
-						const orgOrUser = contribution.filepath.split('/')[3]!;
-						const repository = contribution.filepath.split('/')[4]!;
-						const commitSha = contribution.filepath.split('/')[5]!;
-
-						return (
-							(
-								<TextureImage
-									key={contribution.id}
-									src={contribution.filepath}
-									alt={contribution.filename}
-									popupStyles={{
-										backgroundColor: 'transparent',
-										padding: 0,
-										border: 'none',
-										boxShadow: 'none',
-									}}
-								>
-									<Group gap={2}>
-										{texture && (
-											<SmallTile color="gray" w={125} h="100%">
-												<Group w="100%" h="100%" justify="center" align="center">
-													<TextureImage
-														src={texture.filepath}
-														alt={texture.name}
-														mcmeta={texture.mcmeta}
-														size={115}
-													/>
-												</Group>
-											</SmallTile>
-										)}
-										<Stack gap={2} align="start" miw={400} maw={400}>
-											<SmallTile color="gray">
-												<Text fw={500} ta="center">{texture?.name}</Text>
-											</SmallTile>
-
-											<Group gap={2} w="100%" wrap="nowrap" align="start">
-												<SmallTile color="gray" className="navbar-icon-fix" style={{ '--size': '28px' }}>
-													<GoCommit />
-												</SmallTile>
-												<SmallTile color="gray">
-													<Text size="xs">
-														<a
-															href={gitCommitUrl({ orgOrUser, repository, commitSha })}
-															target="_blank"
-															rel="noreferrer"
-														>
-															{commitSha}
-														</a>
-													</Text>
-												</SmallTile>
-											</Group>
-
-											<Group gap={2} w="100%" wrap="nowrap" align="start">
-												<SmallTile color="gray" className="navbar-icon-fix" style={{ '--size': '28px' }}>
-													<GoRelFilePath />
-												</SmallTile>
-												<SmallTile color="gray">
-													<Text size="xs">
-														<a
-															href={gitBlobUrl({ orgOrUser, repository, branchOrCommit: commitSha, path: contribution.filename })}
-															target="_blank"
-															rel="noreferrer"
-														>
-															{contribution.filename}
-														</a>
-													</Text>
-												</SmallTile>
-											</Group>
-
-											<Group gap={2} w="100%">
-												<Group gap={2} w="calc((100% - 4px) / 3)" wrap="nowrap" align="start">
-													<SmallTile color="gray" className="navbar-icon-fix" style={{ '--size': '28px' }}>
-														<LuArrowUpDown />
-													</SmallTile>
-													<SmallTile color="gray">
-														<Text size="xs">
-															{contribution.poll.upvotes.length - contribution.poll.downvotes.length}
-														</Text>
-													</SmallTile>
-												</Group>
-												<Group gap={2} w="calc((100% - 4px) / 3)" wrap="nowrap" align="start">
-													<SmallTile color="gray" className="navbar-icon-fix" style={{ '--size': '28px' }}>
-														<GoHash />
-													</SmallTile>
-													<SmallTile color="gray">
-														<Text size="xs">
-															{contribution.textureId}
-														</Text>
-													</SmallTile>
-												</Group>
-												<Group gap={2} w="calc((100% - 4px) / 3)" wrap="nowrap" align="start">
-													<SmallTile color="gray" className="navbar-icon-fix" style={{ '--size': '28px' }}>
-														<GoHourglass />
-													</SmallTile>
-													<SmallTile color="gray">
-														<Text size="xs">
-															{contribution.status}
-														</Text>
-													</SmallTile>
-												</Group>
-											</Group>
-										</Stack>
-									</Group>
-								</TextureImage>
-							)
-						);
-					})}
-				</Group>
-			)}
-
-		</Stack>
-	);
-}
-
diff --git a/src/server/data/contributions.ts b/src/server/data/contributions.ts
index cae82b66..a26d5f46 100644
--- a/src/server/data/contributions.ts
+++ b/src/server/data/contributions.ts
@@ -117,6 +117,15 @@ export async function getLatestContributionsOfModVersion(modVersionId: string, r
 
 // POST
 
+export async function submitContributions(ownerId: string, contributionsIds: string[]) {
+	await canAccess(UserRole.USER, ownerId);
+
+	await db.contribution.updateMany({
+		where: { id: { in: contributionsIds }, ownerId },
+		data: { status: Status.PENDING },
+	});
+}
+
 export async function checkContributionStatus(contributionId: string) {
 	await canAccess(UserRole.COUNCIL);
 

From ae623b005ab3ff48690a742d3963493d4bfe865a Mon Sep 17 00:00:00 2001
From: Julien Constant <julienconstant190@gmail.com>
Date: Sun, 18 Aug 2024 03:58:00 +0200
Subject: [PATCH 08/30] fix : fix mods resource pack download for the new
 contributions

---
 src/app/api/download/mods/[modVerId]/[res]/route.ts | 12 +++++-------
 1 file changed, 5 insertions(+), 7 deletions(-)

diff --git a/src/app/api/download/mods/[modVerId]/[res]/route.ts b/src/app/api/download/mods/[modVerId]/[res]/route.ts
index dce3a271..fea7745a 100644
--- a/src/app/api/download/mods/[modVerId]/[res]/route.ts
+++ b/src/app/api/download/mods/[modVerId]/[res]/route.ts
@@ -3,7 +3,7 @@ import { createReadStream } from 'fs';
 import { Resolution, Status } from '@prisma/client';
 import JSZip from 'jszip';
 
-import { FILE_DIR, FILE_PATH, PUBLIC_PATH } from '~/lib/constants';
+import { PUBLIC_PATH } from '~/lib/constants';
 import { db } from '~/lib/db';
 import { getPackFormatVersion, getVanillaTextureSrc, sortBySemver } from '~/lib/utils';
 import { getModVersionProgression } from '~/server/data/mods-version';
@@ -79,10 +79,8 @@ export async function GET(req: Request, { params: { modVerId, res } }: Params) {
 
 		const contribution = linkedTexture.texture.contributions[0]!;
 		if (contribution) {
-			zip.file<'stream'>(
-				`${linkedTexture.assetPath}`,
-				createReadStream(`${FILE_PATH}/${contribution.filepath.replace('/files', '/').replace(FILE_DIR, '')}`)
-			);
+			const contributionTexture = await fetch(contribution.filepath);
+			zip.file<'arraybuffer'>(`${linkedTexture.assetPath}`, contributionTexture.arrayBuffer());
 
 			if (contribution.mcmeta) {
 				zip.file<'text'>(
@@ -100,8 +98,8 @@ export async function GET(req: Request, { params: { modVerId, res } }: Params) {
 
 		const vanillaTextureId = linkedTexture.texture.vanillaTextureId;
 		if (vanillaTextureId) {
-			const vanillaTexture = (await fetch(getVanillaTextureSrc(vanillaTextureId, res))).arrayBuffer();
-			zip.file<'arraybuffer'>(`${linkedTexture.assetPath}`, vanillaTexture);
+			const vanillaTexture = await fetch(getVanillaTextureSrc(vanillaTextureId, res));
+			zip.file<'arraybuffer'>(`${linkedTexture.assetPath}`, vanillaTexture.arrayBuffer());
 
 			if (linkedTexture.texture.mcmeta) {
 				zip.file<'text'>(

From 2b149f94510bb75f915e19bf8c6cd9fc3ca98572 Mon Sep 17 00:00:00 2001
From: Julien Constant <julienconstant190@gmail.com>
Date: Sun, 18 Aug 2024 03:58:20 +0200
Subject: [PATCH 09/30] fix : delete contributions download endpoint #134

---
 .../download/contributions/[ownerId]/route.ts | 43 -------------------
 1 file changed, 43 deletions(-)
 delete mode 100644 src/app/api/download/contributions/[ownerId]/route.ts

diff --git a/src/app/api/download/contributions/[ownerId]/route.ts b/src/app/api/download/contributions/[ownerId]/route.ts
deleted file mode 100644
index 64f3a70f..00000000
--- a/src/app/api/download/contributions/[ownerId]/route.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-
-import { createReadStream } from 'fs';
-
-import { UserRole } from '@prisma/client';
-import JSZip from 'jszip';
-
-import { canAccess } from '~/lib/auth';
-import { FILE_DIR, FILE_PATH } from '~/lib/constants';
-import { db } from '~/lib/db';
-
-interface Params {
-	params: {
-		ownerId: string;
-	};
-}
-
-export async function GET(req: Request, { params: { ownerId } }: Params) {
-	await canAccess(UserRole.ADMIN, ownerId);
-
-	const contributions = await db.contribution.findMany({
-		where: { ownerId },
-		select: { filepath: true, filename: true, status: true, hash: true },
-	});
-
-	const zip = new JSZip();
-
-	for (const contribution of contributions) {
-		zip.file<'stream'>(
-			`${contribution.status}/${contribution.hash}_${contribution.filename}`,
-			createReadStream(`${FILE_PATH}/${contribution.filepath.replace('/files', '/').replace(FILE_DIR, '')}`)
-		);
-	}
-
-	const zipFile = await zip.generateAsync({ type: 'nodebuffer' });
-
-	return new Response(zipFile, {
-		status: 200,
-		headers: {
-			'Content-Type': 'application/zip',
-		},
-	});
-
-}

From 5264e0576f365783a9f016314eb1113dbd46b5d6 Mon Sep 17 00:00:00 2001
From: Julien Constant <julienconstant190@gmail.com>
Date: Sun, 18 Aug 2024 03:59:26 +0200
Subject: [PATCH 10/30] fix : remove unused function

---
 src/server/actions/git.ts | 11 -----------
 1 file changed, 11 deletions(-)

diff --git a/src/server/actions/git.ts b/src/server/actions/git.ts
index 95ea9619..359a2d37 100644
--- a/src/server/actions/git.ts
+++ b/src/server/actions/git.ts
@@ -293,17 +293,6 @@ async function createCommit({ message, treeSha, parentCommitSha, owner, repo }:
 	return data;
 }
 
-async function createEmptyTree(owner = GITHUB_ORG_NAME, repo = GITHUB_DEFAULT_REPO_NAME) {
-	const octokit = await getOctokit();
-	const { data } = await octokit.git.createTree({
-		owner,
-		repo,
-		tree: [],
-	});
-
-	return data.sha;
-}
-
 interface CreateNewTreeOptions {
 	filenames: string[],
 	blobs: { url: string, sha: string }[],

From cb00138c36bfd33e722fd187a52f17dc91e04ced Mon Sep 17 00:00:00 2001
From: Julien Constant <julienconstant190@gmail.com>
Date: Sun, 18 Aug 2024 04:00:29 +0200
Subject: [PATCH 11/30] fix : hide help by default

---
 src/app/(pages)/(protected)/contribute/page.tsx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/app/(pages)/(protected)/contribute/page.tsx b/src/app/(pages)/(protected)/contribute/page.tsx
index 82cd8a33..12fae3b6 100644
--- a/src/app/(pages)/(protected)/contribute/page.tsx
+++ b/src/app/(pages)/(protected)/contribute/page.tsx
@@ -35,7 +35,7 @@ export default function ContributeSubmissionsPage() {
 
 	const [loading, startTransition] = useTransition();
 	const [hasFork, setHasFork] = useState<string | null>(null);
-	const [showHelp, helpShown] = useState(true);
+	const [showHelp, helpShown] = useState(false);
 
 	const [selectedContributions, setSelectedContributions] = useState<string[]>([]);
 

From 648a28a43d7cda138628d1309b7879f9f0b14567 Mon Sep 17 00:00:00 2001
From: Julien Constant <julienconstant190@gmail.com>
Date: Sun, 18 Aug 2024 04:03:08 +0200
Subject: [PATCH 12/30] fix : remove sync button (not needed)

---
 .../(protected)/user/[userId]/page.tsx        | 28 ++++++-------------
 1 file changed, 9 insertions(+), 19 deletions(-)

diff --git a/src/app/(pages)/(protected)/user/[userId]/page.tsx b/src/app/(pages)/(protected)/user/[userId]/page.tsx
index 59e04f79..30d95cba 100644
--- a/src/app/(pages)/(protected)/user/[userId]/page.tsx
+++ b/src/app/(pages)/(protected)/user/[userId]/page.tsx
@@ -107,26 +107,16 @@ const UserPage = () => {
 		if (hasFork) {
 			return (
 				<Tile p="xs" pl={windowWidth <= BREAKPOINT_MOBILE_LARGE ? 'xs' : 'md'} color="teal">
-					<Group justify="space-between" gap="xs">
-						<Group gap="sm">
-							<GoCheckCircle size={20} color="white" />
-							<Group gap={3}>
-								<Text size="sm" c="white">Default textures repository forked: </Text>
-								<Text size="sm" c="white">
-									<Link href={hasFork} style={{ color: 'white' }}>
-										{windowWidth <= BREAKPOINT_MOBILE_LARGE ? 'link' : hasFork}
-									</Link>
-								</Text>
-							</Group>
+					<Group gap="sm">
+						<GoCheckCircle size={20} color="white" />
+						<Group gap={3}>
+							<Text size="sm" c="white">Default textures repository forked: </Text>
+							<Text size="sm" c="white">
+								<Link href={hasFork} style={{ color: 'white' }}>
+									{windowWidth <= BREAKPOINT_MOBILE_LARGE ? 'link' : hasFork}
+								</Link>
+							</Text>
 						</Group>
-
-						<Button
-							variant="outline"
-							color="white"
-							fullWidth={windowWidth <= BREAKPOINT_MOBILE_LARGE}
-						>
-								Sync Fork
-						</Button>
 					</Group>
 				</Tile>
 			);

From d069c767d296781228d07f0079a3a050fa52bb74 Mon Sep 17 00:00:00 2001
From: Julien Constant <julienconstant190@gmail.com>
Date: Sun, 18 Aug 2024 04:10:26 +0200
Subject: [PATCH 13/30] feat : (part 4) add missing functions

---
 .../(pages)/(protected)/contribute/page.tsx   | 33 ++++++++++++-------
 src/server/data/contributions.ts              | 25 +++++++++++++-
 2 files changed, 46 insertions(+), 12 deletions(-)

diff --git a/src/app/(pages)/(protected)/contribute/page.tsx b/src/app/(pages)/(protected)/contribute/page.tsx
index 12fae3b6..efc85f08 100644
--- a/src/app/(pages)/(protected)/contribute/page.tsx
+++ b/src/app/(pages)/(protected)/contribute/page.tsx
@@ -20,7 +20,7 @@ import { useDeviceSize } from '~/hooks/use-device-size';
 import { useEffectOnce } from '~/hooks/use-effect-once';
 import { BREAKPOINT_MOBILE_LARGE, COLORS, gitBlobUrl, gitCommitUrl, GRADIENT, GRADIENT_DANGER } from '~/lib/constants';
 import { getContributionsOfFork, getFork } from '~/server/actions/git';
-import { createContributionsFromGitFiles, deleteContributionsOrArchive, getContributionsOfUser, submitContributions } from '~/server/data/contributions';
+import { archiveContributions, createContributionsFromGitFiles, deleteContributions, deleteContributionsOrArchive, getContributionsOfUser, submitContributions } from '~/server/data/contributions';
 import { getTextures } from '~/server/data/texture';
 
 import type { Texture } from '@prisma/client';
@@ -145,13 +145,6 @@ export default function ContributeSubmissionsPage() {
 		);
 	});
 
-	const handleSubmitSelectedDraftContribution = () => {
-		startTransition(async () => {
-			await submitContributions(user.id!, selectedContributions);
-			await reload();
-		});
-	};
-
 	return (
 		<Stack gap="xs">
 			<Group
@@ -216,7 +209,12 @@ export default function ContributeSubmissionsPage() {
 						variant="gradient"
 						gradient={GRADIENT}
 						disabled={selectedContributions.length === 0}
-						onClick={handleSubmitSelectedDraftContribution}
+						onClick={() => {
+							startTransition(async () => {
+								await submitContributions(user.id!, selectedContributions);
+								await reload();
+							});
+						}}
 					>
 						Submit {selectedContributions.length} draft{selectedContributions.length > 1 ? 's' : ''}
 					</Button>
@@ -228,8 +226,14 @@ export default function ContributeSubmissionsPage() {
 						variant="gradient"
 						gradient={GRADIENT_DANGER}
 						disabled={selectedContributions.length === 0}
+						onClick={() => {
+							startTransition(async () => {
+								await archiveContributions(user.id!, selectedContributions);
+								await reload();
+							});
+						}}
 					>
-						Unlist {selectedContributions.length} contribution{selectedContributions.length > 1 ? 's' : ''}
+						Archive {selectedContributions.length} contribution{selectedContributions.length > 1 ? 's' : ''}
 					</Button>
 				)}
 
@@ -239,6 +243,12 @@ export default function ContributeSubmissionsPage() {
 						variant="gradient"
 						gradient={GRADIENT_DANGER}
 						disabled={selectedContributions.length === 0}
+						onClick={() => {
+							startTransition(async () => {
+								await deleteContributions(user.id!, selectedContributions);
+								await reload();
+							});
+						}}
 					>
 						Delete {selectedContributions.length} contribution{selectedContributions.length > 1 ? 's' : ''}
 					</Button>
@@ -273,8 +283,9 @@ export default function ContributeSubmissionsPage() {
 						A few tips:
 					</Text>
 					<List ml="sm">
-						<List.Item>You have to click on contributions before submitting/unlisting/deleting them.</List.Item>
+						<List.Item>You have to click on contributions before submitting/archiving/deleting them.</List.Item>
 						<List.Item>You can hit <Kbd component="span">{os === 'macos' ? '⌘' : 'Ctrl'}</Kbd> + <Kbd component="span">A</Kbd> to select them all</List.Item>
+						<List.Item>If you delete an archived contribution, make sure to delete it from your fork first, otherwise it will be re-added to the database as a draft.</List.Item>
 					</List>
 				</Tile>
 			)}
diff --git a/src/server/data/contributions.ts b/src/server/data/contributions.ts
index a26d5f46..9bedec3e 100644
--- a/src/server/data/contributions.ts
+++ b/src/server/data/contributions.ts
@@ -118,7 +118,7 @@ export async function getLatestContributionsOfModVersion(modVersionId: string, r
 // POST
 
 export async function submitContributions(ownerId: string, contributionsIds: string[]) {
-	await canAccess(UserRole.USER, ownerId);
+	await canAccess(UserRole.ADMIN, ownerId);
 
 	await db.contribution.updateMany({
 		where: { id: { in: contributionsIds }, ownerId },
@@ -126,6 +126,15 @@ export async function submitContributions(ownerId: string, contributionsIds: str
 	});
 }
 
+export async function archiveContributions(ownerId: string, contributionsIds: string[]) {
+	await canAccess(UserRole.ADMIN, ownerId);
+
+	await db.contribution.updateMany({
+		where: { id: { in: contributionsIds }, ownerId },
+		data: { status: Status.ARCHIVED },
+	});
+}
+
 export async function checkContributionStatus(contributionId: string) {
 	await canAccess(UserRole.COUNCIL);
 
@@ -228,3 +237,17 @@ export async function deleteContributionsOrArchive(
 		}
 	}
 }
+
+export async function deleteContributions(ownerId: string, ids: string[]) {
+	await canAccess(UserRole.ADMIN, ownerId);
+
+	const contributions = await db.contribution.findMany({
+		where: { id: { in: ids } },
+		include: { coAuthors: { select: { id: true } } },
+	});
+
+	for (const contribution of contributions) {
+		await db.contribution.delete({ where: { id: contribution.id } });
+		await db.poll.delete({ where: { id: contribution.pollId } });
+	}
+}

From 65eafac58c96e3e163df21eb30c9432657a97ca1 Mon Sep 17 00:00:00 2001
From: Julien Constant <julienconstant190@gmail.com>
Date: Sun, 18 Aug 2024 04:16:22 +0200
Subject: [PATCH 14/30] fix : add messages

---
 .../(pages)/(protected)/contribute/page.tsx   | 25 ++++++++++++++++++-
 1 file changed, 24 insertions(+), 1 deletion(-)

diff --git a/src/app/(pages)/(protected)/contribute/page.tsx b/src/app/(pages)/(protected)/contribute/page.tsx
index efc85f08..100e13c5 100644
--- a/src/app/(pages)/(protected)/contribute/page.tsx
+++ b/src/app/(pages)/(protected)/contribute/page.tsx
@@ -302,6 +302,30 @@ export default function ContributeSubmissionsPage() {
 				</Group>
 			)}
 
+			{contributions.length !== 0 && contributions.filter((c) => c.status === Object.keys(Status)[activeTab]).length === 0 && (
+				<Group
+					align="center"
+					justify="center"
+					h="100px"
+					w="100%"
+					style={{ height: 'calc(81% - (2 * var(--mantine-spacing-sm) - 62px))' }}
+				>
+					<Text c="dimmed">No contributions to show</Text>
+				</Group>
+			)}
+
+			{contributions.length === 0 && (
+				<Group
+					align="center"
+					justify="center"
+					h="100px"
+					w="100%"
+					style={{ height: 'calc(81% - (2 * var(--mantine-spacing-sm) - 62px))' }}
+				>
+					<Text c="dimmed">No contributions, you need to push some textures to your fork first!</Text>
+				</Group>
+			)}
+
 			{hasFork && (
 				<Group gap="xs">
 					{contributions.filter((c) => c.status === Object.keys(Status)[activeTab]).map((contribution, index) => {
@@ -422,7 +446,6 @@ export default function ContributeSubmissionsPage() {
 					})}
 				</Group>
 			)}
-
 		</Stack>
 	);
 }

From f4f76fba2f01b20a4b1ca8796d8b0c2afab6dcc6 Mon Sep 17 00:00:00 2001
From: Julien Constant <julienconstant190@gmail.com>
Date: Sun, 18 Aug 2024 04:43:46 +0200
Subject: [PATCH 15/30] fix : use a different repo for dev

---
 src/lib/constants.ts      |  6 ++----
 src/server/actions/git.ts | 20 +++++---------------
 2 files changed, 7 insertions(+), 19 deletions(-)

diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 04e9d4c5..69af153b 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -106,7 +106,7 @@ export const COLORS: Record<Status, MantineColor> = {
 };
 
 export const GITHUB_ORG_NAME = 'faithful-mods';
-export const GITHUB_DEFAULT_REPO_NAME = 'resources-default';
+export const GITHUB_DEFAULT_REPO_NAME = process.env.NODE_ENV === 'production' ? 'resources-default' : 'resources-default-dev';
 
 export type RawUrl =
  | `https://raw.githubusercontent.com/${string}/${string}/${string}`
@@ -121,9 +121,7 @@ export type FileGitParams = {
 
 export const gitRawUrl = ({ orgOrUser, repository, branchOrCommit, path }: FileGitParams): RawUrl => {
 	if (branchOrCommit) return `https://raw.githubusercontent.com/${orgOrUser}/${repository}/${branchOrCommit}/${path}`;
-	const branch = process.env.NODE_ENV === 'production' ? 'main' : 'dev';
-
-	return `https://raw.githubusercontent.com/${orgOrUser}/${repository}/${branch}/${path}`;
+	return `https://raw.githubusercontent.com/${orgOrUser}/${repository}/main/${path}`;
 };
 
 export type CommitUrl = `https://github.com/${string}/${string}/commit/${string}`;
diff --git a/src/server/actions/git.ts b/src/server/actions/git.ts
index 359a2d37..6051f58b 100644
--- a/src/server/actions/git.ts
+++ b/src/server/actions/git.ts
@@ -107,16 +107,9 @@ export async function forkRepository() {
 		await createBranchFromCommit(username, GITHUB_DEFAULT_REPO_NAME, resolution, firstCommitSha);
 	}
 
-	// rename the dev/main branch to x16 (if prod => main => x16 and DEL dev, if dev => dev => x16 and DEL main)
-	if (process.env.NODE_ENV !== 'production') {
-		await setDefaultBranch(username, GITHUB_DEFAULT_REPO_NAME, 'dev');
-		await deleteBranch(username, GITHUB_DEFAULT_REPO_NAME, 'main');
-		await renameBranch(username, GITHUB_DEFAULT_REPO_NAME, 'dev', 'x16');
-	}
-	else {
-		await deleteBranch(username, GITHUB_DEFAULT_REPO_NAME, 'dev');
-		await renameBranch(username, GITHUB_DEFAULT_REPO_NAME, 'main', 'x16');
-	}
+	// set x32 as default branch and delete main (x16) as it's not needed
+	await setDefaultBranch(username, GITHUB_DEFAULT_REPO_NAME, 'x32');
+	await deleteBranch(username, GITHUB_DEFAULT_REPO_NAME, 'main');
 }
 
 /**
@@ -128,10 +121,8 @@ export async function forkRepository() {
 export async function uploadToRepository(files: base64[], filenames: string[], commitMessage: string): Promise<void> {
 	await canAccess(UserRole.COUNCIL);
 
-	const branch = process.env.NODE_ENV === 'production' ? 'main' : 'dev';
-
 	// get latest commit
-	const currentCommit = await getCurrentCommit(branch);
+	const currentCommit = await getCurrentCommit('main');
 
 	// create blobs for each file
 	const blobs = await Promise.all(files.map((file) => createBlobFile(file)));
@@ -143,7 +134,7 @@ export async function uploadToRepository(files: base64[], filenames: string[], c
 	const newCommit = await createCommit({ message: commitMessage, treeSha: newTree.sha, parentCommitSha: currentCommit.commit_sha });
 
 	// update the branch to point to the new commit
-	await setBranchToCommit(newCommit.sha, branch);
+	await setBranchToCommit(newCommit.sha, 'main');
 }
 
 export interface GitFile {
@@ -158,7 +149,6 @@ export interface GitFile {
 export async function getContributionsOfFork(resolution: Resolution): Promise<GitFile[]> {
 	const username = await getUserGitHubUsername();
 	const files = await listFilesInBranch(username, GITHUB_DEFAULT_REPO_NAME, resolution);
-	console.log(files, resolution);
 
 	return files as GitFile[];
 }

From 655be2175bef8296427046c82d2dfdb9759e92c1 Mon Sep 17 00:00:00 2001
From: Julien Constant <julienconstant190@gmail.com>
Date: Sun, 18 Aug 2024 04:44:14 +0200
Subject: [PATCH 16/30] fix : use hashes instead of texture ids as it's
 immutable

---
 src/server/actions/files.ts      |  4 ++--
 src/server/data/contributions.ts | 16 +++++-----------
 2 files changed, 7 insertions(+), 13 deletions(-)

diff --git a/src/server/actions/files.ts b/src/server/actions/files.ts
index 8eca11b8..7d78eb15 100644
--- a/src/server/actions/files.ts
+++ b/src/server/actions/files.ts
@@ -345,11 +345,11 @@ export async function extractDefaultResourcePack(jar: File, modVersion: ModVersi
 				mcmeta,
 			});
 
-			const filepath = `${gitRawUrl({ orgOrUser: GITHUB_ORG_NAME, repository: GITHUB_DEFAULT_REPO_NAME })}/${texture.id}.png` as const;
+			const filepath = gitRawUrl({ orgOrUser: GITHUB_ORG_NAME, repository: GITHUB_DEFAULT_REPO_NAME, path: `${hash}.png` });
 			await db.texture.update({ where: { id: texture.id }, data: { filepath } });
 
 			filesToCommit.push(buffer.toString('base64') as base64);
-			filesNamesOnGit.push(`${texture.id}.png`);
+			filesNamesOnGit.push(`${hash}.png`);
 		}
 		else {
 			if (texture.name !== textureName && !texture.aliases.includes(textureName)) {
diff --git a/src/server/data/contributions.ts b/src/server/data/contributions.ts
index 9bedec3e..a4944528 100644
--- a/src/server/data/contributions.ts
+++ b/src/server/data/contributions.ts
@@ -174,16 +174,10 @@ export async function createContributionsFromGitFiles(ownerId: string, resolutio
 		const existingContribution = await db.contribution.findFirst({ where: { hash: file.sha } });
 		if (existingContribution) continue;
 
-		let textureId: number;
-
-		try {
-			const filename = file.path.includes('/') ? file.path.split('/')[1] : file.path;
-			textureId = parseInt(filename?.replace('.png', '') ?? '', 10);
-			if (isNaN(textureId)) continue;
-		} catch (e) {
-			console.error(e);
-			continue;
-		}
+		const hash = (file.path.includes('/') ? file.path.split('/')[1] : file.path)?.replace('.png', '');
+		const texture = await db.texture.findFirst({ where: { hash } });
+
+		if (!texture) continue;
 
 		const poll = await db.poll.create({ data: {} });
 		const contribution = await db.contribution.create({
@@ -195,7 +189,7 @@ export async function createContributionsFromGitFiles(ownerId: string, resolutio
 				pollId: poll.id,
 				filename: file.path,
 				resolution,
-				textureId,
+				textureId: texture.id,
 			},
 		});
 

From af9a9e6b24269864755db95601e136a29b89ba94 Mon Sep 17 00:00:00 2001
From: Julien Constant <julienconstant190@gmail.com>
Date: Sun, 18 Aug 2024 04:44:25 +0200
Subject: [PATCH 17/30] fix : improve wording

---
 src/app/(pages)/(protected)/contribute/page.tsx | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/src/app/(pages)/(protected)/contribute/page.tsx b/src/app/(pages)/(protected)/contribute/page.tsx
index 100e13c5..4893ce40 100644
--- a/src/app/(pages)/(protected)/contribute/page.tsx
+++ b/src/app/(pages)/(protected)/contribute/page.tsx
@@ -259,13 +259,13 @@ export default function ContributeSubmissionsPage() {
 			{showHelp && (
 				<Tile style={{ borderRadius: 'var(--mantine-radius-default)' }}>
 					<Text>
-						To contribute to the resource pack, you need to:
+						To contribute, you need to:
 					</Text>
 					<List ml="sm">
-						<List.Item><Text fw={360}>Fork the default repository using the settings in your <Link href='/user/me'>account page</Link>;</Text></List.Item>
+						<List.Item><Text fw={360}>Fork the default repository using the specified area in your <Link href='/user/me'>user page</Link>;</Text></List.Item>
 						<List.Item><Text fw={360}>Clone it to your local machine using <Link href="https://git-scm.com/" target="_blank">Git</Link> or <Link href="https://desktop.github.com/download/" target="_blank">GitHub Desktop</Link>;</Text></List.Item>
 						<List.Item><Text fw={360}>Switch to the branch corresponding to the resolution you want to contribute to;</Text></List.Item>
-						<List.Item><Text fw={360}>Add textures to the repository, <Text component="span" fw={700}>each texture should have the same name as the contributed texture ID</Text>;</Text></List.Item>
+						<List.Item><Text fw={360}>Add textures to the repository, <Text component="span" fw={700}>each texture should have the same name as the contributed texture name in the <Link href="https://github.com/faithful-mods/resources-default" target="_blank">default repository</Link></Text>;</Text></List.Item>
 						<List.Item><Text fw={360}>Commit your changes and push them to your fork;</Text></List.Item>
 						<List.Item><Text fw={360}>Click the reload button to see your contributions here.</Text></List.Item>
 					</List>

From b3dbf51dda85dbb1027998b7caf576605afffae6 Mon Sep 17 00:00:00 2001
From: Julien Constant <julienconstant190@gmail.com>
Date: Mon, 19 Aug 2024 00:35:13 +0200
Subject: [PATCH 18/30] fix : adapt user page to small devices

---
 src/app/(pages)/(protected)/user/[userId]/page.tsx | 8 +++++---
 1 file changed, 5 insertions(+), 3 deletions(-)

diff --git a/src/app/(pages)/(protected)/user/[userId]/page.tsx b/src/app/(pages)/(protected)/user/[userId]/page.tsx
index 30d95cba..1f18dd7e 100644
--- a/src/app/(pages)/(protected)/user/[userId]/page.tsx
+++ b/src/app/(pages)/(protected)/user/[userId]/page.tsx
@@ -106,8 +106,8 @@ const UserPage = () => {
 	const forkedInfo = () => {
 		if (hasFork) {
 			return (
-				<Tile p="xs" pl={windowWidth <= BREAKPOINT_MOBILE_LARGE ? 'xs' : 'md'} color="teal">
-					<Group gap="sm">
+				<Tile p="xs" pl={windowWidth <= BREAKPOINT_MOBILE_LARGE ? 'xs' : 'md'} color="teal" mih={56}>
+					<Group gap="sm" mt="auto" mb="auto">
 						<GoCheckCircle size={20} color="white" />
 						<Group gap={3}>
 							<Text size="sm" c="white">Default textures repository forked: </Text>
@@ -208,6 +208,7 @@ const UserPage = () => {
 						>
 							<Button
 								justify={windowWidth <= BREAKPOINT_MOBILE_LARGE ? 'center' : 'right'}
+								fullWidth={windowWidth <= BREAKPOINT_MOBILE_LARGE}
 								variant="transparent"
 								color="red"
 								onClick={() => signOut({ callbackUrl: '/' })}
@@ -219,6 +220,7 @@ const UserPage = () => {
 								gradient={GRADIENT}
 								onClick={() => onSubmit(form.values)}
 								disabled={loading || !form.isValid() || user === undefined}
+								fullWidth={windowWidth <= BREAKPOINT_MOBILE_LARGE}
 								loading={loading}
 							>
 								Save
@@ -244,7 +246,7 @@ const UserPage = () => {
 						borderColor: 'var(--mantine-color-red-filled)',
 					}}
 				>
-					<Group justify="space-between">
+					<Group justify="space-between" style={{ opacity: hasFork ? 1 : .5 }}>
 						<Stack gap={0}>
 							<Text>Delete the forked repository</Text>
 							<Text c="dimmed" size="xs">This action is irreversible, all contributions will be lost.</Text>

From 3a4b8cedbdfc7d9056f345628a7414bfd436ee92 Mon Sep 17 00:00:00 2001
From: Julien Constant <julienconstant190@gmail.com>
Date: Mon, 19 Aug 2024 00:57:34 +0200
Subject: [PATCH 19/30] feat : add the fork button to the contribute page

---
 .../(pages)/(protected)/contribute/page.tsx   | 31 +++----
 .../(protected)/user/[userId]/page.tsx        | 69 ++-------------
 src/components/fork.tsx                       | 83 +++++++++++++++++++
 3 files changed, 102 insertions(+), 81 deletions(-)
 create mode 100644 src/components/fork.tsx

diff --git a/src/app/(pages)/(protected)/contribute/page.tsx b/src/app/(pages)/(protected)/contribute/page.tsx
index 4893ce40..1bf62447 100644
--- a/src/app/(pages)/(protected)/contribute/page.tsx
+++ b/src/app/(pages)/(protected)/contribute/page.tsx
@@ -12,6 +12,7 @@ import { ActionIcon, Badge, Button, FloatingIndicator, Group, Indicator, Kbd, Li
 import { useHotkeys, useOs, usePrevious } from '@mantine/hooks';
 import { Resolution, Status } from '@prisma/client';
 
+import ForkInfo from '~/components/fork';
 import { SmallTile } from '~/components/small-tile';
 import { TextureImage } from '~/components/texture-img';
 import { Tile } from '~/components/tile';
@@ -19,7 +20,7 @@ import { useCurrentUser } from '~/hooks/use-current-user';
 import { useDeviceSize } from '~/hooks/use-device-size';
 import { useEffectOnce } from '~/hooks/use-effect-once';
 import { BREAKPOINT_MOBILE_LARGE, COLORS, gitBlobUrl, gitCommitUrl, GRADIENT, GRADIENT_DANGER } from '~/lib/constants';
-import { getContributionsOfFork, getFork } from '~/server/actions/git';
+import { getContributionsOfFork } from '~/server/actions/git';
 import { archiveContributions, createContributionsFromGitFiles, deleteContributions, deleteContributionsOrArchive, getContributionsOfUser, submitContributions } from '~/server/data/contributions';
 import { getTextures } from '~/server/data/texture';
 
@@ -34,7 +35,7 @@ export default function ContributeSubmissionsPage() {
 	const os = useOs();
 
 	const [loading, startTransition] = useTransition();
-	const [hasFork, setHasFork] = useState<string | null>(null);
+	const [forkUrl, setForkUrl] = useState<string | null>(null);
 	const [showHelp, helpShown] = useState(false);
 
 	const [selectedContributions, setSelectedContributions] = useState<string[]>([]);
@@ -61,10 +62,8 @@ export default function ContributeSubmissionsPage() {
 
 	const reload = async () => {
 		startTransition(async () => {
-			const fork = await getFork();
-			if (!fork) return;
+			if (!forkUrl) return;
 
-			setHasFork(fork);
 			getContributionsOfFork(resolution).then(updateForkContributions);
 		});
 	};
@@ -147,6 +146,12 @@ export default function ContributeSubmissionsPage() {
 
 	return (
 		<Stack gap="xs">
+
+			<ForkInfo
+				onUrlUpdate={setForkUrl}
+				forkUrl={forkUrl}
+			/>
+
 			<Group
 				wrap="nowrap"
 				gap="xs"
@@ -262,7 +267,7 @@ export default function ContributeSubmissionsPage() {
 						To contribute, you need to:
 					</Text>
 					<List ml="sm">
-						<List.Item><Text fw={360}>Fork the default repository using the specified area in your <Link href='/user/me'>user page</Link>;</Text></List.Item>
+						<List.Item><Text fw={360}>If not already, fork the default textures repository using the &quot;Create Fork&quot; button;</Text></List.Item>
 						<List.Item><Text fw={360}>Clone it to your local machine using <Link href="https://git-scm.com/" target="_blank">Git</Link> or <Link href="https://desktop.github.com/download/" target="_blank">GitHub Desktop</Link>;</Text></List.Item>
 						<List.Item><Text fw={360}>Switch to the branch corresponding to the resolution you want to contribute to;</Text></List.Item>
 						<List.Item><Text fw={360}>Add textures to the repository, <Text component="span" fw={700}>each texture should have the same name as the contributed texture name in the <Link href="https://github.com/faithful-mods/resources-default" target="_blank">default repository</Link></Text>;</Text></List.Item>
@@ -290,18 +295,6 @@ export default function ContributeSubmissionsPage() {
 				</Tile>
 			)}
 
-			{hasFork === null && (
-				<Group
-					align="center"
-					justify="center"
-					h="100px"
-					w="100%"
-					style={{ height: 'calc(81% - (2 * var(--mantine-spacing-sm) - 62px))' }}
-				>
-					<Text c="dimmed">You need to fork the default repository first</Text>
-				</Group>
-			)}
-
 			{contributions.length !== 0 && contributions.filter((c) => c.status === Object.keys(Status)[activeTab]).length === 0 && (
 				<Group
 					align="center"
@@ -326,7 +319,7 @@ export default function ContributeSubmissionsPage() {
 				</Group>
 			)}
 
-			{hasFork && (
+			{forkUrl && (
 				<Group gap="xs">
 					{contributions.filter((c) => c.status === Object.keys(Status)[activeTab]).map((contribution, index) => {
 						const texture = textures.find((t) => t.id === contribution.textureId);
diff --git a/src/app/(pages)/(protected)/user/[userId]/page.tsx b/src/app/(pages)/(protected)/user/[userId]/page.tsx
index 1f18dd7e..98f3933c 100644
--- a/src/app/(pages)/(protected)/user/[userId]/page.tsx
+++ b/src/app/(pages)/(protected)/user/[userId]/page.tsx
@@ -1,18 +1,16 @@
 'use client';
 
-import Link from 'next/link';
 import { useParams, useRouter } from 'next/navigation';
 
 import { useState, useTransition } from 'react';
 
-import { GoCheckCircle, GoStop } from 'react-icons/go';
-
 import { Button, Text, TextInput, Group, Stack, Badge } from '@mantine/core';
 import { useForm } from '@mantine/form';
 import { UserRole } from '@prisma/client';
 import { signOut } from 'next-auth/react';
 import { useSession } from 'next-auth/react';
 
+import ForkInfo from '~/components/fork';
 import { TextureImage } from '~/components/texture-img';
 import { Tile } from '~/components/tile';
 import { useCurrentUser } from '~/hooks/use-current-user';
@@ -20,7 +18,7 @@ import { useDeviceSize } from '~/hooks/use-device-size';
 import { useEffectOnce } from '~/hooks/use-effect-once';
 import { BREAKPOINT_MOBILE_LARGE, GRADIENT, MAX_NAME_LENGTH, MIN_NAME_LENGTH } from '~/lib/constants';
 import { notify } from '~/lib/utils';
-import { deleteFork, forkRepository, getFork } from '~/server/actions/git';
+import { deleteFork } from '~/server/actions/git';
 import { getUserById } from '~/server/data/user';
 import { updateUser } from '~/server/data/user';
 
@@ -30,6 +28,7 @@ const UserPage = () => {
 	const params = useParams();
 	const user = useCurrentUser()!;
 	const self = params.userId === 'me';
+	const [forkUrl, setForkUrl] = useState<string | null>(null);
 
 	const [displayedUser, setDisplayedUser] = useState<User>();
 	const router = useRouter();
@@ -38,8 +37,6 @@ const UserPage = () => {
 	const [loading, startTransition] = useTransition();
 	const [windowWidth] = useDeviceSize();
 
-	const [hasFork, setHasFork] = useState<string | null>(null);
-
 	const form = useForm<Pick<User, 'name' | 'image'>>({
 		initialValues: { name: user.name!, image: user.image! },
 		validate: {
@@ -80,7 +77,6 @@ const UserPage = () => {
 			if (params.userId === user?.id) router.push('/user/me');
 			const userId = self ? user?.id! : params.userId as string;
 
-			getFork().then(setHasFork);
 			getUserById(userId).then(setDisplayedUser);
 		});
 	};
@@ -88,7 +84,7 @@ const UserPage = () => {
 	const handleForkDelete = async () => {
 		startTransition(async () => {
 			await deleteFork();
-			await reload();
+			setForkUrl(null);
 		});
 	};
 
@@ -96,57 +92,6 @@ const UserPage = () => {
 		reload();
 	});
 
-	const handleSetupForkedRepository = async () => {
-		startTransition(async () => {
-			await forkRepository();
-			await reload();
-		});
-	};
-
-	const forkedInfo = () => {
-		if (hasFork) {
-			return (
-				<Tile p="xs" pl={windowWidth <= BREAKPOINT_MOBILE_LARGE ? 'xs' : 'md'} color="teal" mih={56}>
-					<Group gap="sm" mt="auto" mb="auto">
-						<GoCheckCircle size={20} color="white" />
-						<Group gap={3}>
-							<Text size="sm" c="white">Default textures repository forked: </Text>
-							<Text size="sm" c="white">
-								<Link href={hasFork} style={{ color: 'white' }}>
-									{windowWidth <= BREAKPOINT_MOBILE_LARGE ? 'link' : hasFork}
-								</Link>
-							</Text>
-						</Group>
-					</Group>
-				</Tile>
-			);
-		}
-
-		return (
-			<Tile p="xs" pl={windowWidth <= BREAKPOINT_MOBILE_LARGE ? 'xs' : 'md'} color="yellow">
-				<Group justify="space-between" gap="xs">
-					<Group gap="sm">
-						<GoStop color="black" size={20} />
-						<Group gap="xs">
-							<Text size="sm" c="black">Default textures repository not forked</Text>
-						</Group>
-					</Group>
-
-					<Button
-						variant="outline"
-						color="black"
-						onClick={handleSetupForkedRepository}
-						disabled={!!hasFork}
-						loading={loading}
-						fullWidth={windowWidth <= BREAKPOINT_MOBILE_LARGE}
-					>
-						Create Fork
-					</Button>
-				</Group>
-			</Tile>
-		);
-	};
-
 	return (displayedUser && (
 		<Stack gap="xl">
 			<Group
@@ -232,7 +177,7 @@ const UserPage = () => {
 
 			<Stack gap="xs">
 				<Text fw={700}>Contributions Repository</Text>
-				{forkedInfo()}
+				<ForkInfo onUrlUpdate={setForkUrl} forkUrl={forkUrl} />
 			</Stack>
 
 			<Stack gap="xs" mb="sm">
@@ -246,7 +191,7 @@ const UserPage = () => {
 						borderColor: 'var(--mantine-color-red-filled)',
 					}}
 				>
-					<Group justify="space-between" style={{ opacity: hasFork ? 1 : .5 }}>
+					<Group justify="space-between" style={{ opacity: forkUrl ? 1 : .5 }}>
 						<Stack gap={0}>
 							<Text>Delete the forked repository</Text>
 							<Text c="dimmed" size="xs">This action is irreversible, all contributions will be lost.</Text>
@@ -255,7 +200,7 @@ const UserPage = () => {
 							variant="default"
 							style={{ color: 'var(--mantine-color-red-text)' }}
 							onClick={handleForkDelete}
-							disabled={!hasFork}
+							disabled={!forkUrl}
 							loading={loading}
 							fullWidth={windowWidth <= BREAKPOINT_MOBILE_LARGE}
 						>
diff --git a/src/components/fork.tsx b/src/components/fork.tsx
new file mode 100644
index 00000000..3bb06793
--- /dev/null
+++ b/src/components/fork.tsx
@@ -0,0 +1,83 @@
+import Link from 'next/link';
+
+import { useTransition } from 'react';
+
+import { GoCheckCircle, GoStop } from 'react-icons/go';
+
+import { Button, Group, Text } from '@mantine/core';
+
+import { useDeviceSize } from '~/hooks/use-device-size';
+import { useEffectOnce } from '~/hooks/use-effect-once';
+import { BREAKPOINT_MOBILE_LARGE } from '~/lib/constants';
+import { forkRepository, getFork } from '~/server/actions/git';
+
+import { Tile } from './tile';
+
+interface Props {
+	onUrlUpdate: (url: string | null) => void;
+	forkUrl: string | null;
+	hideIfForked?: boolean;
+}
+
+export default function ForkInfo({ onUrlUpdate, forkUrl, hideIfForked }: Props) {
+	const [windowWidth] = useDeviceSize();
+	const [loading, startTransition] = useTransition();
+
+	const handleSetupForkedRepository = async () => {
+		startTransition(async () => {
+			await forkRepository();
+			const res = await getFork();
+			onUrlUpdate(res);
+		});
+	};
+
+	useEffectOnce(() => {
+		startTransition(() => {
+			getFork().then(onUrlUpdate);
+		});
+	});
+
+	if (forkUrl && hideIfForked) return null;
+
+	if (forkUrl) {
+		return (
+			<Tile p="xs" pl={windowWidth <= BREAKPOINT_MOBILE_LARGE ? 'xs' : 'md'} color="teal" mih={56}>
+				<Group gap="sm" mt="auto" mb="auto">
+					<GoCheckCircle size={20} color="white" />
+					<Group gap={3}>
+						<Text size="sm" c="white">Default textures repository forked: </Text>
+						<Text size="sm" c="white">
+							<Link href={forkUrl} style={{ color: 'white' }} target="_blank">
+								{windowWidth <= BREAKPOINT_MOBILE_LARGE ? 'link' : forkUrl}
+							</Link>
+						</Text>
+					</Group>
+				</Group>
+			</Tile>
+		);
+	}
+
+	return (
+		<Tile p="xs" pl={windowWidth <= BREAKPOINT_MOBILE_LARGE ? 'xs' : 'md'} color="yellow">
+			<Group justify="space-between" gap="xs">
+				<Group gap="sm">
+					<GoStop color="black" size={20} />
+					<Group gap="xs">
+						<Text size="sm" c="black">Default textures repository not forked</Text>
+					</Group>
+				</Group>
+
+				<Button
+					variant="outline"
+					color="black"
+					onClick={handleSetupForkedRepository}
+					disabled={!!forkUrl}
+					loading={loading}
+					fullWidth={windowWidth <= BREAKPOINT_MOBILE_LARGE}
+				>
+					Create Fork
+				</Button>
+			</Group>
+		</Tile>
+	);
+}

From 83e4c7059f4b67274083ae93e5f1c3b753cef608 Mon Sep 17 00:00:00 2001
From: Julien Constant <julienconstant190@gmail.com>
Date: Mon, 19 Aug 2024 01:10:39 +0200
Subject: [PATCH 20/30] fix : make sure resolution branches are empty

---
 src/server/actions/git.ts | 17 +++++++++++++++--
 1 file changed, 15 insertions(+), 2 deletions(-)

diff --git a/src/server/actions/git.ts b/src/server/actions/git.ts
index 6051f58b..6ca048b6 100644
--- a/src/server/actions/git.ts
+++ b/src/server/actions/git.ts
@@ -101,10 +101,23 @@ export async function forkRepository() {
 
 	// create empty branches for each resolution
 	const resolutions = Object.keys(Resolution) as Resolution[];
-	const firstCommitSha = await getFirstCommit(username, GITHUB_DEFAULT_REPO_NAME);
+	const SHA1_EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; // see https://github.com/orgs/community/discussions/24699
+
+	const res = await octokit.request('POST /repos/{owner}/{repo}/git/commits', {
+		owner: username,
+		repo: GITHUB_DEFAULT_REPO_NAME,
+		message: 'initial commit',
+		tree: SHA1_EMPTY_TREE,
+		parents: [],
+	});
 
 	for (const resolution of resolutions) {
-		await createBranchFromCommit(username, GITHUB_DEFAULT_REPO_NAME, resolution, firstCommitSha);
+		await octokit.request('POST /repos/{owner}/{repo}/git/refs', {
+			owner: username,
+			repo: GITHUB_DEFAULT_REPO_NAME,
+			ref: `refs/heads/${resolution}`,
+			sha: res.data.sha,
+		});
 	}
 
 	// set x32 as default branch and delete main (x16) as it's not needed

From 189677621d896106339ec815e0f27ea9055a5849 Mon Sep 17 00:00:00 2001
From: Julien Constant <julienconstant190@gmail.com>
Date: Mon, 19 Aug 2024 01:19:35 +0200
Subject: [PATCH 21/30] feat : add ID to search filter

---
 src/lib/utils.ts | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index 6f360844..8130df18 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -35,13 +35,14 @@ export function sortByName<T extends { name: string, id?: string | number }>(a:
 	return a.name.localeCompare(b.name) || `${a.id}`.localeCompare(`${b.id}` ?? '') || 0;
 }
 
-export function searchFilter<T extends { name: string, aliases?: string[] }>(search: string) {
+export function searchFilter<T extends { id: string | number; name: string, aliases?: string[] }>(search: string) {
 	return (item: T) => {
 		const searchLower = search.toLowerCase();
 		const name = item.name.toLowerCase();
+		const id = `${item.id}`.toLowerCase();
 		const aliases = item.aliases?.map((alias) => alias.toLowerCase()) ?? [];
 
-		return name.includes(searchLower) || aliases.some((alias) => alias.includes(searchLower));
+		return id === searchLower || name.includes(searchLower) || aliases.some((alias) => alias.includes(searchLower));
 	};
 }
 

From 24c835934110cf97f6f6a38ba196e369e0f877b0 Mon Sep 17 00:00:00 2001
From: Julien Constant <julienconstant190@gmail.com>
Date: Mon, 19 Aug 2024 02:19:33 +0200
Subject: [PATCH 22/30] feat : add magic wand to select vanilla texture

---
 .../textures/modal/texture-general.tsx        | 174 +++++++++++-------
 src/components/fakeInputLabel.tsx             |  39 ++++
 2 files changed, 146 insertions(+), 67 deletions(-)
 create mode 100644 src/components/fakeInputLabel.tsx

diff --git a/src/app/(pages)/(protected)/council/textures/modal/texture-general.tsx b/src/app/(pages)/(protected)/council/textures/modal/texture-general.tsx
index cea430bc..e3ed8765 100644
--- a/src/app/(pages)/(protected)/council/textures/modal/texture-general.tsx
+++ b/src/app/(pages)/(protected)/council/textures/modal/texture-general.tsx
@@ -1,12 +1,16 @@
 import { useState, useTransition } from 'react';
 
-import { Stack, Switch, TextInput, Text, Textarea, Button, Group, Select } from '@mantine/core';
+import { PiMagicWandBold } from 'react-icons/pi';
+
+import { Stack, Switch, TextInput, Text, Textarea, Button, Group, Select, ActionIcon } from '@mantine/core';
 import { useForm } from '@mantine/form';
 import { Resolution } from '@prisma/client';
 
+import { FakeInputLabel } from '~/components/fakeInputLabel';
 import { TextureImage } from '~/components/texture-img';
+import { useDeviceSize } from '~/hooks/use-device-size';
 import { useEffectOnce } from '~/hooks/use-effect-once';
-import { GRADIENT } from '~/lib/constants';
+import { BREAKPOINT_MOBILE_LARGE, GRADIENT } from '~/lib/constants';
 import { getVanillaTextures } from '~/server/actions/faithful-pack';
 import { getTextureStatus, updateTexture } from '~/server/data/texture';
 
@@ -25,9 +29,11 @@ export interface TextureGeneralForm {
 
 export function TextureGeneral({ texture }: TextureGeneralProps) {
 	const [loading, startTransition] = useTransition();
+	const [windowWidth] = useDeviceSize();
 
 	const [contributionsStatus, setContributionsStatus] = useState<ContributionActivationStatus[]>([]);
 
+	const [vanillaTextureSearch, setVanillaTextureSearch] = useState<string>('');
 	const [vanillaTexture, setVanillaTexture] = useState<string | null>(texture.vanillaTextureId);
 	const [vanillaTextures, setVanillaTextures] = useState<FaithfulCached[]>([]);
 
@@ -86,8 +92,8 @@ export function TextureGeneral({ texture }: TextureGeneralProps) {
 
 	return (
 		<Stack>
-			<Group mt="md" wrap="nowrap" align="start">
-				<Stack gap="sm" w="100%">
+			<Group mt="md" wrap={windowWidth <= BREAKPOINT_MOBILE_LARGE ? 'wrap' : 'nowrap'} align="start">
+				<Stack w="100%">
 					<TextInput
 						w="100%"
 						required
@@ -103,83 +109,117 @@ export function TextureGeneral({ texture }: TextureGeneralProps) {
 						{...form.getInputProps('aliases')}
 					/>
 				</Stack>
-				<Stack gap="sm" w="100%">
-					<Select
-						w="100%"
-						limit={25}
-						label="Vanilla texture"
-						placeholder="Type to search or select a vanilla texture"
+				<Stack w="100%" gap="md">
+					<FakeInputLabel
+						label="Vanilla Texture"
 						description="If this texture is a vanilla texture, select the corresponding vanilla texture, contributions will be disabled"
-						clearable
-						searchable
-						value={vanillaTexture}
-						data={vanillaTextures.map((vt) => ({ value: vt.textureId, label: vt.textureName }))}
-						renderOption={renderMultiSelectOption}
-						onChange={(vanillaTexture) => {
-							setVanillaTexture(vanillaTexture);
-							setContributionsStatus([
-								{ resolution: null, status: vanillaTexture === null },
-								...Object.keys(Resolution).flatMap((res) => ({ resolution: res as Resolution, status: vanillaTexture === null })),
-							]);
-						}}
-					/>
+					>
+						<Group gap="xs" wrap="nowrap">
+							<ActionIcon
+								variant="light"
+								className="navbar-icon-fix"
+								onClick={() => {
+									const name = form.getValues().name;
+									const vanillaTexture = vanillaTextures.find((vt) => vt.textureName === name)?.textureId ?? null;
+
+									setVanillaTexture(vanillaTexture);
+									setVanillaTextureSearch(name ?? '');
+									setContributionsStatus([
+										{ resolution: null, status: vanillaTexture === null },
+										...Object.keys(Resolution).flatMap((res) => ({ resolution: res as Resolution, status: vanillaTexture === null })),
+									]);
+								}}
+							>
+								<PiMagicWandBold />
+							</ActionIcon>
+							<Select
+								w="100%"
+
+								placeholder="Type to search or select a vanilla texture"
+								limit={25}
+
+								clearable
+
+								data={vanillaTextures.map((vt) => ({ value: vt.textureId, label: vt.textureName }))}
+								value={vanillaTexture}
+								defaultValue={vanillaTextures.find((vt) => vt.textureId === vanillaTexture)?.textureName}
+								renderOption={renderMultiSelectOption}
+
+								onChange={(vanillaTexture) => {
+									setVanillaTexture(vanillaTexture);
+									setVanillaTextureSearch(vanillaTextures.find((vt) => vt.textureId === vanillaTexture)?.textureName ?? '');
+									setContributionsStatus([
+										{ resolution: null, status: vanillaTexture === null },
+										...Object.keys(Resolution).flatMap((res) => ({ resolution: res as Resolution, status: vanillaTexture === null })),
+									]);
+								}}
 
-					<Stack gap="xs">
-						<Stack gap={5}>
-							<Text size="var(--input-label-size, var(--mantine-font-size-sm))">
-							Contributions
-							</Text>
-							<Text c="dimmed" size="var(--input-description-size, calc(var(--mantine-font-size-sm)  - calc(.125rem * var(--mantine-scale))))">
-							Users will not be able to contribute to this texture on the unselected resolutions
-							</Text>
-						</Stack>
-						<Switch
-							label="Contributions enabled"
-							disabled={vanillaTexture !== null}
-							color="blue"
-							onLabel="ON"
-							offLabel="OFF"
-							checked={contributionsStatus.find((s) => s.resolution === null)?.status}
-							onChange={(e) => {
-								setContributionsStatus([
-									{ resolution: null, status: e.currentTarget.checked },
-									...Object.keys(Resolution).flatMap((res) => ({ resolution: res as Resolution, status: e.currentTarget.checked })),
-								]);
-							}}
-						/>
-
-						{(Object.keys(Resolution) as Resolution[]).map((res) =>
+								searchable
+								searchValue={vanillaTextureSearch}
+								onSearchChange={setVanillaTextureSearch}
+							/>
+						</Group>
+					</FakeInputLabel>
+
+					<FakeInputLabel
+						label="Contributions"
+						description="Users will not be able to contribute to this texture"
+						gap="var(--mantine-spacing-xs)"
+					>
+						<Stack>
 							<Switch
-								key={res}
-								label={res}
+								label="Enable contributions"
+								disabled={vanillaTexture !== null}
 								color="blue"
 								onLabel="ON"
 								offLabel="OFF"
-								disabled={!contributionsStatus.find((s) => s.resolution === null)?.status}
-								checked={contributionsStatus.find((s) => s.resolution === res)?.status}
+								checked={contributionsStatus.find((s) => s.resolution === null)?.status}
 								onChange={(e) => {
-									setContributionsStatus(contributionsStatus.map((s) => s.resolution === res ? { resolution: res, status: e.currentTarget.checked } : s));
+									setContributionsStatus([
+										{ resolution: null, status: e.currentTarget.checked },
+										...Object.keys(Resolution).flatMap((res) => ({ resolution: res as Resolution, status: e.currentTarget.checked })),
+									]);
 								}}
 							/>
-						)}
 
-					</Stack>
+							<Stack gap="xs">
+								<Text c="dimmed" size="var(--input-description-size, calc(var(--mantine-font-size-sm)  - calc(.125rem * var(--mantine-scale))))">
+									Enable/disable contributions for specific resolutions
+								</Text>
+
+								<Group>
+									{(Object.keys(Resolution) as Resolution[]).map((res) =>
+										<Switch
+											key={res}
+											label={res}
+											color="blue"
+											onLabel="ON"
+											offLabel="OFF"
+											disabled={!contributionsStatus.find((s) => s.resolution === null)?.status}
+											checked={contributionsStatus.find((s) => s.resolution === res)?.status}
+											onChange={(e) => {
+												setContributionsStatus(contributionsStatus.map((s) => s.resolution === res ? { resolution: res, status: e.currentTarget.checked } : s));
+											}}
+										/>
+									)}
+								</Group>
+							</Stack>
+						</Stack>
+					</FakeInputLabel>
 				</Stack>
 			</Group>
 
-			<Group
-				justify='end'
+			<Button
+				mt="md"
+				fullWidth
+				variant="gradient"
+				gradient={GRADIENT}
+				onClick={() => handleSave()}
+				disabled={loading || !form.isValid()}
+				loading={loading}
 			>
-				<Button
-					variant="gradient"
-					gradient={GRADIENT}
-					onClick={() => handleSave()}
-					disabled={loading || !form.isValid()}
-					loading={loading}
-				>
-					Save
-				</Button>
-			</Group>
+				Save
+			</Button>
 		</Stack>
 	);
 }
diff --git a/src/components/fakeInputLabel.tsx b/src/components/fakeInputLabel.tsx
new file mode 100644
index 00000000..5021e9d1
--- /dev/null
+++ b/src/components/fakeInputLabel.tsx
@@ -0,0 +1,39 @@
+import { Stack, Text } from '@mantine/core';
+
+interface Props {
+	children: React.ReactNode;
+	label: string;
+	description?: string;
+	gap?: number | string;
+}
+
+export function FakeInputLabel({ gap, label, description, children }: Props) {
+	return (
+		<Stack gap={0} mt={2}>
+			<Stack gap={0}>
+				<Text
+					lh="var(--mantine-line-height)"
+					size="var(--mantine-font-size-sm)"
+					fw={500}
+				>
+					{label}
+				</Text>
+				<Text
+					lh={1.2}
+					size="var(--input-description-size, calc(var(--mantine-font-size-sm) - calc(.125rem * var(--mantine-scale))))"
+					c="dimmed"
+				>
+					{description}
+				</Text>
+			</Stack>
+
+			<div
+				style={{
+					marginTop: gap ? gap : description ? 'calc(var(--mantine-spacing-xs) / 2)' : 1,
+				}}
+			>
+				{children}
+			</div>
+		</Stack>
+	);
+}

From bcb013377f5311ddf727eb46a91922fcd4d91ccd Mon Sep 17 00:00:00 2001
From: Julien Constant <julienconstant190@gmail.com>
Date: Mon, 19 Aug 2024 03:00:44 +0200
Subject: [PATCH 23/30] feat : add texture hash & vanilla mention to popup

---
 src/components/texture-contribution.tsx | 77 ++++++++++++++++++++-----
 src/components/texture.tsx              | 40 ++++++++++---
 2 files changed, 93 insertions(+), 24 deletions(-)

diff --git a/src/components/texture-contribution.tsx b/src/components/texture-contribution.tsx
index 24d5a44c..792b8894 100644
--- a/src/components/texture-contribution.tsx
+++ b/src/components/texture-contribution.tsx
@@ -1,7 +1,9 @@
+import Link from 'next/link';
+
 import { useMemo, useState } from 'react';
 import type { RefObject } from 'react';
 
-import { GoHash, GoPeople, GoPerson } from 'react-icons/go';
+import { GoHash, GoLinkExternal, GoLog, GoPeople, GoPerson } from 'react-icons/go';
 import { PiApproximateEquals } from 'react-icons/pi';
 
 import { Avatar, Group, Stack, Text } from '@mantine/core';
@@ -9,7 +11,7 @@ import { Avatar, Group, Stack, Text } from '@mantine/core';
 import { SmallTile } from '~/components/small-tile';
 import { TextureImage } from '~/components/texture-img';
 import { useEffectOnce } from '~/hooks/use-effect-once';
-import { getVanillaTextureSrc } from '~/lib/utils';
+import { getVanillaResolution, getVanillaTextureSrc } from '~/lib/utils';
 import { getLatestVanillaTextureContribution } from '~/server/actions/faithful-pack';
 
 import type { Contribution, Resolution, Texture } from '@prisma/client';
@@ -55,6 +57,8 @@ export function GalleryTextureWithContribution({
 
 	const [vanillaContribution, setVanillaContribution] = useState<FPStoredContribution | null>(null);
 
+	const filteredVanillaCoAuthors = useMemo(() => vanillaContribution?.coAuthors.filter((ca) => ca.username !== vanillaContribution?.owner.username) ?? [], [vanillaContribution]);
+
 	useEffectOnce(() => {
 		if (!texture.vanillaTextureId || resolution === 'x16') return;
 
@@ -76,7 +80,7 @@ export function GalleryTextureWithContribution({
 				boxShadow: 'none',
 			}}
 		>
-			<Stack gap={2} align="start" miw={400} maw={400}>
+			<Stack gap={2} align="start" miw={450} maw={450}>
 				<SmallTile color="gray">
 					<Text fw={500} ta="center">{texture.name}</Text>
 				</SmallTile>
@@ -97,14 +101,14 @@ export function GalleryTextureWithContribution({
 						</SmallTile>
 					</Group>
 				)}
-				{resolution !== 'x16' && (contribution && contribution.coAuthors.length > 0) || (vanillaContribution && vanillaContribution.coAuthors.length > 0) && (
+				{resolution !== 'x16' && (contribution && contribution.coAuthors.length > 0) || (filteredVanillaCoAuthors.length > 0) && (
 					<Group gap={2} w="100%" wrap="nowrap" align="start">
 						<SmallTile color="gray" className="navbar-icon-fix" style={{ '--size': '28px' }}>
 							<GoPeople />
 						</SmallTile>
 						<SmallTile color="gray">
 							<Text size="xs">
-								{contribution ? contribution.coAuthors.map((ca) => ca.name).join(', ') : vanillaContribution?.coAuthors.map((ca) => ca.username).join(', ')}
+								{contribution ? contribution.coAuthors.map((ca) => ca.name).join(', ') : filteredVanillaCoAuthors.map((ca) => ca.username).join(', ')}
 							</Text>
 						</SmallTile>
 					</Group>
@@ -121,16 +125,59 @@ export function GalleryTextureWithContribution({
 						</SmallTile>
 					</Group>
 				)}
-				<Group gap={2} w="100%" wrap="nowrap" align="start">
-					<SmallTile color="gray" className="navbar-icon-fix" style={{ '--size': '28px' }}>
-						<GoHash />
-					</SmallTile>
-					<SmallTile color="gray">
-						<Text size="xs" c="dimmed">
-							ID: {texture.id}
-						</Text>
-					</SmallTile>
-				</Group>
+				{resolution !== 'x16' && texture.vanillaTextureId && (
+					<Group gap={2} w="100%" wrap="nowrap" align="start">
+						<SmallTile color="gray" className="navbar-icon-fix" style={{ '--size': '28px' }} >
+							<GoLinkExternal />
+						</SmallTile>
+						<SmallTile color="gray">
+							<Text size="xs">
+								<Link
+									href={`https://webapp.faithfulpack.net/gallery/java/${getVanillaResolution(resolution)}/java-snapshot/all?show=${texture.vanillaTextureId}`}
+									target="_blank"
+								>
+									See in the Faithful Webapp
+								</Link>
+							</Text>
+						</SmallTile>
+					</Group>
+				)}
+				{!texture.vanillaTextureId && (
+					<Group gap={2} w="100%" wrap="nowrap" align="start">
+						<SmallTile color="gray" className="navbar-icon-fix" style={{ '--size': '28px' }}>
+							<GoHash />
+						</SmallTile>
+						<SmallTile color="gray">
+							<Text size="xs" c="dimmed">
+								Texture ID: {texture.id}
+							</Text>
+						</SmallTile>
+					</Group>
+				)}
+				{(resolution === 'x16' && !texture.vanillaTextureId || !contribution && !vanillaContribution) && (
+					<Group gap={2} w="100%" wrap="nowrap" align="start">
+						<SmallTile color="gray" className="navbar-icon-fix" style={{ '--size': '28px' }}>
+							<GoLog />
+						</SmallTile>
+						<SmallTile color="gray">
+							<Text size="xs" c="dimmed">
+								{texture.hash}
+							</Text>
+						</SmallTile>
+					</Group>
+				)}
+				{texture.vanillaTextureId && (
+					<Group gap={2} w="100%" wrap="nowrap" align="start">
+						<SmallTile color="gray" className="navbar-icon-fix" style={{ '--size': '28px' }}>
+							<GoHash />
+						</SmallTile>
+						<SmallTile color="gray">
+							<Text size="xs" c="dimmed">
+								Vanilla Texture ID: {texture.vanillaTextureId}
+							</Text>
+						</SmallTile>
+					</Group>
+				)}
 				{texture.aliases.length > 0 && (
 					<Group gap={2} w="100%" wrap="nowrap" align="start">
 						<SmallTile color="gray" className="navbar-icon-fix" style={{ '--size': '28px' }}>
diff --git a/src/components/texture.tsx b/src/components/texture.tsx
index 9b597a8c..683a2553 100644
--- a/src/components/texture.tsx
+++ b/src/components/texture.tsx
@@ -1,7 +1,7 @@
 import { useMemo } from 'react';
 import type { RefObject } from 'react';
 
-import { GoHash } from 'react-icons/go';
+import { GoAlert, GoHash, GoLog } from 'react-icons/go';
 import { PiApproximateEquals } from 'react-icons/pi';
 
 import { Group, Stack, Text } from '@mantine/core';
@@ -48,27 +48,49 @@ export function GalleryTexture({
 				boxShadow: 'none',
 			}}
 		>
-			<Stack gap={2} align="start" miw={400} maw={400}>
-				<SmallTile>
+			<Stack gap={2} align="start" miw={450} maw={450}>
+				<SmallTile color="gray">
 					<Text fw={500} ta="center">{texture.name}</Text>
 				</SmallTile>
+				{texture.vanillaTextureId && (
+					<Group gap={2} w="100%" wrap="nowrap" align="start">
+						<SmallTile color="gray" className="navbar-icon-fix" style={{ '--size': '28px' }} >
+							<GoAlert color="orange" />
+						</SmallTile>
+						<SmallTile color="gray">
+							<Text size="xs">
+								Vanilla texture : {texture.vanillaTextureId}
+							</Text>
+						</SmallTile>
+					</Group>
+				)}
 				<Group gap={2} w="100%" wrap="nowrap" align="start">
-					<SmallTile className="navbar-icon-fix" style={{ '--size': '28px' }}>
+					<SmallTile color="gray" className="navbar-icon-fix" style={{ '--size': '28px' }}>
 						<GoHash />
 					</SmallTile>
-					<SmallTile>
-						<Text size="xs" c="dimmed">
+					<SmallTile color="gray">
+						<Text size="xs">
 							ID: {texture.id}
 						</Text>
 					</SmallTile>
 				</Group>
+				<Group gap={2} w="100%" wrap="nowrap" align="start">
+					<SmallTile color="gray" className="navbar-icon-fix" style={{ '--size': '28px' }}>
+						<GoLog />
+					</SmallTile>
+					<SmallTile color="gray">
+						<Text size="xs">
+							{texture.hash}
+						</Text>
+					</SmallTile>
+				</Group>
 				{texture.aliases.length > 0 && (
 					<Group gap={2} w="100%" wrap="nowrap" align="start">
-						<SmallTile className="navbar-icon-fix" style={{ '--size': '28px' }}>
+						<SmallTile color="gray" className="navbar-icon-fix" style={{ '--size': '28px' }}>
 							<PiApproximateEquals />
 						</SmallTile>
-						<SmallTile>
-							<Text size="xs" c="dimmed">
+						<SmallTile color="gray">
+							<Text size="xs">
 								{texture.aliases.join(', ')}
 							</Text>
 						</SmallTile>

From f8b8bf666ab5c6e4f4841ef348fb912e9fe3ead7 Mon Sep 17 00:00:00 2001
From: Julien Constant <julienconstant190@gmail.com>
Date: Mon, 19 Aug 2024 03:42:27 +0200
Subject: [PATCH 24/30] feat : add vanilla texture & disabled contribution to
 contributions

---
 .../(pages)/(protected)/contribute/page.tsx   | 216 ++++++++++++------
 src/server/data/texture.ts                    |   8 +-
 2 files changed, 147 insertions(+), 77 deletions(-)

diff --git a/src/app/(pages)/(protected)/contribute/page.tsx b/src/app/(pages)/(protected)/contribute/page.tsx
index 1bf62447..814a142f 100644
--- a/src/app/(pages)/(protected)/contribute/page.tsx
+++ b/src/app/(pages)/(protected)/contribute/page.tsx
@@ -4,7 +4,7 @@ import Link from 'next/link';
 
 import { useCallback, useEffect, useState, useTransition } from 'react';
 
-import { GoCommit, GoHash, GoHourglass, GoQuestion, GoRelFilePath } from 'react-icons/go';
+import { GoAlert, GoCommit, GoHash, GoHourglass, GoQuestion, GoRelFilePath } from 'react-icons/go';
 import { IoReload } from 'react-icons/io5';
 import { LuArrowUpDown } from 'react-icons/lu';
 
@@ -24,9 +24,9 @@ import { getContributionsOfFork } from '~/server/actions/git';
 import { archiveContributions, createContributionsFromGitFiles, deleteContributions, deleteContributionsOrArchive, getContributionsOfUser, submitContributions } from '~/server/data/contributions';
 import { getTextures } from '~/server/data/texture';
 
-import type { Texture } from '@prisma/client';
 import type { GitFile } from '~/server/actions/git';
 import type { GetContributionsOfUser } from '~/server/data/contributions';
+import type { GetTextures } from '~/server/data/texture';
 
 import './styles.scss';
 
@@ -44,7 +44,7 @@ export default function ContributeSubmissionsPage() {
 	const prevRes = usePrevious(resolution);
 
 	const [contributions, setContributions] = useState<GetContributionsOfUser[]>([]);
-	const [textures, setTextures] = useState<Texture[]>([]);
+	const [textures, setTextures] = useState<GetTextures[]>([]);
 
 	const [groupRef, setGroupRef] = useState<HTMLDivElement | null>(null);
 	const [controlsRefs, setControlsRefs] = useState<Record<string, HTMLButtonElement | null>>({});
@@ -52,9 +52,24 @@ export default function ContributeSubmissionsPage() {
 	const [windowWidth] = useDeviceSize();
 
 	useHotkeys([
-		['mod+a', () => setSelectedContributions(contributions.filter((c) => c.status === Object.keys(Status)[activeTab]).map((c) => c.id))],
+		[
+			'mod+a',
+			() => setSelectedContributions(contributions
+				.filter(canContributionBeSubmitted)
+				.filter((c) => c.status === Object.keys(Status)[activeTab]).map((c) => c.id)),
+		],
 	]);
 
+	const canContributionBeSubmitted = useCallback((contribution: GetContributionsOfUser) => {
+		const texture = textures.find((t) => t.id === contribution.textureId);
+		if (!texture) return false;
+
+		const disabledResolution = texture.disabledContributions.find((dc) => dc.resolution === resolution);
+		const allResolutionsDisabled = texture.disabledContributions.find((dc) => dc.resolution === null);
+
+		return !disabledResolution && !allResolutionsDisabled;
+	}, [resolution, textures]);
+
 	const setControlRef = (index: number) => (node: HTMLButtonElement) => {
 		controlsRefs[index] = node;
 		setControlsRefs(controlsRefs);
@@ -268,7 +283,7 @@ export default function ContributeSubmissionsPage() {
 					</Text>
 					<List ml="sm">
 						<List.Item><Text fw={360}>If not already, fork the default textures repository using the &quot;Create Fork&quot; button;</Text></List.Item>
-						<List.Item><Text fw={360}>Clone it to your local machine using <Link href="https://git-scm.com/" target="_blank">Git</Link> or <Link href="https://desktop.github.com/download/" target="_blank">GitHub Desktop</Link>;</Text></List.Item>
+						<List.Item><Text fw={360}>Clone it to your local machine using <Link href="https://git-scm.com/" target="_blank">Git</Link> or <Link href="https://desktop.github.com/download/" target="_blank">GitHub Desktop</Link> (recommended);</Text></List.Item>
 						<List.Item><Text fw={360}>Switch to the branch corresponding to the resolution you want to contribute to;</Text></List.Item>
 						<List.Item><Text fw={360}>Add textures to the repository, <Text component="span" fw={700}>each texture should have the same name as the contributed texture name in the <Link href="https://github.com/faithful-mods/resources-default" target="_blank">default repository</Link></Text>;</Text></List.Item>
 						<List.Item><Text fw={360}>Commit your changes and push them to your fork;</Text></List.Item>
@@ -327,19 +342,29 @@ export default function ContributeSubmissionsPage() {
 						const repository = contribution.filepath.split('/')[4]!;
 						const commitSha = contribution.filepath.split('/')[5]!;
 
+						const disabledResolution = texture?.disabledContributions.find((dc) => dc.resolution === resolution);
+						const allResolutionsDisabled = texture?.disabledContributions.find((dc) => dc.resolution === null);
+
 						return (
 							(
 								<TextureImage
 									key={index}
 									src={contribution.filepath}
 									alt={contribution.filename}
-									onClick={() => setSelectedContributions((prev) => {
-										if (prev.includes(contribution.id)) return prev.filter((id) => id !== contribution.id);
-										return [...prev, contribution.id];
-									})}
+									onClick={() => {
+										if (!canContributionBeSubmitted(contribution)) return;
+
+										setSelectedContributions((prev) => {
+											if (prev.includes(contribution.id)) return prev.filter((id) => id !== contribution.id);
+											return [...prev, contribution.id];
+										});
+									}}
+
 									styles={{
 										boxShadow: selectedContributions.includes(contribution.id) ? '0 0 0 2px var(--mantine-color-teal-filled)' : 'none',
+										cursor: canContributionBeSubmitted(contribution) ? 'pointer' : 'not-allowed',
 									}}
+
 									popupStyles={{
 										backgroundColor: 'transparent',
 										padding: 0,
@@ -347,92 +372,133 @@ export default function ContributeSubmissionsPage() {
 										boxShadow: 'none',
 									}}
 								>
-									<Group gap={2}>
-										{texture && (
-											<SmallTile color="gray" w={125} h="100%">
-												<Group w="100%" h="100%" justify="center" align="center">
-													<TextureImage
-														src={texture.filepath}
-														alt={texture.name}
-														mcmeta={texture.mcmeta}
-														size={115}
-													/>
-												</Group>
-											</SmallTile>
-										)}
-										<Stack gap={2} align="start" miw={400} maw={400}>
-											<SmallTile color="gray">
-												<Text fw={500} ta="center">{texture?.name}</Text>
-											</SmallTile>
-
-											<Group gap={2} w="100%" wrap="nowrap" align="start">
-												<SmallTile color="gray" className="navbar-icon-fix" style={{ '--size': '28px' }}>
-													<GoCommit />
+									<Stack gap={2}>
+										<Group gap={2}>
+											{texture && (
+												<SmallTile color="gray" w={125} h="100%">
+													<Group w="100%" h="100%" justify="center" align="center">
+														<TextureImage
+															src={texture.filepath}
+															alt={texture.name}
+															mcmeta={texture.mcmeta}
+															size={115}
+														/>
+													</Group>
 												</SmallTile>
+											)}
+											<Stack gap={2} align="start" miw={468} maw={468}>
 												<SmallTile color="gray">
-													<Text size="xs">
-														<a
-															href={gitCommitUrl({ orgOrUser, repository, commitSha })}
-															target="_blank"
-															rel="noreferrer"
-														>
-															{commitSha}
-														</a>
-													</Text>
+													<Text fw={500} ta="center">{texture?.name}</Text>
 												</SmallTile>
-											</Group>
-
-											<Group gap={2} w="100%" wrap="nowrap" align="start">
-												<SmallTile color="gray" className="navbar-icon-fix" style={{ '--size': '28px' }}>
-													<GoRelFilePath />
-												</SmallTile>
-												<SmallTile color="gray">
-													<Text size="xs">
-														<a
-															href={gitBlobUrl({ orgOrUser, repository, branchOrCommit: commitSha, path: contribution.filename })}
-															target="_blank"
-															rel="noreferrer"
-														>
-															{contribution.filename}
-														</a>
-													</Text>
-												</SmallTile>
-											</Group>
 
-											<Group gap={2} w="100%">
-												<Group gap={2} w="calc((100% - 4px) / 3)" wrap="nowrap" align="start">
+												<Group gap={2} w="100%" wrap="nowrap" align="start">
 													<SmallTile color="gray" className="navbar-icon-fix" style={{ '--size': '28px' }}>
-														<LuArrowUpDown />
+														<GoCommit />
 													</SmallTile>
 													<SmallTile color="gray">
 														<Text size="xs">
-															{contribution.poll.upvotes.length - contribution.poll.downvotes.length}
+															<a
+																href={gitCommitUrl({ orgOrUser, repository, commitSha })}
+																target="_blank"
+																rel="noreferrer"
+															>
+																{commitSha}
+															</a>
 														</Text>
 													</SmallTile>
 												</Group>
-												<Group gap={2} w="calc((100% - 4px) / 3)" wrap="nowrap" align="start">
+
+												<Group gap={2} w="100%" wrap="nowrap" align="start">
 													<SmallTile color="gray" className="navbar-icon-fix" style={{ '--size': '28px' }}>
-														<GoHash />
+														<GoRelFilePath />
 													</SmallTile>
 													<SmallTile color="gray">
 														<Text size="xs">
-															{contribution.textureId}
+															<a
+																href={gitBlobUrl({ orgOrUser, repository, branchOrCommit: commitSha, path: contribution.filename })}
+																target="_blank"
+																rel="noreferrer"
+															>
+																{contribution.filename}
+															</a>
 														</Text>
 													</SmallTile>
 												</Group>
-												<Group gap={2} w="calc((100% - 4px) / 3)" wrap="nowrap" align="start">
-													<SmallTile color="gray" className="navbar-icon-fix" style={{ '--size': '28px' }}>
-														<GoHourglass />
-													</SmallTile>
-													<SmallTile color="gray">
-														<Text size="xs">
-															{contribution.status}
-														</Text>
-													</SmallTile>
+
+												<Group gap={2} w="100%">
+													<Group gap={2} w="calc((100% - 4px) / 3)" wrap="nowrap" align="start">
+														<SmallTile color="gray" className="navbar-icon-fix" style={{ '--size': '28px' }}>
+															<LuArrowUpDown />
+														</SmallTile>
+														<SmallTile color="gray">
+															<Text size="xs">
+																{contribution.poll.upvotes.length - contribution.poll.downvotes.length}
+															</Text>
+														</SmallTile>
+													</Group>
+													<Group gap={2} w="calc((100% - 4px) / 3)" wrap="nowrap" align="start">
+														<SmallTile color="gray" className="navbar-icon-fix" style={{ '--size': '28px' }}>
+															<GoHash />
+														</SmallTile>
+														<SmallTile color="gray">
+															<Text size="xs">
+																{contribution.textureId}
+															</Text>
+														</SmallTile>
+													</Group>
+													<Group gap={2} w="calc((100% - 4px) / 3)" wrap="nowrap" align="start">
+														<SmallTile color="gray" className="navbar-icon-fix" style={{ '--size': '28px' }}>
+															<GoHourglass />
+														</SmallTile>
+														<SmallTile color="gray">
+															<Text size="xs">
+																{contribution.status}
+															</Text>
+														</SmallTile>
+													</Group>
 												</Group>
+											</Stack>
+										</Group>
+
+										{texture && texture.vanillaTextureId && (
+											<Group gap={2} w="100%" wrap="nowrap" align="start">
+												<SmallTile color="yellow" className="navbar-icon-fix" style={{ '--size': '28px' }}>
+													<GoAlert color="black" />
+												</SmallTile>
+												<SmallTile color="yellow">
+													<Text size="xs" c="black">
+														This texture is a vanilla texture, contributions should be done from the officials Discords channels.
+													</Text>
+												</SmallTile>
+											</Group>
+										)}
+
+										{texture && !texture.vanillaTextureId && disabledResolution && !allResolutionsDisabled && (
+											<Group gap={2} w="100%" wrap="nowrap" align="start">
+												<SmallTile color="red" className="navbar-icon-fix" style={{ '--size': '28px' }}>
+													<GoAlert color="black" />
+												</SmallTile>
+												<SmallTile color="red">
+													<Text size="xs" c="black">
+														This texture does not accept contributions for the {resolution} resolution.
+													</Text>
+												</SmallTile>
+											</Group>
+										)}
+
+										{texture && !texture.vanillaTextureId && allResolutionsDisabled && (
+											<Group gap={2} w="100%" wrap="nowrap" align="start">
+												<SmallTile color="red" className="navbar-icon-fix" style={{ '--size': '28px' }}>
+													<GoAlert color="black" />
+												</SmallTile>
+												<SmallTile color="red">
+													<Text size="xs" c="black">
+														This texture does not accept contributions for any resolution.
+													</Text>
+												</SmallTile>
 											</Group>
-										</Stack>
-									</Group>
+										)}
+									</Stack>
 								</TextureImage>
 							)
 						);
diff --git a/src/server/data/texture.ts b/src/server/data/texture.ts
index 2b2dac01..1c9479a6 100644
--- a/src/server/data/texture.ts
+++ b/src/server/data/texture.ts
@@ -9,13 +9,17 @@ import { db } from '~/lib/db';
 import { remove } from '../actions/files';
 
 import type { ContributionDeactivation, Texture } from '@prisma/client';
-import type { ContributionActivationStatus, Progression, TextureMCMETA } from '~/types';
+import type { ContributionActivationStatus, Prettify, Progression, TextureMCMETA } from '~/types';
 
 import '~/lib/polyfills';
 
 // GET
 
-export async function getTextures(): Promise<(Texture & { disabledContributions: ContributionDeactivation[] })[]> {
+export type GetTextures = Prettify<Texture & {
+	disabledContributions: ContributionDeactivation[];
+}>;
+
+export async function getTextures(): Promise<GetTextures[]> {
 	return db.texture.findMany({ include: { disabledContributions: true } });
 }
 

From daec0ada473c4cc3640a536c5d319674603a63e3 Mon Sep 17 00:00:00 2001
From: Julien Constant <julienconstant190@gmail.com>
Date: Mon, 19 Aug 2024 03:44:15 +0200
Subject: [PATCH 25/30] fix : clean up unused code

---
 src/server/actions/git.ts | 40 ---------------------------------------
 1 file changed, 40 deletions(-)

diff --git a/src/server/actions/git.ts b/src/server/actions/git.ts
index 6ca048b6..7db5f200 100644
--- a/src/server/actions/git.ts
+++ b/src/server/actions/git.ts
@@ -216,24 +216,6 @@ async function setDefaultBranch(owner: string, repo: string, branch: string) {
 	});
 }
 
-async function renameBranch(owner: string, repo: string, oldBranch: string, newBranch: string) {
-	const octokit = await getOctokit();
-	const { data } = await octokit.git.getRef({
-		owner,
-		repo,
-		ref: `heads/${oldBranch}`,
-	});
-
-	await octokit.git.createRef({
-		owner,
-		repo,
-		ref: `refs/heads/${newBranch}`,
-		sha: data.object.sha,
-	});
-
-	await deleteBranch(owner, repo, oldBranch);
-}
-
 async function deleteBranch(owner: string, repo: string, branch: string) {
 	const octokit = await getOctokit();
 	await octokit.git.deleteRef({
@@ -243,28 +225,6 @@ async function deleteBranch(owner: string, repo: string, branch: string) {
 	});
 }
 
-async function createBranchFromCommit(owner: string, repo: string, branch: string, commitSha: string) {
-	const octokit = await getOctokit();
-	await octokit.git.createRef({
-		owner,
-		repo,
-		ref: `refs/heads/${branch}`,
-		sha: commitSha,
-	});
-}
-
-async function getFirstCommit(owner: string, repo: string) {
-	const octokit = await getOctokit();
-	const { data } = await octokit.repos.listCommits({
-		owner, repo,
-		sha: 'main',
-		per_page: 1,
-		page: 1,
-	});
-
-	return data[0]!.sha;
-}
-
 async function setBranchToCommit(commitSha: string, branch: string) {
 	const octokit = await getOctokit();
 	await octokit.git.updateRef({

From 33189c60332f2c71df5f85dd4a58835eb4ad9542 Mon Sep 17 00:00:00 2001
From: Julien Constant <julienconstant190@gmail.com>
Date: Mon, 19 Aug 2024 03:52:00 +0200
Subject: [PATCH 26/30] feat : use mod unique id instead of cuid for pagination

---
 src/app/(pages)/mods/[modId]/gallery/page.tsx |  4 ++--
 src/app/(pages)/mods/[modId]/layout.tsx       |  6 +++---
 src/app/(pages)/mods/[modId]/page.tsx         | 10 +++++-----
 src/app/(pages)/mods/page.tsx                 |  2 +-
 src/server/data/mods-version.ts               | 11 +++++++----
 src/server/data/mods.ts                       |  4 ++++
 6 files changed, 22 insertions(+), 15 deletions(-)

diff --git a/src/app/(pages)/mods/[modId]/gallery/page.tsx b/src/app/(pages)/mods/[modId]/gallery/page.tsx
index 13ded730..0d6a51d3 100644
--- a/src/app/(pages)/mods/[modId]/gallery/page.tsx
+++ b/src/app/(pages)/mods/[modId]/gallery/page.tsx
@@ -14,7 +14,7 @@ import { useEffectOnce } from '~/hooks/use-effect-once';
 import { BREAKPOINT_MOBILE_LARGE, BREAKPOINT_TABLET, ITEMS_PER_PAGE, ITEMS_PER_ROW } from '~/lib/constants';
 import { searchFilter } from '~/lib/utils';
 import { getLatestContributionsOfModVersion } from '~/server/data/contributions';
-import { getModVersionFromMod } from '~/server/data/mods-version';
+import { getModVersionFromModForgeId } from '~/server/data/mods-version';
 import { getTexturesFromModVersion } from '~/server/data/texture';
 
 import type { ModVersion, Texture } from '@prisma/client';
@@ -48,7 +48,7 @@ export default function ModGalleryPage() {
 	const texturesGroupRef = useRef<HTMLDivElement>(null);
 
 	useEffectOnce(() => {
-		getModVersionFromMod(modId).then((versions) => {
+		getModVersionFromModForgeId(modId).then((versions) => {
 			setModVersions(versions);
 			setModVersionShown(versions[0]?.id ?? null);
 		});
diff --git a/src/app/(pages)/mods/[modId]/layout.tsx b/src/app/(pages)/mods/[modId]/layout.tsx
index 0c49093c..0197c14d 100644
--- a/src/app/(pages)/mods/[modId]/layout.tsx
+++ b/src/app/(pages)/mods/[modId]/layout.tsx
@@ -16,7 +16,7 @@ import { TextureImage } from '~/components/texture-img';
 import { useDeviceSize } from '~/hooks/use-device-size';
 import { useEffectOnce } from '~/hooks/use-effect-once';
 import { BREAKPOINT_MOBILE_LARGE } from '~/lib/constants';
-import { getModsFromIds, getModDownloads } from '~/server/data/mods';
+import { getModDownloads, getModFromForgeId } from '~/server/data/mods';
 
 import type { Mod } from '@prisma/client';
 import type { Downloads } from '~/types';
@@ -35,8 +35,8 @@ export default function ModLayout({ children }: { children: React.ReactNode }) {
 	useEffectOnce(() => {
 		if (!modId) return;
 
-		getModsFromIds([modId])
-			.then((mod) => setMod(mod[0] ?? null))
+		getModFromForgeId(modId)
+			.then(setMod)
 			.then(() => getModDownloads(modId).then(setDownloads))
 			.finally(() => setLoading(false));
 	});
diff --git a/src/app/(pages)/mods/[modId]/page.tsx b/src/app/(pages)/mods/[modId]/page.tsx
index 931d50ba..6d1469a6 100644
--- a/src/app/(pages)/mods/[modId]/page.tsx
+++ b/src/app/(pages)/mods/[modId]/page.tsx
@@ -14,8 +14,8 @@ import { Tile } from '~/components/tile';
 import { useDeviceSize } from '~/hooks/use-device-size';
 import { useEffectOnce } from '~/hooks/use-effect-once';
 import { BREAKPOINT_MOBILE_LARGE, ITEMS_PER_PAGE, RESOLUTIONS_COLORS, EMPTY_PROGRESSION, ITEMS_PER_PAGE_DEFAULT } from '~/lib/constants';
-import { getModsFromIds } from '~/server/data/mods';
-import { getModVersionFromMod, getModVersionProgressionFromMod } from '~/server/data/mods-version';
+import { getModFromForgeId } from '~/server/data/mods';
+import { getModVersionFromModForgeId, getModVersionProgressionFromModForgeId } from '~/server/data/mods-version';
 
 import type { Mod, ModVersion } from '@prisma/client';
 import type { Progression } from '~/types';
@@ -107,9 +107,9 @@ export default function ModPage() {
 	}, [search, versions]);
 
 	useEffectOnce(() => {
-		getModsFromIds([modId]).then((mod) => setMod(mod[0] ?? null));
-		getModVersionProgressionFromMod(modId).then(setProgressions);
-		getModVersionFromMod(modId).then((versions) => {
+		getModFromForgeId(modId).then(setMod);
+		getModVersionProgressionFromModForgeId(modId).then(setProgressions);
+		getModVersionFromModForgeId(modId).then((versions) => {
 			setVersions(versions);
 			setFilteredVersions(versions);
 		});
diff --git a/src/app/(pages)/mods/page.tsx b/src/app/(pages)/mods/page.tsx
index e25648f0..0be00c6d 100644
--- a/src/app/(pages)/mods/page.tsx
+++ b/src/app/(pages)/mods/page.tsx
@@ -280,7 +280,7 @@ export default function Mods() {
 					modsShown[activePage - 1] && modsShown[activePage - 1]?.map((m) => (
 						<Tile
 							key={m.id}
-							onClick={() => router.push(`/mods/${m.id}`)}
+							onClick={() => router.push(`/mods/${m.forgeId}`)}
 							className="cursor-pointer mod-card"
 						>
 							<Stack gap="xs">
diff --git a/src/server/data/mods-version.ts b/src/server/data/mods-version.ts
index 3ba6b4e3..b10e130d 100644
--- a/src/server/data/mods-version.ts
+++ b/src/server/data/mods-version.ts
@@ -42,8 +42,11 @@ export async function getModVersionsWithModpacks(modId: string): Promise<ModVers
 	return res;
 }
 
-export async function getModVersionFromMod(modId: string): Promise<ModVersion[]> {
-	return db.modVersion.findMany({ where: { modId } }).then((res) => res.sort((a, b) => sortBySemver(a.version, b.version)));
+export async function getModVersionFromModForgeId(forgeId: string): Promise<ModVersion[]> {
+	const mod = await db.mod.findFirst({ where: { forgeId }	});
+	if (!mod) return [];
+
+	return db.modVersion.findMany({ where: { modId: mod.id } });
 }
 
 export async function getModVersions(): Promise<ModVersion[]> {
@@ -69,8 +72,8 @@ export async function getModsVersionsFromResources(resourceIds: string[]): Promi
 		.then((res) => res.map((modVer) => ({ ...modVer, resources: modVer.resources.map((r) => r.id) })));
 }
 
-export async function getModVersionProgressionFromMod(modId: string): Promise<Record<string, Progression>> {
-	const modVersions = await db.modVersion.findMany({ where: { modId }, select: { id: true } });
+export async function getModVersionProgressionFromModForgeId(forgeId: string): Promise<Record<string, Progression>> {
+	const modVersions = await getModVersionFromModForgeId(forgeId);
 	const modVersionsIds = modVersions.map((mv) => mv.id);
 
 	const progressions: Record<string, Progression> = {};
diff --git a/src/server/data/mods.ts b/src/server/data/mods.ts
index fef5d8cc..f68f5481 100644
--- a/src/server/data/mods.ts
+++ b/src/server/data/mods.ts
@@ -16,6 +16,10 @@ import type { Downloads } from '~/types';
 
 // GET
 
+export async function getModFromForgeId(forgeId: string): Promise<Mod | null> {
+	return db.mod.findFirst({ where: { forgeId } });
+}
+
 export async function getModsFromIds(ids: string[]): Promise<Mod[]> {
 	return db.mod.findMany({ where: { id: { in: ids } } });
 }

From e7fd04e652c4f61fa0e065459bd6e7468cad008d Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 19 Aug 2024 03:53:05 +0200
Subject: [PATCH 27/30] deps-dev : bump tailwindcss from 3.4.8 to 3.4.10 (#136)

Bumps [tailwindcss](https://github.com/tailwindlabs/tailwindcss) from 3.4.8 to 3.4.10.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/v3.4.10/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/compare/v3.4.8...v3.4.10)

---
updated-dependencies:
- dependency-name: tailwindcss
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
 package-lock.json | 9 ++++-----
 package.json      | 2 +-
 2 files changed, 5 insertions(+), 6 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index 82fc62eb..52c0313d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -63,7 +63,7 @@
 				"stylelint": "^16.8.1",
 				"stylelint-config-standard-scss": "^13.1.0",
 				"stylelint-scss": "^6.5.0",
-				"tailwindcss": "^3.4.8",
+				"tailwindcss": "^3.4.10",
 				"tsx": "^4.16.5",
 				"typescript": "^5.5.4"
 			}
@@ -7121,10 +7121,9 @@
 			}
 		},
 		"node_modules/tailwindcss": {
-			"version": "3.4.8",
-			"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.8.tgz",
-			"integrity": "sha512-GkP17r9GQkxgZ9FKHJQEnjJuKBcbFhMFzKu5slmN6NjlCuFnYJMQ8N4AZ6VrUyiRXlDtPKHkesuQ/MS913Nvdg==",
-			"license": "MIT",
+			"version": "3.4.10",
+			"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.10.tgz",
+			"integrity": "sha512-KWZkVPm7yJRhdu4SRSl9d4AK2wM3a50UsvgHZO7xY77NQr2V+fIrEuoDGQcbvswWvFGbS2f6e+jC/6WJm1Dl0w==",
 			"dependencies": {
 				"@alloc/quick-lru": "^5.2.0",
 				"arg": "^5.0.2",
diff --git a/package.json b/package.json
index 9c4895ad..04a5213d 100644
--- a/package.json
+++ b/package.json
@@ -66,7 +66,7 @@
 		"stylelint": "^16.8.1",
 		"stylelint-config-standard-scss": "^13.1.0",
 		"stylelint-scss": "^6.5.0",
-		"tailwindcss": "^3.4.8",
+		"tailwindcss": "^3.4.10",
 		"tsx": "^4.16.5",
 		"typescript": "^5.5.4"
 	}

From 86ad5415eaffb06dc28e58dab27a273ea3a4820c Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 19 Aug 2024 03:59:27 +0200
Subject: [PATCH 28/30] deps-dev : bump eslint-plugin-unused-imports from 3.2.0
 to 4.1.3 (#127)

Bumps [eslint-plugin-unused-imports](https://github.com/sweepline/eslint-plugin-unused-imports) from 3.2.0 to 4.1.3.
- [Commits](https://github.com/sweepline/eslint-plugin-unused-imports/commits/v4.1.3)

---
updated-dependencies:
- dependency-name: eslint-plugin-unused-imports
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
---
 package-lock.json | 27 ++++++---------------------
 package.json      |  2 +-
 2 files changed, 7 insertions(+), 22 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index 52c0313d..6a27ce30 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -54,7 +54,7 @@
 				"cross-env": "^7.0.3",
 				"eslint": "^8.57.0",
 				"eslint-config-next": "^14.2.5",
-				"eslint-plugin-unused-imports": "^3.2.0",
+				"eslint-plugin-unused-imports": "^4.1.3",
 				"postcss": "^8.4.39",
 				"postcss-preset-mantine": "^1.17.0",
 				"postcss-simple-vars": "^7.0.1",
@@ -3392,19 +3392,13 @@
 			}
 		},
 		"node_modules/eslint-plugin-unused-imports": {
-			"version": "3.2.0",
-			"resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-3.2.0.tgz",
-			"integrity": "sha512-6uXyn6xdINEpxE1MtDjxQsyXB37lfyO2yKGVVgtD7WEWQGORSOZjgrD6hBhvGv4/SO+TOlS+UnC6JppRqbuwGQ==",
+			"version": "4.1.3",
+			"resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.1.3.tgz",
+			"integrity": "sha512-lqrNZIZjFMUr7P06eoKtQLwyVRibvG7N+LtfKtObYGizAAGrcqLkc3tDx+iAik2z7q0j/XI3ihjupIqxhFabFA==",
 			"dev": true,
-			"dependencies": {
-				"eslint-rule-composer": "^0.3.0"
-			},
-			"engines": {
-				"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
-			},
 			"peerDependencies": {
-				"@typescript-eslint/eslint-plugin": "6 - 7",
-				"eslint": "8"
+				"@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0",
+				"eslint": "^9.0.0 || ^8.0.0"
 			},
 			"peerDependenciesMeta": {
 				"@typescript-eslint/eslint-plugin": {
@@ -3412,15 +3406,6 @@
 				}
 			}
 		},
-		"node_modules/eslint-rule-composer": {
-			"version": "0.3.0",
-			"resolved": "https://registry.npmjs.org/eslint-rule-composer/-/eslint-rule-composer-0.3.0.tgz",
-			"integrity": "sha512-bt+Sh8CtDmn2OajxvNO+BX7Wn4CIWMpTRm3MaiKPCQcnnlm0CS2mhui6QaoeQugs+3Kj2ESKEEGJUdVafwhiCg==",
-			"dev": true,
-			"engines": {
-				"node": ">=4.0.0"
-			}
-		},
 		"node_modules/eslint-scope": {
 			"version": "7.2.2",
 			"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
diff --git a/package.json b/package.json
index 04a5213d..cb645092 100644
--- a/package.json
+++ b/package.json
@@ -57,7 +57,7 @@
 		"cross-env": "^7.0.3",
 		"eslint": "^8.57.0",
 		"eslint-config-next": "^14.2.5",
-		"eslint-plugin-unused-imports": "^3.2.0",
+		"eslint-plugin-unused-imports": "^4.1.3",
 		"postcss": "^8.4.39",
 		"postcss-preset-mantine": "^1.17.0",
 		"postcss-simple-vars": "^7.0.1",

From 2dc7fbb4a2dfd41d4607ae382539516d389f2d5a Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 19 Aug 2024 03:59:55 +0200
Subject: [PATCH 29/30] deps-dev : bump tsx from 4.16.5 to 4.17.0 (#128)

Bumps [tsx](https://github.com/privatenumber/tsx) from 4.16.5 to 4.17.0.
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.16.5...v4.17.0)

---
updated-dependencies:
- dependency-name: tsx
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
---
 package-lock.json | 290 ++++++++++++++++++++++------------------------
 package.json      |   2 +-
 2 files changed, 142 insertions(+), 150 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index 6a27ce30..5260987c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -64,7 +64,7 @@
 				"stylelint-config-standard-scss": "^13.1.0",
 				"stylelint-scss": "^6.5.0",
 				"tailwindcss": "^3.4.10",
-				"tsx": "^4.16.5",
+				"tsx": "^4.17.0",
 				"typescript": "^5.5.4"
 			}
 		},
@@ -381,394 +381,387 @@
 			}
 		},
 		"node_modules/@esbuild/aix-ppc64": {
-			"version": "0.21.5",
-			"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
-			"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+			"version": "0.23.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz",
+			"integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==",
 			"cpu": [
 				"ppc64"
 			],
 			"dev": true,
-			"license": "MIT",
 			"optional": true,
 			"os": [
 				"aix"
 			],
 			"engines": {
-				"node": ">=12"
+				"node": ">=18"
 			}
 		},
 		"node_modules/@esbuild/android-arm": {
-			"version": "0.21.5",
-			"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
-			"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+			"version": "0.23.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz",
+			"integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==",
 			"cpu": [
 				"arm"
 			],
 			"dev": true,
-			"license": "MIT",
 			"optional": true,
 			"os": [
 				"android"
 			],
 			"engines": {
-				"node": ">=12"
+				"node": ">=18"
 			}
 		},
 		"node_modules/@esbuild/android-arm64": {
-			"version": "0.21.5",
-			"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
-			"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+			"version": "0.23.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz",
+			"integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
-			"license": "MIT",
 			"optional": true,
 			"os": [
 				"android"
 			],
 			"engines": {
-				"node": ">=12"
+				"node": ">=18"
 			}
 		},
 		"node_modules/@esbuild/android-x64": {
-			"version": "0.21.5",
-			"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
-			"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+			"version": "0.23.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz",
+			"integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
-			"license": "MIT",
 			"optional": true,
 			"os": [
 				"android"
 			],
 			"engines": {
-				"node": ">=12"
+				"node": ">=18"
 			}
 		},
 		"node_modules/@esbuild/darwin-arm64": {
-			"version": "0.21.5",
-			"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
-			"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+			"version": "0.23.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz",
+			"integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
-			"license": "MIT",
 			"optional": true,
 			"os": [
 				"darwin"
 			],
 			"engines": {
-				"node": ">=12"
+				"node": ">=18"
 			}
 		},
 		"node_modules/@esbuild/darwin-x64": {
-			"version": "0.21.5",
-			"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
-			"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+			"version": "0.23.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz",
+			"integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
-			"license": "MIT",
 			"optional": true,
 			"os": [
 				"darwin"
 			],
 			"engines": {
-				"node": ">=12"
+				"node": ">=18"
 			}
 		},
 		"node_modules/@esbuild/freebsd-arm64": {
-			"version": "0.21.5",
-			"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
-			"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+			"version": "0.23.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz",
+			"integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
-			"license": "MIT",
 			"optional": true,
 			"os": [
 				"freebsd"
 			],
 			"engines": {
-				"node": ">=12"
+				"node": ">=18"
 			}
 		},
 		"node_modules/@esbuild/freebsd-x64": {
-			"version": "0.21.5",
-			"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
-			"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+			"version": "0.23.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz",
+			"integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
-			"license": "MIT",
 			"optional": true,
 			"os": [
 				"freebsd"
 			],
 			"engines": {
-				"node": ">=12"
+				"node": ">=18"
 			}
 		},
 		"node_modules/@esbuild/linux-arm": {
-			"version": "0.21.5",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
-			"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+			"version": "0.23.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz",
+			"integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==",
 			"cpu": [
 				"arm"
 			],
 			"dev": true,
-			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
 			],
 			"engines": {
-				"node": ">=12"
+				"node": ">=18"
 			}
 		},
 		"node_modules/@esbuild/linux-arm64": {
-			"version": "0.21.5",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
-			"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+			"version": "0.23.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz",
+			"integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
-			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
 			],
 			"engines": {
-				"node": ">=12"
+				"node": ">=18"
 			}
 		},
 		"node_modules/@esbuild/linux-ia32": {
-			"version": "0.21.5",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
-			"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+			"version": "0.23.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz",
+			"integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==",
 			"cpu": [
 				"ia32"
 			],
 			"dev": true,
-			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
 			],
 			"engines": {
-				"node": ">=12"
+				"node": ">=18"
 			}
 		},
 		"node_modules/@esbuild/linux-loong64": {
-			"version": "0.21.5",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
-			"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+			"version": "0.23.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz",
+			"integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==",
 			"cpu": [
 				"loong64"
 			],
 			"dev": true,
-			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
 			],
 			"engines": {
-				"node": ">=12"
+				"node": ">=18"
 			}
 		},
 		"node_modules/@esbuild/linux-mips64el": {
-			"version": "0.21.5",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
-			"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+			"version": "0.23.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz",
+			"integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==",
 			"cpu": [
 				"mips64el"
 			],
 			"dev": true,
-			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
 			],
 			"engines": {
-				"node": ">=12"
+				"node": ">=18"
 			}
 		},
 		"node_modules/@esbuild/linux-ppc64": {
-			"version": "0.21.5",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
-			"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+			"version": "0.23.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz",
+			"integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==",
 			"cpu": [
 				"ppc64"
 			],
 			"dev": true,
-			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
 			],
 			"engines": {
-				"node": ">=12"
+				"node": ">=18"
 			}
 		},
 		"node_modules/@esbuild/linux-riscv64": {
-			"version": "0.21.5",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
-			"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+			"version": "0.23.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz",
+			"integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==",
 			"cpu": [
 				"riscv64"
 			],
 			"dev": true,
-			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
 			],
 			"engines": {
-				"node": ">=12"
+				"node": ">=18"
 			}
 		},
 		"node_modules/@esbuild/linux-s390x": {
-			"version": "0.21.5",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
-			"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+			"version": "0.23.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz",
+			"integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==",
 			"cpu": [
 				"s390x"
 			],
 			"dev": true,
-			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
 			],
 			"engines": {
-				"node": ">=12"
+				"node": ">=18"
 			}
 		},
 		"node_modules/@esbuild/linux-x64": {
-			"version": "0.21.5",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
-			"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+			"version": "0.23.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz",
+			"integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
-			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
 			],
 			"engines": {
-				"node": ">=12"
+				"node": ">=18"
 			}
 		},
 		"node_modules/@esbuild/netbsd-x64": {
-			"version": "0.21.5",
-			"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
-			"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+			"version": "0.23.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz",
+			"integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
-			"license": "MIT",
 			"optional": true,
 			"os": [
 				"netbsd"
 			],
 			"engines": {
-				"node": ">=12"
+				"node": ">=18"
+			}
+		},
+		"node_modules/@esbuild/openbsd-arm64": {
+			"version": "0.23.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz",
+			"integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==",
+			"cpu": [
+				"arm64"
+			],
+			"dev": true,
+			"optional": true,
+			"os": [
+				"openbsd"
+			],
+			"engines": {
+				"node": ">=18"
 			}
 		},
 		"node_modules/@esbuild/openbsd-x64": {
-			"version": "0.21.5",
-			"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
-			"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+			"version": "0.23.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz",
+			"integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
-			"license": "MIT",
 			"optional": true,
 			"os": [
 				"openbsd"
 			],
 			"engines": {
-				"node": ">=12"
+				"node": ">=18"
 			}
 		},
 		"node_modules/@esbuild/sunos-x64": {
-			"version": "0.21.5",
-			"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
-			"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+			"version": "0.23.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz",
+			"integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
-			"license": "MIT",
 			"optional": true,
 			"os": [
 				"sunos"
 			],
 			"engines": {
-				"node": ">=12"
+				"node": ">=18"
 			}
 		},
 		"node_modules/@esbuild/win32-arm64": {
-			"version": "0.21.5",
-			"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
-			"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+			"version": "0.23.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz",
+			"integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
-			"license": "MIT",
 			"optional": true,
 			"os": [
 				"win32"
 			],
 			"engines": {
-				"node": ">=12"
+				"node": ">=18"
 			}
 		},
 		"node_modules/@esbuild/win32-ia32": {
-			"version": "0.21.5",
-			"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
-			"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+			"version": "0.23.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz",
+			"integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==",
 			"cpu": [
 				"ia32"
 			],
 			"dev": true,
-			"license": "MIT",
 			"optional": true,
 			"os": [
 				"win32"
 			],
 			"engines": {
-				"node": ">=12"
+				"node": ">=18"
 			}
 		},
 		"node_modules/@esbuild/win32-x64": {
-			"version": "0.21.5",
-			"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
-			"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+			"version": "0.23.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz",
+			"integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
-			"license": "MIT",
 			"optional": true,
 			"os": [
 				"win32"
 			],
 			"engines": {
-				"node": ">=12"
+				"node": ">=18"
 			}
 		},
 		"node_modules/@eslint-community/eslint-utils": {
@@ -3010,42 +3003,42 @@
 			}
 		},
 		"node_modules/esbuild": {
-			"version": "0.21.5",
-			"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
-			"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+			"version": "0.23.1",
+			"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz",
+			"integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==",
 			"dev": true,
 			"hasInstallScript": true,
-			"license": "MIT",
 			"bin": {
 				"esbuild": "bin/esbuild"
 			},
 			"engines": {
-				"node": ">=12"
+				"node": ">=18"
 			},
 			"optionalDependencies": {
-				"@esbuild/aix-ppc64": "0.21.5",
-				"@esbuild/android-arm": "0.21.5",
-				"@esbuild/android-arm64": "0.21.5",
-				"@esbuild/android-x64": "0.21.5",
-				"@esbuild/darwin-arm64": "0.21.5",
-				"@esbuild/darwin-x64": "0.21.5",
-				"@esbuild/freebsd-arm64": "0.21.5",
-				"@esbuild/freebsd-x64": "0.21.5",
-				"@esbuild/linux-arm": "0.21.5",
-				"@esbuild/linux-arm64": "0.21.5",
-				"@esbuild/linux-ia32": "0.21.5",
-				"@esbuild/linux-loong64": "0.21.5",
-				"@esbuild/linux-mips64el": "0.21.5",
-				"@esbuild/linux-ppc64": "0.21.5",
-				"@esbuild/linux-riscv64": "0.21.5",
-				"@esbuild/linux-s390x": "0.21.5",
-				"@esbuild/linux-x64": "0.21.5",
-				"@esbuild/netbsd-x64": "0.21.5",
-				"@esbuild/openbsd-x64": "0.21.5",
-				"@esbuild/sunos-x64": "0.21.5",
-				"@esbuild/win32-arm64": "0.21.5",
-				"@esbuild/win32-ia32": "0.21.5",
-				"@esbuild/win32-x64": "0.21.5"
+				"@esbuild/aix-ppc64": "0.23.1",
+				"@esbuild/android-arm": "0.23.1",
+				"@esbuild/android-arm64": "0.23.1",
+				"@esbuild/android-x64": "0.23.1",
+				"@esbuild/darwin-arm64": "0.23.1",
+				"@esbuild/darwin-x64": "0.23.1",
+				"@esbuild/freebsd-arm64": "0.23.1",
+				"@esbuild/freebsd-x64": "0.23.1",
+				"@esbuild/linux-arm": "0.23.1",
+				"@esbuild/linux-arm64": "0.23.1",
+				"@esbuild/linux-ia32": "0.23.1",
+				"@esbuild/linux-loong64": "0.23.1",
+				"@esbuild/linux-mips64el": "0.23.1",
+				"@esbuild/linux-ppc64": "0.23.1",
+				"@esbuild/linux-riscv64": "0.23.1",
+				"@esbuild/linux-s390x": "0.23.1",
+				"@esbuild/linux-x64": "0.23.1",
+				"@esbuild/netbsd-x64": "0.23.1",
+				"@esbuild/openbsd-arm64": "0.23.1",
+				"@esbuild/openbsd-x64": "0.23.1",
+				"@esbuild/sunos-x64": "0.23.1",
+				"@esbuild/win32-arm64": "0.23.1",
+				"@esbuild/win32-ia32": "0.23.1",
+				"@esbuild/win32-x64": "0.23.1"
 			}
 		},
 		"node_modules/escalade": {
@@ -7229,13 +7222,12 @@
 			"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
 		},
 		"node_modules/tsx": {
-			"version": "4.16.5",
-			"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.16.5.tgz",
-			"integrity": "sha512-ArsiAQHEW2iGaqZ8fTA1nX0a+lN5mNTyuGRRO6OW3H/Yno1y9/t1f9YOI1Cfoqz63VAthn++ZYcbDP7jPflc+A==",
+			"version": "4.17.0",
+			"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.17.0.tgz",
+			"integrity": "sha512-eN4mnDA5UMKDt4YZixo9tBioibaMBpoxBkD+rIPAjVmYERSG0/dWEY1CEFuV89CgASlKL499q8AhmkMnnjtOJg==",
 			"dev": true,
-			"license": "MIT",
 			"dependencies": {
-				"esbuild": "~0.21.5",
+				"esbuild": "~0.23.0",
 				"get-tsconfig": "^4.7.5"
 			},
 			"bin": {
diff --git a/package.json b/package.json
index cb645092..83f08aff 100644
--- a/package.json
+++ b/package.json
@@ -67,7 +67,7 @@
 		"stylelint-config-standard-scss": "^13.1.0",
 		"stylelint-scss": "^6.5.0",
 		"tailwindcss": "^3.4.10",
-		"tsx": "^4.16.5",
+		"tsx": "^4.17.0",
 		"typescript": "^5.5.4"
 	}
 }

From 6cb0461503c0c16331ac348247d088217ac1854a Mon Sep 17 00:00:00 2001
From: Julien Constant <julienconstant190@gmail.com>
Date: Mon, 19 Aug 2024 04:06:32 +0200
Subject: [PATCH 30/30] deps: update deps and dev-deps

---
 package-lock.json | 212 +++++++++++++++++++++++-----------------------
 package.json      |  24 +++---
 2 files changed, 120 insertions(+), 116 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index 5260987c..9459728b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,14 +12,14 @@
 				"@auth/prisma-adapter": "^2.4.2",
 				"@hookform/resolvers": "^3.9.0",
 				"@ltd/j-toml": "^1.38.0",
-				"@mantine/carousel": "^7.12.0",
-				"@mantine/core": "^7.12.0",
-				"@mantine/dates": "^7.12.0",
-				"@mantine/dropzone": "^7.12.0",
-				"@mantine/form": "^7.12.0",
-				"@mantine/hooks": "^7.12.0",
-				"@mantine/modals": "^7.12.0",
-				"@mantine/notifications": "^7.12.0",
+				"@mantine/carousel": "^7.12.1",
+				"@mantine/core": "^7.12.1",
+				"@mantine/dates": "^7.12.1",
+				"@mantine/dropzone": "^7.12.1",
+				"@mantine/form": "^7.12.1",
+				"@mantine/hooks": "^7.12.1",
+				"@mantine/modals": "^7.12.1",
+				"@mantine/notifications": "^7.12.1",
 				"@octokit/rest": "^21.0.2",
 				"@types/unzipper": "^0.10.9",
 				"class-variance-authority": "^0.7.0",
@@ -32,13 +32,13 @@
 				"react-compare-slider": "^3.1.0",
 				"react-dom": "^18",
 				"react-hook-form": "^7.52.2",
-				"react-icons": "^5.2.1",
+				"react-icons": "^5.3.0",
 				"react-spinners": "^0.14.1",
 				"sass": "^1.77.8",
 				"server-only": "^0.0.1",
 				"socket.io": "^4.7.5",
 				"socket.io-client": "^4.7.5",
-				"tailwind-merge": "^2.4.0",
+				"tailwind-merge": "^2.5.2",
 				"tailwindcss-animate": "^1.0.7",
 				"unzipper": "^0.12.1",
 				"uuid": "^10.0.0",
@@ -46,7 +46,7 @@
 			},
 			"devDependencies": {
 				"@prisma/client": "^5.18.0",
-				"@types/node": "^20.14.14",
+				"@types/node": "^20.16.0",
 				"@types/react": "^18.3.3",
 				"@types/react-dom": "^18",
 				"@types/uuid": "^10.0.0",
@@ -60,7 +60,7 @@
 				"postcss-simple-vars": "^7.0.1",
 				"prisma": "^5.18.0",
 				"prisma-json-types-generator": "^3.0.4",
-				"stylelint": "^16.8.1",
+				"stylelint": "^16.8.2",
 				"stylelint-config-standard-scss": "^13.1.0",
 				"stylelint-scss": "^6.5.0",
 				"tailwindcss": "^3.4.10",
@@ -282,9 +282,9 @@
 			}
 		},
 		"node_modules/@csstools/css-parser-algorithms": {
-			"version": "2.7.1",
-			"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-2.7.1.tgz",
-			"integrity": "sha512-2SJS42gxmACHgikc1WGesXLIT8d/q2l0UFM7TaEeIzdFCE/FPMtTiizcPGGJtlPo2xuQzY09OhrLTzRxqJqwGw==",
+			"version": "3.0.1",
+			"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.1.tgz",
+			"integrity": "sha512-lSquqZCHxDfuTg/Sk2hiS0mcSFCEBuj49JfzPHJogDBT0mGCyY5A1AQzBWngitrp7i1/HAZpIgzF/VjhOEIJIg==",
 			"dev": true,
 			"funding": [
 				{
@@ -298,16 +298,16 @@
 			],
 			"license": "MIT",
 			"engines": {
-				"node": "^14 || ^16 || >=18"
+				"node": ">=18"
 			},
 			"peerDependencies": {
-				"@csstools/css-tokenizer": "^2.4.1"
+				"@csstools/css-tokenizer": "^3.0.1"
 			}
 		},
 		"node_modules/@csstools/css-tokenizer": {
-			"version": "2.4.1",
-			"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-2.4.1.tgz",
-			"integrity": "sha512-eQ9DIktFJBhGjioABJRtUucoWR2mwllurfnM8LuNGAqX3ViZXaUchqk+1s7jjtkFiT9ySdACsFEA3etErkALUg==",
+			"version": "3.0.1",
+			"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.1.tgz",
+			"integrity": "sha512-UBqaiu7kU0lfvaP982/o3khfXccVlHPWp0/vwwiIgDF0GmqqqxoiXC/6FCjlS9u92f7CoEz6nXKQnrn1kIAkOw==",
 			"dev": true,
 			"funding": [
 				{
@@ -321,13 +321,13 @@
 			],
 			"license": "MIT",
 			"engines": {
-				"node": "^14 || ^16 || >=18"
+				"node": ">=18"
 			}
 		},
 		"node_modules/@csstools/media-query-list-parser": {
-			"version": "2.1.13",
-			"resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-2.1.13.tgz",
-			"integrity": "sha512-XaHr+16KRU9Gf8XLi3q8kDlI18d5vzKSKCY510Vrtc9iNR0NJzbY9hhTmwhzYZj/ZwGL4VmB3TA9hJW0Um2qFA==",
+			"version": "3.0.1",
+			"resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-3.0.1.tgz",
+			"integrity": "sha512-HNo8gGD02kHmcbX6PvCoUuOQvn4szyB9ca63vZHKX5A81QytgDG4oxG4IaEfHTlEZSZ6MjPEMWIVU+zF2PZcgw==",
 			"dev": true,
 			"funding": [
 				{
@@ -341,17 +341,17 @@
 			],
 			"license": "MIT",
 			"engines": {
-				"node": "^14 || ^16 || >=18"
+				"node": ">=18"
 			},
 			"peerDependencies": {
-				"@csstools/css-parser-algorithms": "^2.7.1",
-				"@csstools/css-tokenizer": "^2.4.1"
+				"@csstools/css-parser-algorithms": "^3.0.1",
+				"@csstools/css-tokenizer": "^3.0.1"
 			}
 		},
 		"node_modules/@csstools/selector-specificity": {
-			"version": "3.1.1",
-			"resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-3.1.1.tgz",
-			"integrity": "sha512-a7cxGcJ2wIlMFLlh8z2ONm+715QkPHiyJcxwQlKOz/03GPw1COpfhcmC9wm4xlZfp//jWHNNMwzjtqHXVWU9KA==",
+			"version": "4.0.0",
+			"resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-4.0.0.tgz",
+			"integrity": "sha512-189nelqtPd8++phaHNwYovKZI0FOzH1vQEE3QhHHkNIGrg5fSs9CbYP3RvfEH5geztnIA9Jwq91wyOIwAW5JIQ==",
 			"dev": true,
 			"funding": [
 				{
@@ -363,11 +363,12 @@
 					"url": "https://opencollective.com/csstools"
 				}
 			],
+			"license": "MIT-0",
 			"engines": {
-				"node": "^14 || ^16 || >=18"
+				"node": ">=18"
 			},
 			"peerDependencies": {
-				"postcss-selector-parser": "^6.0.13"
+				"postcss-selector-parser": "^6.1.0"
 			}
 		},
 		"node_modules/@dual-bundle/import-meta-resolve": {
@@ -1001,22 +1002,22 @@
 			"license": "LGPL-3.0"
 		},
 		"node_modules/@mantine/carousel": {
-			"version": "7.12.0",
-			"resolved": "https://registry.npmjs.org/@mantine/carousel/-/carousel-7.12.0.tgz",
-			"integrity": "sha512-c+IaeDAHR77E8jbgFWkjN4cA0E2c9fIcvPGZnFNZ7NUFQjuQmaOdNcLqid2FOk+ay889eN+6PGuYmSgW58amCQ==",
+			"version": "7.12.1",
+			"resolved": "https://registry.npmjs.org/@mantine/carousel/-/carousel-7.12.1.tgz",
+			"integrity": "sha512-dnzd5kJvObjrW3S0N85Pua7sVuB1A47j96FXW/GlV0OnbnMfpZv6aO29Vi9YoqlGzius8rsDzbXmL725xcLPmQ==",
 			"license": "MIT",
 			"peerDependencies": {
-				"@mantine/core": "7.12.0",
-				"@mantine/hooks": "7.12.0",
+				"@mantine/core": "7.12.1",
+				"@mantine/hooks": "7.12.1",
 				"embla-carousel-react": ">=7.0.0",
 				"react": "^18.2.0",
 				"react-dom": "^18.2.0"
 			}
 		},
 		"node_modules/@mantine/core": {
-			"version": "7.12.0",
-			"resolved": "https://registry.npmjs.org/@mantine/core/-/core-7.12.0.tgz",
-			"integrity": "sha512-FxsaIaEnqxV71MBGGsvXXad2q9KYTaIQFVP4TSAZI6xLChklXF/qJTqvabweaoW9BaVQT75b/BnUoJFzPfyAfw==",
+			"version": "7.12.1",
+			"resolved": "https://registry.npmjs.org/@mantine/core/-/core-7.12.1.tgz",
+			"integrity": "sha512-PXKIDaT1fpNB77dPQIcdFGM2NRnfmsJSVx3uuBccngBQWMIWI0wPyiO1Y26DK4LQrbrypeb+TS+Zxpgx6RoiCA==",
 			"license": "MIT",
 			"dependencies": {
 				"@floating-ui/react": "^0.26.9",
@@ -1027,46 +1028,46 @@
 				"type-fest": "^4.12.0"
 			},
 			"peerDependencies": {
-				"@mantine/hooks": "7.12.0",
+				"@mantine/hooks": "7.12.1",
 				"react": "^18.2.0",
 				"react-dom": "^18.2.0"
 			}
 		},
 		"node_modules/@mantine/dates": {
-			"version": "7.12.0",
-			"resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-7.12.0.tgz",
-			"integrity": "sha512-68oDcDV+FnhQK90J9vFtO872rT303nGwR4DpAQqFAzdNBWxc3h5089/S+rehYryH4Pcwru4t0FqSB4fRvlUtLw==",
+			"version": "7.12.1",
+			"resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-7.12.1.tgz",
+			"integrity": "sha512-+Dg5ZGoYPWYRWPY7HagLeW36ayVjKQIkTpdNvgGDwh5YpaFy5cHd6LK6USKUshTsRPuzM3oUKwXIBK8hsigMyA==",
 			"license": "MIT",
 			"dependencies": {
 				"clsx": "^2.1.1"
 			},
 			"peerDependencies": {
-				"@mantine/core": "7.12.0",
-				"@mantine/hooks": "7.12.0",
+				"@mantine/core": "7.12.1",
+				"@mantine/hooks": "7.12.1",
 				"dayjs": ">=1.0.0",
 				"react": "^18.2.0",
 				"react-dom": "^18.2.0"
 			}
 		},
 		"node_modules/@mantine/dropzone": {
-			"version": "7.12.0",
-			"resolved": "https://registry.npmjs.org/@mantine/dropzone/-/dropzone-7.12.0.tgz",
-			"integrity": "sha512-emj9D2fCaeLmrBPE15dAZ31tI0hDENMN3Emz1xqMqmR/5xuzsVSTiZRJxIARsHn2Muiva3jLdRLFdW9A8YB3FA==",
+			"version": "7.12.1",
+			"resolved": "https://registry.npmjs.org/@mantine/dropzone/-/dropzone-7.12.1.tgz",
+			"integrity": "sha512-IuAdCnl6PDtkDnGp4vQlHgxr9z3R7s0685khVKpxy/3f+XfdoswUBBY3X7XyirpDXMIjMD4SLpkIzwuUXgZsag==",
 			"license": "MIT",
 			"dependencies": {
 				"react-dropzone-esm": "15.0.1"
 			},
 			"peerDependencies": {
-				"@mantine/core": "7.12.0",
-				"@mantine/hooks": "7.12.0",
+				"@mantine/core": "7.12.1",
+				"@mantine/hooks": "7.12.1",
 				"react": "^18.2.0",
 				"react-dom": "^18.2.0"
 			}
 		},
 		"node_modules/@mantine/form": {
-			"version": "7.12.0",
-			"resolved": "https://registry.npmjs.org/@mantine/form/-/form-7.12.0.tgz",
-			"integrity": "sha512-npNHxjis/tOun12EYPYP9cQwJbtFHcGZF1m2yNCcNFVMdkBtTiqH23DdGByXmJRkypYQssSMdQTm3F1zfGsjdQ==",
+			"version": "7.12.1",
+			"resolved": "https://registry.npmjs.org/@mantine/form/-/form-7.12.1.tgz",
+			"integrity": "sha512-Q+lpgG9N8srlsI0IPnD1V1c2ZaI0xmR3bBEVm+LttSos6Q5zkG49Yy011mc0cXzEKUk2h48j8PLoPHfSEzO03g==",
 			"license": "MIT",
 			"dependencies": {
 				"fast-deep-equal": "^3.1.3",
@@ -1077,46 +1078,46 @@
 			}
 		},
 		"node_modules/@mantine/hooks": {
-			"version": "7.12.0",
-			"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-7.12.0.tgz",
-			"integrity": "sha512-UKMSpQZMdmecZX1PKPoknfUOE9MfDPiZR1myU4wUUKpaZibvvmhYuy8mcOOmYWegapRS3ErKIAc2cNnJ1Dk4RQ==",
+			"version": "7.12.1",
+			"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-7.12.1.tgz",
+			"integrity": "sha512-YPA3qiMHJkWID5+YzakBaLvjHtX3Fg3PdPY49iIb/CaWM9+lrJ+77TOVS7bsY7ZTBHXUfzft1/6Woqt3xSuweA==",
 			"license": "MIT",
 			"peerDependencies": {
 				"react": "^18.2.0"
 			}
 		},
 		"node_modules/@mantine/modals": {
-			"version": "7.12.0",
-			"resolved": "https://registry.npmjs.org/@mantine/modals/-/modals-7.12.0.tgz",
-			"integrity": "sha512-CXt2nUK0VuWc+cwC1flCeH5FnQYjA8iQfGgZ37wSFv2qxzJFQ61QlRJjdgIG7T+DccUHjqXKkjYohLxXE36EQQ==",
+			"version": "7.12.1",
+			"resolved": "https://registry.npmjs.org/@mantine/modals/-/modals-7.12.1.tgz",
+			"integrity": "sha512-olS07yDcCFLGylLGaQgBiTnKcRrUZVLKqBFBw5glcmc/wZmJf4SDMgx5mxSwBnsbJOwJ2d3aIYwO/qNTNnluSg==",
 			"license": "MIT",
 			"peerDependencies": {
-				"@mantine/core": "7.12.0",
-				"@mantine/hooks": "7.12.0",
+				"@mantine/core": "7.12.1",
+				"@mantine/hooks": "7.12.1",
 				"react": "^18.2.0",
 				"react-dom": "^18.2.0"
 			}
 		},
 		"node_modules/@mantine/notifications": {
-			"version": "7.12.0",
-			"resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-7.12.0.tgz",
-			"integrity": "sha512-eW2g66b1K/EUdHD842QnQHWdKWbk1mCJkzDAyxcMGZ2BqU2zzpTUZdexbfDg2BqE/Mj/BGc3B9r2mKHt/6ebBg==",
+			"version": "7.12.1",
+			"resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-7.12.1.tgz",
+			"integrity": "sha512-YIV2ItCRJzbOjEyXtz5Rjf3qn6kwmcz6CqAGurpd+kecxx6wwNoKuKs6YNlz7tcprFegcH/hCUkW2tVbXHKVBA==",
 			"license": "MIT",
 			"dependencies": {
-				"@mantine/store": "7.12.0",
+				"@mantine/store": "7.12.1",
 				"react-transition-group": "4.4.5"
 			},
 			"peerDependencies": {
-				"@mantine/core": "7.12.0",
-				"@mantine/hooks": "7.12.0",
+				"@mantine/core": "7.12.1",
+				"@mantine/hooks": "7.12.1",
 				"react": "^18.2.0",
 				"react-dom": "^18.2.0"
 			}
 		},
 		"node_modules/@mantine/store": {
-			"version": "7.12.0",
-			"resolved": "https://registry.npmjs.org/@mantine/store/-/store-7.12.0.tgz",
-			"integrity": "sha512-gKOJQVKTxJQbjhG/qlaLiv47ydHgdN+ZC2jFRJHr1jjNeiCqzIT4wX1ofG27c5byPTAwAHvuf+/FLOV3rywUpA==",
+			"version": "7.12.1",
+			"resolved": "https://registry.npmjs.org/@mantine/store/-/store-7.12.1.tgz",
+			"integrity": "sha512-zIzYEheEyXchPTNKsm88BJ0CTEZV6ZNwMhMDWHKQE3CzjKLJdKHJdIBcZImRU3Pn4GROZdZdIkQF9HLJ6BjvYw==",
 			"license": "MIT",
 			"peerDependencies": {
 				"react": "^18.2.0"
@@ -1616,12 +1617,12 @@
 			"dev": true
 		},
 		"node_modules/@types/node": {
-			"version": "20.14.14",
-			"resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.14.tgz",
-			"integrity": "sha512-d64f00982fS9YoOgJkAMolK7MN8Iq3TDdVjchbYHdEmjth/DHowx82GnoA+tVUAN+7vxfYUgAzi+JXbKNd2SDQ==",
+			"version": "20.16.0",
+			"resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.0.tgz",
+			"integrity": "sha512-vDxceJcoZhIVh67S568bm1UGZO0DX0hpplJZxzeXMKwIPLn190ec5RRxQ69BKhX44SUGIxxgMdDY557lGLKprQ==",
 			"license": "MIT",
 			"dependencies": {
-				"undici-types": "~5.26.4"
+				"undici-types": "~6.19.2"
 			}
 		},
 		"node_modules/@types/prop-types": {
@@ -4082,10 +4083,11 @@
 			}
 		},
 		"node_modules/ignore": {
-			"version": "5.3.1",
-			"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz",
-			"integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==",
+			"version": "5.3.2",
+			"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+			"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">= 4"
 			}
@@ -5611,9 +5613,9 @@
 			}
 		},
 		"node_modules/postcss-resolve-nested-selector": {
-			"version": "0.1.4",
-			"resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.4.tgz",
-			"integrity": "sha512-R6vHqZWgVnTAPq0C+xjyHfEZqfIYboCBVSy24MjxEDm+tIh1BU4O6o7DP7AA7kHzf136d+Qc5duI4tlpHjixDw==",
+			"version": "0.1.6",
+			"resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.6.tgz",
+			"integrity": "sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==",
 			"dev": true,
 			"license": "MIT"
 		},
@@ -5670,9 +5672,9 @@
 			}
 		},
 		"node_modules/postcss-selector-parser": {
-			"version": "6.1.1",
-			"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.1.tgz",
-			"integrity": "sha512-b4dlw/9V8A71rLIDsSwVmak9z2DuBUB7CA1/wSdelNEzqsjoSPeADTWNO09lpH49Diy3/JIZ2bSPB1dI3LJCHg==",
+			"version": "6.1.2",
+			"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
+			"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
 			"license": "MIT",
 			"dependencies": {
 				"cssesc": "^3.0.0",
@@ -5889,9 +5891,10 @@
 			}
 		},
 		"node_modules/react-icons": {
-			"version": "5.2.1",
-			"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.2.1.tgz",
-			"integrity": "sha512-zdbW5GstTzXaVKvGSyTaBalt7HSfuK5ovrzlpyiWHAFXndXTdd/1hdDHI4xBM1Mn7YriT6aqESucFl9kEXzrdw==",
+			"version": "5.3.0",
+			"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.3.0.tgz",
+			"integrity": "sha512-DnUk8aFbTyQPSkCfF8dbX6kQjXA9DktMeJqfjrg6cK9vwQVMxmcA3BfP4QoiztVmEHtwlTgLFsPuH2NskKT6eg==",
+			"license": "MIT",
 			"peerDependencies": {
 				"react": "*"
 			}
@@ -6707,9 +6710,9 @@
 			}
 		},
 		"node_modules/stylelint": {
-			"version": "16.8.1",
-			"resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.8.1.tgz",
-			"integrity": "sha512-O8aDyfdODSDNz/B3gW2HQ+8kv8pfhSu7ZR7xskQ93+vI6FhKKGUJMQ03Ydu+w3OvXXE0/u4hWU4hCPNOyld+OA==",
+			"version": "16.8.2",
+			"resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.8.2.tgz",
+			"integrity": "sha512-fInKATippQhcSm7AB+T32GpI+626yohrg33GkFT/5jzliUw5qhlwZq2UQQwgl3HsHrf09oeARi0ZwgY/UWEv9A==",
 			"dev": true,
 			"funding": [
 				{
@@ -6723,10 +6726,10 @@
 			],
 			"license": "MIT",
 			"dependencies": {
-				"@csstools/css-parser-algorithms": "^2.7.1",
-				"@csstools/css-tokenizer": "^2.4.1",
-				"@csstools/media-query-list-parser": "^2.1.13",
-				"@csstools/selector-specificity": "^3.1.1",
+				"@csstools/css-parser-algorithms": "^3.0.0",
+				"@csstools/css-tokenizer": "^3.0.0",
+				"@csstools/media-query-list-parser": "^3.0.0",
+				"@csstools/selector-specificity": "^4.0.0",
 				"@dual-bundle/import-meta-resolve": "^4.1.0",
 				"balanced-match": "^2.0.0",
 				"colord": "^2.9.3",
@@ -6741,7 +6744,7 @@
 				"globby": "^11.1.0",
 				"globjoin": "^0.1.4",
 				"html-tags": "^3.3.1",
-				"ignore": "^5.3.1",
+				"ignore": "^5.3.2",
 				"imurmurhash": "^0.1.4",
 				"is-plain-object": "^5.0.0",
 				"known-css-properties": "^0.34.0",
@@ -6750,10 +6753,10 @@
 				"micromatch": "^4.0.7",
 				"normalize-path": "^3.0.0",
 				"picocolors": "^1.0.1",
-				"postcss": "^8.4.40",
-				"postcss-resolve-nested-selector": "^0.1.4",
+				"postcss": "^8.4.41",
+				"postcss-resolve-nested-selector": "^0.1.6",
 				"postcss-safe-parser": "^7.0.0",
-				"postcss-selector-parser": "^6.1.1",
+				"postcss-selector-parser": "^6.1.2",
 				"postcss-value-parser": "^4.2.0",
 				"resolve-from": "^5.0.0",
 				"string-width": "^4.2.3",
@@ -7089,9 +7092,9 @@
 			}
 		},
 		"node_modules/tailwind-merge": {
-			"version": "2.4.0",
-			"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.4.0.tgz",
-			"integrity": "sha512-49AwoOQNKdqKPd9CViyH5wJoSKsCDjUlzL8DxuGp3P1FsGY36NJDAa18jLZcaHAUUuTj+JB8IAo8zWgBNvBF7A==",
+			"version": "2.5.2",
+			"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.5.2.tgz",
+			"integrity": "sha512-kjEBm+pvD+6eAwzJL2Bi+02/9LFLal1Gs61+QB7HvTfQQ0aXwC5LGT8PEt1gS0CWKktKe6ysPTAy3cBC5MeiIg==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
@@ -7366,9 +7369,10 @@
 			}
 		},
 		"node_modules/undici-types": {
-			"version": "5.26.5",
-			"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
-			"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="
+			"version": "6.19.6",
+			"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.6.tgz",
+			"integrity": "sha512-e/vggGopEfTKSvj4ihnOLTsqhrKRN3LeO6qSN/GxohhuRv8qH9bNQ4B8W7e/vFL+0XTnmHPB4/kegunZGA4Org==",
+			"license": "MIT"
 		},
 		"node_modules/universal-user-agent": {
 			"version": "7.0.2",
diff --git a/package.json b/package.json
index 83f08aff..b55d519d 100644
--- a/package.json
+++ b/package.json
@@ -15,14 +15,14 @@
 		"@auth/prisma-adapter": "^2.4.2",
 		"@hookform/resolvers": "^3.9.0",
 		"@ltd/j-toml": "^1.38.0",
-		"@mantine/carousel": "^7.12.0",
-		"@mantine/core": "^7.12.0",
-		"@mantine/dates": "^7.12.0",
-		"@mantine/dropzone": "^7.12.0",
-		"@mantine/form": "^7.12.0",
-		"@mantine/hooks": "^7.12.0",
-		"@mantine/modals": "^7.12.0",
-		"@mantine/notifications": "^7.12.0",
+		"@mantine/carousel": "^7.12.1",
+		"@mantine/core": "^7.12.1",
+		"@mantine/dates": "^7.12.1",
+		"@mantine/dropzone": "^7.12.1",
+		"@mantine/form": "^7.12.1",
+		"@mantine/hooks": "^7.12.1",
+		"@mantine/modals": "^7.12.1",
+		"@mantine/notifications": "^7.12.1",
 		"@octokit/rest": "^21.0.2",
 		"@types/unzipper": "^0.10.9",
 		"class-variance-authority": "^0.7.0",
@@ -35,13 +35,13 @@
 		"react-compare-slider": "^3.1.0",
 		"react-dom": "^18",
 		"react-hook-form": "^7.52.2",
-		"react-icons": "^5.2.1",
+		"react-icons": "^5.3.0",
 		"react-spinners": "^0.14.1",
 		"sass": "^1.77.8",
 		"server-only": "^0.0.1",
 		"socket.io": "^4.7.5",
 		"socket.io-client": "^4.7.5",
-		"tailwind-merge": "^2.4.0",
+		"tailwind-merge": "^2.5.2",
 		"tailwindcss-animate": "^1.0.7",
 		"unzipper": "^0.12.1",
 		"uuid": "^10.0.0",
@@ -49,7 +49,7 @@
 	},
 	"devDependencies": {
 		"@prisma/client": "^5.18.0",
-		"@types/node": "^20.14.14",
+		"@types/node": "^20.16.0",
 		"@types/react": "^18.3.3",
 		"@types/react-dom": "^18",
 		"@types/uuid": "^10.0.0",
@@ -63,7 +63,7 @@
 		"postcss-simple-vars": "^7.0.1",
 		"prisma": "^5.18.0",
 		"prisma-json-types-generator": "^3.0.4",
-		"stylelint": "^16.8.1",
+		"stylelint": "^16.8.2",
 		"stylelint-config-standard-scss": "^13.1.0",
 		"stylelint-scss": "^6.5.0",
 		"tailwindcss": "^3.4.10",