diff --git a/web/.gitignore b/web/.gitignore
new file mode 100644
index 0000000..70c525e
--- /dev/null
+++ b/web/.gitignore
@@ -0,0 +1,42 @@
+bun.lockb
+
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# env files (can opt-in for committing if needed)
+.env*
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
diff --git a/web/README.md b/web/README.md
new file mode 100644
index 0000000..e69de29
diff --git a/web/next.config.ts b/web/next.config.ts
new file mode 100644
index 0000000..e9ffa30
--- /dev/null
+++ b/web/next.config.ts
@@ -0,0 +1,7 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+ /* config options here */
+};
+
+export default nextConfig;
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..19f1e50
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "bhop-website",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint"
+ },
+ "dependencies": {
+ "@emotion/react": "^11.14.0",
+ "@emotion/styled": "^11.14.0",
+ "@mui/icons-material": "^6.1.10",
+ "@mui/material": "^6.1.10",
+ "next": "15.0.4",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "sass": "^1.82.0"
+ },
+ "devDependencies": {
+ "@types/node": "^20.17.9",
+ "@types/react": "^19.0.1",
+ "@types/react-dom": "^19.0.2",
+ "typescript": "^5.7.2"
+ }
+}
diff --git a/web/src/app/_components/header.tsx b/web/src/app/_components/header.tsx
new file mode 100644
index 0000000..9996ffd
--- /dev/null
+++ b/web/src/app/_components/header.tsx
@@ -0,0 +1,31 @@
+import Link from "next/link"
+
+import "./styles/header.scss"
+
+interface HeaderButton {
+ name: string,
+ href: string
+}
+function HeaderButton(header: HeaderButton) {
+ return (
+
+
+
+ )
+}
+
+export default function Header() {
+ return (
+
+ )
+}
\ No newline at end of file
diff --git a/web/src/app/_components/styles/header.scss b/web/src/app/_components/styles/header.scss
new file mode 100644
index 0000000..3e1d248
--- /dev/null
+++ b/web/src/app/_components/styles/header.scss
@@ -0,0 +1,38 @@
+.header-bar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ width: 100vw;
+ height: 60px;
+ background: rgb(59,64,70);
+ background: linear-gradient(180deg, #363b40 0%, #353a40 100%);
+
+ button {
+ background-color: transparent;
+ border: 0;
+ color: white;
+ }
+
+ .left {
+ margin-left: 15px;
+
+ button {
+ font-size: 1.2rem;
+ }
+ }
+
+ .right {
+ display: flex;
+ gap: 7px;
+ margin-right: 50px;
+
+ button {
+ font-size: 1rem;
+ color: rgb(180,180,180);
+
+ &:hover {
+ color: white
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/web/src/app/globals.scss b/web/src/app/globals.scss
new file mode 100644
index 0000000..fdebe0a
--- /dev/null
+++ b/web/src/app/globals.scss
@@ -0,0 +1,26 @@
+$review-border-color: #c8c8c8;
+$review-border: 1px solid $review-border-color;
+
+@mixin border-with-radius {
+ border: $review-border {
+ radius: 5px;
+ }
+}
+
+:root {
+ color-scheme: light;
+}
+
+body {
+ font-family: -apple-system, "Segoe UI", system-ui, Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", "Twemoji Mozilla";
+ box-sizing: border-box;
+ margin: 0;
+}
+
+button {
+ cursor: pointer;
+}
+
+a:active, a:link, a:hover {
+ text-decoration: none;
+}
\ No newline at end of file
diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx
new file mode 100644
index 0000000..d524fd0
--- /dev/null
+++ b/web/src/app/layout.tsx
@@ -0,0 +1,9 @@
+import "./globals.scss";
+
+export default function RootLayout({children}: Readonly<{children: React.ReactNode}>) {
+ return (
+
+
{children}
+
+ );
+}
\ No newline at end of file
diff --git a/web/src/app/map/[map_name]/layout.tsx b/web/src/app/map/[map_name]/layout.tsx
new file mode 100644
index 0000000..e4b91b3
--- /dev/null
+++ b/web/src/app/map/[map_name]/layout.tsx
@@ -0,0 +1,34 @@
+import { SubmissionStatus } from "@/app/ts/Submission";
+import MapInfoPage from "./page";
+import Header from "@/app/_components/header";
+
+import "./styles/layout.scss";
+
+export default function MapPage() {
+ const placeholder_Comment = [
+ {
+ comment: "This map has been accepted and is in the game.",
+ date: "on Dec 8 '24 at 18:46",
+ name: "BhopMaptest"
+ },
+ {
+ comment: "This map is so mid...",
+ date: "on Dec 8 '24 at 18:46",
+ name: "vmsize"
+ },
+ {
+ comment: "I prefer strafe client",
+ date: "on Dec 8 '24 at 18:46",
+ name: "Quaternions"
+ }
+ ]
+
+ return (
+ <>
+
+
+
+
+ >
+ )
+}
\ No newline at end of file
diff --git a/web/src/app/map/[map_name]/page.tsx b/web/src/app/map/[map_name]/page.tsx
new file mode 100644
index 0000000..6aa58ac
--- /dev/null
+++ b/web/src/app/map/[map_name]/page.tsx
@@ -0,0 +1,132 @@
+import { SubmissionStatus, type SubmissionInfo } from "@/app/ts/Submission";
+import { useState, type ReactNode } from "react";
+import { Rating, Button } from "@mui/material";
+import { AssetImage } from "@/app/ts/Roblox";
+import SendIcon from '@mui/icons-material/Send';
+
+import "./styles/page.scss";
+
+interface Window {
+ className: string,
+ title: string,
+ children: ReactNode
+}
+function Window(window: Window) {
+ return (
+
+ )
+}
+
+interface AssetID {
+ id: SubmissionInfo["AssetID"]
+}
+async function ImageAndRatings(asset: AssetID) {
+ const [assetImage, setAssetImage] = useState(null);
+ const asset_image = await AssetImage(asset.id, "420x420")
+
+ return (
+
+ )
+}
+
+interface Comment {
+ picture?: string, //TEMP
+ comment: string,
+ date: string,
+ name: string
+}
+function Comment(comment: Comment) {
+ let placeHolder;
+ if (comment.name === "BhopMaptest") {
+ placeHolder = "https://tr.rbxcdn.com/30DAY-AvatarHeadshot-FB29ADF0A483B2745DB2571DC4785202-Png/150/150/AvatarHeadshot/Webp/noFilter"
+ } else if (comment.name === "vmsize") {
+ placeHolder = "https://tr.rbxcdn.com/30DAY-AvatarHeadshot-ACEB71FADC70B458ECB9D6AA9AAE5913-Png/150/150/AvatarHeadshot/Webp/noFilter"
+ } else {
+ placeHolder = "https://tr.rbxcdn.com/30DAY-AvatarHeadshot-1ED6D3ED61793733397BB596F0ADD369-Png/150/150/AvatarHeadshot/Webp/noFilter"
+ }
+
+ //Highlighted comment
+ const IsBhopMaptest = comment.name == "BhopMaptest"
+
+ return (
+
+
+
+
+ {comment.name}
+ {comment.date}
+
+
{comment.comment}
+
+
+ );
+}
+
+interface CreatorAndReviewStatus {
+ creator: SubmissionInfo["DisplayName"],
+ review: SubmissionInfo["StatusID"],
+ comments: Comment[]
+}
+function TitleAndComments(stats: CreatorAndReviewStatus) {
+ //TODO: switch case this for matching the enums
+ return (
+
+
+ by {stats.creator}
+
+
+ {stats.comments.map(comment => (
+
+ ))}
+
+
+
+
+
+
+ )
+}
+
+interface MapInfo {
+ assetid: SubmissionInfo["AssetID"],
+ status: SubmissionStatus,
+ comments: Comment[]
+}
+export default function MapInfoPage(info: MapInfo) {
+ return (
+
+ )
+}
\ No newline at end of file
diff --git a/web/src/app/map/[map_name]/styles/layout.scss b/web/src/app/map/[map_name]/styles/layout.scss
new file mode 100644
index 0000000..bcdb4bb
--- /dev/null
+++ b/web/src/app/map/[map_name]/styles/layout.scss
@@ -0,0 +1,5 @@
+.map-page-main {
+ display: flex;
+ justify-content: center;
+ width: 100vw;
+}
\ No newline at end of file
diff --git a/web/src/app/map/[map_name]/styles/page.scss b/web/src/app/map/[map_name]/styles/page.scss
new file mode 100644
index 0000000..e5fd458
--- /dev/null
+++ b/web/src/app/map/[map_name]/styles/page.scss
@@ -0,0 +1,50 @@
+@forward "./page/comments.scss";
+@forward "./page/review.scss";
+@forward "./page/rating_window.scss";
+@forward "./page/leave_comment_window.scss";
+@forward "./page/review_status.scss";
+
+@use "../../../globals.scss";
+
+.spacer {
+ display: block;
+ width: 100%;
+ height: 1px;
+ background-color: globals.$review-border-color;
+}
+
+.by-creator {
+ margin-top: 10px;
+}
+
+.review-info {
+ width: 650px;
+ height: 100%;
+
+ > div {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+
+ }
+
+ p, h1 {
+ color: #1e1e1e;
+ }
+
+ h1 {
+ font: {
+ weight: 500;
+ size: 1.8rem
+ };
+ margin: 0;
+ }
+
+ a {
+ color: #008fd6;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+}
\ No newline at end of file
diff --git a/web/src/app/map/[map_name]/styles/page/comments.scss b/web/src/app/map/[map_name]/styles/page/comments.scss
new file mode 100644
index 0000000..50c9fae
--- /dev/null
+++ b/web/src/app/map/[map_name]/styles/page/comments.scss
@@ -0,0 +1,47 @@
+$comments-size: 60px;
+
+.comments {
+ display: grid;
+ gap: 25px;
+ margin-top: 20px;
+
+ .commenter {
+ display: flex;
+ height: $comments-size;
+
+ //BhopMaptest comment
+ &[data-highlighted="true"] {
+ background-color: #ffffd7;
+ }
+
+ > img {
+ border-radius: 50%;
+ }
+
+ .name {
+ font: {
+ weight: 500;
+ size: 1.3em;
+ };
+ }
+
+ .date {
+ font-size: .8em;
+ margin: 0 0 0 5px;
+ color: #646464
+ }
+
+ .details {
+ display: grid;
+ margin-left: 10px;
+
+ header {
+ display: flex;
+ align-items: center;
+ }
+ p:not(.date) {
+ margin: 0;
+ }
+ }
+ }
+}
diff --git a/web/src/app/map/[map_name]/styles/page/leave_comment_window.scss b/web/src/app/map/[map_name]/styles/page/leave_comment_window.scss
new file mode 100644
index 0000000..bd3f755
--- /dev/null
+++ b/web/src/app/map/[map_name]/styles/page/leave_comment_window.scss
@@ -0,0 +1,55 @@
+@use "../../../../globals.scss";
+
+#comment-text-field {
+ @include globals.border-with-radius;
+ resize: none;
+ width: 100%;
+ height: 100px;
+}
+
+.leave-comment-window {
+ @include globals.border-with-radius;
+ width: 100%;
+ height: 230px;
+ margin-top: 35px;
+
+ .rating-type {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 100%;
+ gap: 35%;
+
+ .rating-right {
+ display: grid;
+
+ > span {
+ margin: 6px 0 6px 0;
+ }
+ }
+
+ p {
+ margin: 15px 0 15px 0;
+ }
+ }
+
+ header {
+ display: flex;
+ align-items: center;
+ background-color: #f5f5f5;
+ border-bottom: globals.$review-border;
+ height: 45px;
+
+ p {
+ font-weight: bold;
+ margin: 0 0 0 20px;
+ }
+ }
+ main {
+ padding: 20px;
+
+ button {
+ margin-top: 9px;
+ }
+ }
+}
\ No newline at end of file
diff --git a/web/src/app/map/[map_name]/styles/page/rating_window.scss b/web/src/app/map/[map_name]/styles/page/rating_window.scss
new file mode 100644
index 0000000..bcc9260
--- /dev/null
+++ b/web/src/app/map/[map_name]/styles/page/rating_window.scss
@@ -0,0 +1,44 @@
+@use "../../../../globals.scss";
+
+.rating-window {
+ @include globals.border-with-radius;
+ width: 100%;
+ height: 225px;
+
+ .rating-type {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 100%;
+ gap: 35%;
+
+ .rating-right {
+ display: grid;
+
+ > span {
+ margin: 6px 0 6px 0;
+ }
+ }
+
+ p {
+ margin: 15px 0 15px 0;
+ }
+ }
+
+ header {
+ display: flex;
+ align-items: center;
+ background-color: #f5f5f5;
+ border-bottom: globals.$review-border;
+ height: 45px;
+
+ p {
+ font-weight: bold;
+ margin: 0 0 0 20px;
+ }
+ }
+ main {
+ display: grid;
+ place-items: center;
+ }
+}
\ No newline at end of file
diff --git a/web/src/app/map/[map_name]/styles/page/review.scss b/web/src/app/map/[map_name]/styles/page/review.scss
new file mode 100644
index 0000000..d1b4096
--- /dev/null
+++ b/web/src/app/map/[map_name]/styles/page/review.scss
@@ -0,0 +1,20 @@
+@use "../../../../globals.scss";
+
+.review-section {
+ display: flex;
+ gap: 50px;
+ margin-top: 20px;
+}
+
+.review-area {
+ display: grid;
+ justify-content: center;
+ gap: 25px;
+
+ img {
+ @include globals.border-with-radius;
+ width: 100%;
+ height: 350px;
+ object-fit: contain
+ }
+}
\ No newline at end of file
diff --git a/web/src/app/map/[map_name]/styles/page/review_status.scss b/web/src/app/map/[map_name]/styles/page/review_status.scss
new file mode 100644
index 0000000..211c9af
--- /dev/null
+++ b/web/src/app/map/[map_name]/styles/page/review_status.scss
@@ -0,0 +1,73 @@
+$Published: "0";
+$Rejected: "1";
+$Publishing: "2";
+$Validated: "3";
+$Validating: "4";
+$Accepted: "5";
+$ChangesRequested: "6";
+$Submitted: "7";
+$UnderConstruction: "8";
+
+.review-status {
+ border-radius: 5px;
+
+ p {
+ margin: 3px 25px 3px 25px;
+ font-weight: bold;
+ }
+
+ &[data-review-status="#{$Published}"] {
+ background-color: orange;
+ p {
+ color: white;
+ }
+ }
+ &[data-review-status="#{$Rejected}"] {
+ background-color: orange;
+ p {
+ color: white;
+ }
+ }
+ &[data-review-status="#{$Publishing}"] {
+ background-color: orange;
+ p {
+ color: white;
+ }
+ }
+ &[data-review-status="#{$Validated}"] {
+ background-color: orange;
+ p {
+ color: white;
+ }
+ }
+ &[data-review-status="#{$Validating}"] {
+ background-color: orange;
+ p {
+ color: white;
+ }
+ }
+ &[data-review-status="#{$Accepted}"] {
+ background-color: rgb(2, 162, 2);
+ p {
+ color: white;
+ }
+ }
+ &[data-review-status="#{$ChangesRequested}"] {
+ background-color: orange;
+ p {
+ color: white;
+ }
+ }
+ &[data-review-status="#{$Submitted}"] {
+ background-color: orange;
+ p {
+ color: white;
+ }
+ }
+ &[data-review-status="#{$UnderConstruction}"] {
+ background-color: orange;
+ p {
+ color: white;
+ }
+ }
+}
\ No newline at end of file
diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx
new file mode 100644
index 0000000..c208b1c
--- /dev/null
+++ b/web/src/app/page.tsx
@@ -0,0 +1,7 @@
+import Header from "./_components/header";
+
+export default function Home() {
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/web/src/app/ts/Roblox.ts b/web/src/app/ts/Roblox.ts
new file mode 100644
index 0000000..b642dce
--- /dev/null
+++ b/web/src/app/ts/Roblox.ts
@@ -0,0 +1,39 @@
+const FALLBACK_IMAGE = ""
+
+type thumbsizes = "420" | "720"
+type thumbsize = `${S}x${S}`
+
+async function RoproxyParse(api: Response): Promise {
+ if (api.ok) {
+ const json = await api.json()
+ if (json.errors) {
+ console.warn(json.errors)
+ return FALLBACK_IMAGE
+ }
+ if (json.data) {
+ const data = json.data[0]
+ if (!data) { //For whatever reason roblox will sometimes return an empty array instead of an error message
+ console.warn("Roblox gave us no data,", json)
+ return FALLBACK_IMAGE
+ }
+ if (data.state === "Completed") {
+ return data.imageUrl
+ }
+ console.warn(data)
+ return FALLBACK_IMAGE
+ }
+ }
+
+ console.warn(api)
+ return FALLBACK_IMAGE
+}
+
+export async function AvatarHeadshot(userid: number, size: thumbsize): Promise {
+ const avatarthumb_api = await fetch(`https://thumbnails.roproxy.com/v1/users/avatar-headshot?userIds=${userid}&size=${size}&format=Png&isCircular=false`)
+ return RoproxyParse(avatarthumb_api)
+}
+
+export async function AssetImage(assetid: number, size: thumbsize): Promise {
+ const avatarthumb_api = await fetch(`https://thumbnails.roblox.com/v1/assets?assetIds=${assetid}&returnPolicy=PlaceHolder&size=${size}&format=Png&isCircular=false`)
+ return RoproxyParse(avatarthumb_api)
+}
\ No newline at end of file
diff --git a/web/src/app/ts/Submission.ts b/web/src/app/ts/Submission.ts
new file mode 100644
index 0000000..ee55717
--- /dev/null
+++ b/web/src/app/ts/Submission.ts
@@ -0,0 +1,25 @@
+export const enum SubmissionStatus {
+ Published,
+ Rejected,
+ Publishing,
+ Validated,
+ Validating,
+ Accepted,
+ ChangesRequested,
+ Submitted,
+ UnderConstruction
+}
+
+export interface SubmissionInfo {
+ readonly ID: number,
+ readonly DisplayName: string,
+ readonly Creator: string,
+ readonly GameID: number,
+ readonly Date: number,
+ readonly Submitter: number,
+ readonly AssetID: number,
+ readonly AssetVersion: number,
+ readonly Completed: boolean,
+ readonly TargetAssetID: number,
+ readonly StatusID: SubmissionStatus
+}
\ No newline at end of file
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..633783e
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,28 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "noImplicitAny": true,
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}