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 ( +
+
+

{window.title}

+
+
{window.children}
+
+ ) +} + +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}'s +
+
+

{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 ( +
+
+

bhop_quaternions

+ +
+

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"] +}