diff --git a/.dev.vars.example b/.dev.vars.example new file mode 100644 index 0000000..aea47a2 --- /dev/null +++ b/.dev.vars.example @@ -0,0 +1,25 @@ +# Direct Postgres connection, used by `next dev`/`next build` and as the +# Hyperdrive local connection in `wrangler dev` +DATABASE_URL=postgres://user:pass@pgbouncer-host:6432/left4craft +CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE=postgres://user:pass@pgbouncer-host:6432/left4craft + +# better-auth +BETTER_AUTH_SECRET=generate-with-openssl-rand-base64-32 + +# Discord OAuth (same app as before; callback path is unchanged: +# /api/auth/callback/discord — but add preview/localhost origins in Discord dev portal) +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= + +# Stripe (use test keys locally) +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +NEXT_PUBLIC_STRIPE_KEY= + +# SES (IAM user scoped to ses:SendEmail only) +SES_ACCESS_KEY_ID= +SES_SECRET_ACCESS_KEY= +SES_REGION=us-east-1 +EMAIL_FROM="Left4Craft " + +NEXT_PUBLIC_URL=http://localhost:3000 diff --git a/.env.gpg b/.env.gpg deleted file mode 100644 index 84938e6..0000000 Binary files a/.env.gpg and /dev/null differ diff --git a/.env.template b/.env.template deleted file mode 100644 index 593f033..0000000 --- a/.env.template +++ /dev/null @@ -1,28 +0,0 @@ -NEXT_PUBLIC_URL= - -NEXTAUTH_URL= -NEXTAUTH_SECRET= -JWT_SIGNING_PRIVATE_KEY= - -DYNAMODB_REGION= -DYNAMODB_ACCESS_KEY_ID= -DYNAMODB_ACCESS_KEY_SECRET= -DYNAMODB_NEXTAUTH_TABLE= -DYNAMODB_STRIPE_TABLE= - -DYNAMODB_STRIPE_EVENTS_TABLE= -SQS_QUEUE_URL= - -DYNAMODB_STRIPE_TRIALS_TABLE= - -DISCORD_CLIENT_ID= -DISCORD_CLIENT_SECRET= - -NEXT_PUBLIC_STRIPE_KEY= -STRIPE_SECRET_KEY= -STRIPE_WEBHOOK_SECRET= - -LITEBANS_API= -LITEBANS_API_KEY= - -NODE_ENV= diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index b80517d..0000000 --- a/.eslintrc.js +++ /dev/null @@ -1,198 +0,0 @@ -module.exports = { - 'env': { - 'browser': true, - 'commonjs': false, - 'es2021': true, - 'node': true - }, - 'extends': [ - 'eslint:recommended', - 'plugin:react/recommended', - 'plugin:@next/next/recommended' - ], - 'parserOptions': { - 'ecmaFeatures': { 'jsx': true }, - 'ecmaVersion': 12, - 'sourceType': 'module' - }, - 'plugins': [ - 'react' - ], - 'rules': { - 'array-bracket-newline': [ - 'error', - 'consistent' - ], - 'array-bracket-spacing': [ - 'error', - 'never' - ], - 'array-element-newline': [ - 'error', - 'always' - ], - 'arrow-body-style': [ - 'error', - 'as-needed' - ], - 'arrow-parens': [ - 'error', - 'as-needed' - ], - 'block-spacing': [ - 'error', - 'always' - ], - 'brace-style': [ - 'error', - '1tbs' - ], - 'comma-dangle': [ - 'error', - 'never' - ], - 'comma-spacing': [ - 'error', - { - 'after': true, - 'before': false - } - ], - 'comma-style': [ - 'error', - 'last' - ], - 'computed-property-spacing': [ - 'error', - 'never' - ], - 'curly': [ - 'error', - 'multi-line', // 'multi' - 'consistent' - ], - 'default-case-last': [ - 'error' - ], - 'dot-location': [ - 'error', - 'property' - ], - 'dot-notation': [ - 'error' - ], - 'eqeqeq': [ - 'error' - ], - 'func-call-spacing': [ - 'error', - 'never' - ], - 'indent': [ - 'error', - 'tab' - ], - 'linebreak-style': [ - 'off', - 'windows' - ], - 'max-depth': [ - 'warn', - { 'max': 5 } - ], - 'max-len': [ - 'warn', - { - 'code': 150, - 'ignoreRegExpLiterals': true, - 'ignoreStrings': true, - 'ignoreTemplateLiterals': true, - 'ignoreTrailingComments': true, - 'ignoreUrls': true - } - ], - 'max-lines': [ - 'warn', - { 'max': 500 } - ], - 'max-statements-per-line': [ - 'error' - ], - 'multiline-comment-style': 'off', - 'no-return-assign': [ - 'error' - ], - 'no-template-curly-in-string': [ - 'warn' - ], - 'no-trailing-spaces': [ - 'error' - ], - 'no-underscore-dangle': [ - 'error' - ], - 'no-unneeded-ternary': [ - 'error' - ], - 'no-var': [ - 'error' - ], - 'no-whitespace-before-property': [ - 'error' - ], - 'object-curly-newline': [ - 'error', - { - 'minProperties': 2, - 'multiline': true - } - ], - 'object-curly-spacing': [ - 'error', - 'always' - ], - 'object-property-newline': [ - 'error' - ], - 'operator-linebreak': [ - 'error' - ], - 'prefer-arrow-callback': [ - 'error' - ], - 'prefer-const': [ - 'error', - { - 'destructuring': 'all', - 'ignoreReadBeforeAssign': false - } - ], - 'quotes': [ - 'error', - 'single' - ], - 'react/react-in-jsx-scope': 'off', - 'rest-spread-spacing': [ - 'error', - 'never' - ], - 'semi': [ - 'error', - 'always' - ], - 'sort-keys': [ - 'error', - 'asc', - { 'natural': true } - ], - 'space-in-parens': [ - 'error', - 'never' - ], - 'spaced-comment': [ - 'error', - 'always' - ] - }, - 'settings': { 'react': { 'version': 'detect' } } -}; \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index dbde1dc..07ab950 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,7 +1,6 @@ name: Lint on: - # Trigger the workflow on push or pull request push: branches: - '*' @@ -10,30 +9,29 @@ on: - '*' jobs: - run-linters: - name: Run linters and build project + lint-and-build: + name: Lint and build project runs-on: ubuntu-latest steps: - name: Check out Git repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Set up Node.js - uses: actions/setup-node@v1 + uses: actions/setup-node@v4 with: - node-version: 23 + node-version: 22 + cache: npm - # ESLint and Prettier must be in `package.json` - name: Install Node.js dependencies - run: npm install + run: npm ci - - name: Run linters - uses: wearerequired/lint-action@v1 - with: - eslint: true + - name: Run Biome + run: npx biome ci . + # DATABASE_URL lets the punishments pages pre-render at build time. + # If it is unavailable the pages build empty and ISR fills them in at runtime. - name: Build project env: - LITEBANS_API: ${{ secrets.LITEBANS_API }} - LITEBANS_API_KEY: ${{ secrets.LITEBANS_API_KEY }} - run: npm run build \ No newline at end of file + DATABASE_URL: ${{ secrets.DATABASE_URL }} + run: npm run build diff --git a/.gitignore b/.gitignore index 85037d9..d3f7c87 100644 --- a/.gitignore +++ b/.gitignore @@ -35,10 +35,12 @@ yarn-error.log* # vercel .vercel -# serverless files -/.serverless/ -/.serverless_nextjs/ - -# aws environment setup stript -aws.sh +# cloudflare +/.open-next/ +/.wrangler/ +.dev.vars + +# one-off local artifacts from the AWS -> Cloudflare migration +zone.json +left4craft.org.zone diff --git a/README.md b/README.md index b12f3e3..a80f5a2 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,44 @@ -This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). +# left4web -## Getting Started +The [left4craft.org](https://www.left4craft.org) website: [Next.js](https://nextjs.org/) (pages router), deployed to +**Cloudflare Workers** via [OpenNext](https://opennext.js.org/cloudflare). -First, run the development server: +## Architecture -```bash -npm run dev -# or -yarn dev -``` - -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file. - -[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`. +- **Hosting**: Cloudflare Workers (`@opennextjs/cloudflare` + `wrangler`). ISR pages (`/shop`, `/punishments/*`) + are cached in the `left4web-inc-cache` R2 bucket. +- **Database**: PlanetScale Postgres, reached through pgbouncer via a **Hyperdrive** binding. Holds + better-auth tables, Stripe customer/trial/event tables, the `job_queue` table, and the LiteBans data. +- **Auth**: [better-auth](https://better-auth.com) — Discord OAuth + email magic links (sent with SES v2 over + HTTP via `aws4fetch`). Routes live under `/api/auth/*`. +- **Shop**: Stripe Checkout. The webhook (`/api/stripe/webhook`) records events idempotently in + `stripe_events` and enqueues rank commands into `job_queue`, which the Left4Hub plugin polls with + `FOR UPDATE SKIP LOCKED`. +- **Punishments**: LiteBans tables queried directly (`utils/litebans.js`); `/api/punishments/check` + serves the client-side ban search. -The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. +## Development -## Learn More +```bash +cp .dev.vars.example .dev.vars # fill in secrets +npm install +npm run dev # next dev with wrangler's local binding proxy +``` -To learn more about Next.js, take a look at the following resources: +`next build` needs `DATABASE_URL`, `STRIPE_SECRET_KEY`, and `NEXT_PUBLIC_*` set to fully pre-render; +without them the ISR pages build empty and self-heal at runtime. -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +## Deploy -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! +```bash +npm run preview # build + run the real worker locally +npm run deploy # build + deploy to Cloudflare +``` -## Deploy on Vercel +One-time setup: create the R2 bucket (`npx wrangler r2 bucket create left4web-inc-cache`), apply +`migrations/*.sql` to Postgres, and set secrets with `npx wrangler secret put` (see `.dev.vars.example` +for the list). -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +## Linting -Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. +[Biome](https://biomejs.dev) handles linting and formatting: `npm run lint` / `npm run lint:fix`. diff --git a/amplify.yml b/amplify.yml deleted file mode 100644 index 28babfd..0000000 --- a/amplify.yml +++ /dev/null @@ -1,40 +0,0 @@ -version: 1 -frontend: - phases: - preBuild: - commands: - - npm ci - - echo "DISCORD_CLIENT_ID=$DISCORD_CLIENT_ID" >> .env - - echo "DISCORD_CLIENT_SECRET=$DISCORD_CLIENT_SECRET" >> .env - - echo "DYNAMODB_ACCESS_KEY_ID=$DYNAMODB_ACCESS_KEY_ID" >> .env - - echo "DYNAMODB_ACCESS_KEY_SECRET=$DYNAMODB_ACCESS_KEY_SECRET" >> .env - - echo "DYNAMODB_NEXTAUTH_TABLE=$DYNAMODB_NEXTAUTH_TABLE" >> .env - - echo "DYNAMODB_REGION=$DYNAMODB_REGION" >> .env - - echo "DYNAMODB_STRIPE_EVENTS_TABLE=$DYNAMODB_STRIPE_EVENTS_TABLE" >> .env - - echo "DYNAMODB_STRIPE_TABLE=$DYNAMODB_STRIPE_TABLE" >> .env - - echo "DYNAMODB_STRIPE_TRIALS_TABLE=$DYNAMODB_STRIPE_TRIALS_TABLE" >> .env - - echo "EMAIL_FROM=$EMAIL_FROM" >> .env - - echo "EMAIL_SERVER_HOST=$EMAIL_SERVER_HOST" >> .env - - echo "EMAIL_SERVER_PASSWORD=$EMAIL_SERVER_PASSWORD" >> .env - - echo "EMAIL_SERVER_PORT=$EMAIL_SERVER_PORT" >> .env - - echo "EMAIL_SERVER_USER=$EMAIL_SERVER_USER" >> .env - - echo "JWT_SIGNING_PRIVATE_KEY=$JWT_SIGNING_PRIVATE_KEY" >> .env - - echo "LITEBANS_API=$LITEBANS_API" >> .env - - echo "LITEBANS_API_KEY=$LITEBANS_API_KEY" >> .env - - echo "NEXT_PUBLIC_STRIPE_KEY=$NEXT_PUBLIC_STRIPE_KEY" >> .env - - echo "NEXT_PUBLIC_URL=$NEXT_PUBLIC_URL" >> .env - - echo "NEXTAUTH_SECRET=$NEXTAUTH_SECRET" >> .env - - echo "NEXTAUTH_URL=$NEXTAUTH_URL" >> .env - - echo "SQS_QUEUE_URL=$SQS_QUEUE_URL" >> .env - - echo "STRIPE_SECRET_KEY=$STRIPE_SECRET_KEY" >> .env - - echo "STRIPE_WEBHOOK_SECRET=$STRIPE_WEBHOOK_SECRET" >> .env - build: - commands: - - npm run build - artifacts: - baseDirectory: .next - files: - - '**/*' - cache: - paths: - - node_modules/**/* diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..30299fd --- /dev/null +++ b/biome.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.9/schema.json", + "assist": { + "actions": { + "source": { + "useSortedKeys": "on" + } + }, + "enabled": true + }, + "files": { + "includes": ["**", "!.next", "!.open-next", "!node_modules"] + }, + "formatter": { + "enabled": true, + "indentStyle": "tab", + "lineWidth": 150 + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "always", + "trailingCommas": "none" + } + }, + "linter": { + "enabled": true, + "rules": { + "a11y": { + "noLabelWithoutControl": "warn", + "noStaticElementInteractions": "warn", + "noSvgWithoutTitle": "warn", + "useButtonType": "warn", + "useKeyWithClickEvents": "warn" + }, + "correctness": { + "useExhaustiveDependencies": "warn" + }, + "recommended": true, + "suspicious": { + "noArrayIndexKey": "off" + } + } + }, + "vcs": { + "clientKind": "git", + "enabled": true, + "useIgnoreFile": true + } +} diff --git a/cloudformation.json b/cloudformation.json deleted file mode 100644 index c83411b..0000000 --- a/cloudformation.json +++ /dev/null @@ -1,110 +0,0 @@ -{ - "Resources": { - "NextAuthDevTable": { - "Type": "AWS::DynamoDB::Table", - "Properties": { - "TableName": "left4web_dev_nextauth", - "BillingMode": "PAY_PER_REQUEST", - "AttributeDefinitions": [ - { - "AttributeName": "pk", - "AttributeType": "S" - }, - { - "AttributeName": "sk", - "AttributeType": "S" - }, - { - "AttributeName": "GSI1PK", - "AttributeType": "S" - }, - { - "AttributeName": "GSI1SK", - "AttributeType": "S" - } - ], - "KeySchema": [ - { - "AttributeName": "pk", - "KeyType": "HASH" - }, - { - "AttributeName": "sk", - "KeyType": "RANGE" - } - ], - "GlobalSecondaryIndexes": [ - { - "IndexName": "GSI1", - "KeySchema": [ - { - "AttributeName": "GSI1PK", - "KeyType": "HASH" - }, - { - "AttributeName": "GSI1SK", - "KeyType": "RANGE" - } - ], - "Projection": { - "ProjectionType": "ALL" - } - } - ] - } - }, - "NextAuthProdTable": { - "Type": "AWS::DynamoDB::Table", - "Properties": { - "TableName": "left4web_prod_nextauth", - "BillingMode": "PAY_PER_REQUEST", - "AttributeDefinitions": [ - { - "AttributeName": "pk", - "AttributeType": "S" - }, - { - "AttributeName": "sk", - "AttributeType": "S" - }, - { - "AttributeName": "GSI1PK", - "AttributeType": "S" - }, - { - "AttributeName": "GSI1SK", - "AttributeType": "S" - } - ], - "KeySchema": [ - { - "AttributeName": "pk", - "KeyType": "HASH" - }, - { - "AttributeName": "sk", - "KeyType": "RANGE" - } - ], - "GlobalSecondaryIndexes": [ - { - "IndexName": "GSI1", - "KeySchema": [ - { - "AttributeName": "GSI1PK", - "KeyType": "HASH" - }, - { - "AttributeName": "GSI1SK", - "KeyType": "RANGE" - } - ], - "Projection": { - "ProjectionType": "ALL" - } - } - ] - } - } - } -} diff --git a/components/3d_head.js b/components/3d_head.js index bb7eb19..fdd3d18 100644 --- a/components/3d_head.js +++ b/components/3d_head.js @@ -1,63 +1,41 @@ -import React, { - useRef, useState, useEffect -} from 'react'; +import { Canvas, useFrame } from '@react-three/fiber'; import PropTypes from 'prop-types'; -import { - Canvas, useFrame -} from '@react-three/fiber'; -import { - TextureLoader, MeshBasicMaterial, NearestFilter -} from 'three'; +import React, { useEffect, useRef, useState } from 'react'; +import { MeshBasicMaterial, NearestFilter, TextureLoader } from 'three'; function Box(props) { - // This reference will give us direct access to the mesh const mesh = useRef(); - useEffect( - () => { - const loader = new TextureLoader(); - loader.setPath(`/images/heads/${props.player}/`); - - const materials = []; - for(const texture of ['px.png', - 'nx.png', - 'py.png', - 'ny.png', - 'pz.png', - 'nz.png']) { + useEffect(() => { + const loader = new TextureLoader(); + loader.setPath(`/images/heads/${props.player}/`); - const textureMap = loader.load(texture); + const materials = []; + for (const texture of ['px.png', 'nx.png', 'py.png', 'ny.png', 'pz.png', 'nz.png']) { + const textureMap = loader.load(texture); - // pixelated scaling to prevent blurry faces - textureMap.magFilter = NearestFilter; + // pixelated scaling to prevent blurry faces + textureMap.magFilter = NearestFilter; - materials.push(new MeshBasicMaterial({ map: textureMap })); - } - mesh.current.material = materials; - }, - [] - ); + materials.push(new MeshBasicMaterial({ map: textureMap })); + } + mesh.current.material = materials; + }, []); // Subscribe this component to the render-loop, rotate the mesh every frame - useFrame( - () => { - mesh.current.rotation.y = props.rotation.x; - mesh.current.rotation.x = props.rotation.y; - } - ); + useFrame(() => { + mesh.current.rotation.y = props.rotation.x; + mesh.current.rotation.x = props.rotation.y; + }); // Return view, these are regular three.js elements expressed in JSX return ( - + {/* onClick={event => setActive(!active)} onPointerOver={event => setHover(true)} onPointerOut={event => setHover(false)}> */} - - + + ); } @@ -70,60 +48,51 @@ Box.propTypes = { export function PlayerHead(props) { // store the HTML element to be able to extract its position - const [headElem, - setHeadElem] = useState(null); + const [headElem, setHeadElem] = useState(null); - const [rotation, - setRotation] = useState({ + const [rotation, setRotation] = useState({ x: 0, y: 0 }); // whenever mouse position or scroll changes, update direction // the head is facing - useEffect( - () => { - - if(headElem !== null) { - const bound = headElem.getBoundingClientRect(); - const headX = (bound.left + bound.right) / 2; - const headY = (bound.top + bound.bottom) / 2; - - let xRot = 2*(props.mousePos.x - headX) / document.body.clientWidth; - let yRot = 2*(props.mousePos.y - props.scroll - headY) / window.innerHeight; - - // make sure head isn't rotated too far - if (Math.abs(xRot) > 0.6) { - xRot = Math.sign(xRot) * 0.6; - } - if (Math.abs(yRot) > 0.6) { - yRot = Math.sign(yRot) * 0.6; - } - - // console.log((props.mousePos.x - headX) / document.body.clientWidth); - // console.log((props.mousePos.y - props.scroll - headY) / window.innerHeight); - setRotation({ - x: xRot, - y: yRot - }); + useEffect(() => { + if (headElem !== null) { + const bound = headElem.getBoundingClientRect(); + const headX = (bound.left + bound.right) / 2; + const headY = (bound.top + bound.bottom) / 2; + + let xRot = (2 * (props.mousePos.x - headX)) / document.body.clientWidth; + let yRot = (2 * (props.mousePos.y - props.scroll - headY)) / window.innerHeight; + + // make sure head isn't rotated too far + if (Math.abs(xRot) > 0.6) { + xRot = Math.sign(xRot) * 0.6; + } + if (Math.abs(yRot) > 0.6) { + yRot = Math.sign(yRot) * 0.6; } - }, - [props.mousePos, - props.scroll] - ); + + // console.log((props.mousePos.x - headX) / document.body.clientWidth); + // console.log((props.mousePos.y - props.scroll - headY) / window.innerHeight); + setRotation({ + x: xRot, + y: yRot + }); + } + }, [props.mousePos, props.scroll]); return (
{ - if(el === null || headElem === el) return; + ref={(el) => { + if (el === null || headElem === el) return; setHeadElem(el); - } - }> - + }} + > + - + {/* */} - + -
); } diff --git a/components/ban_search.js b/components/ban_search.js index fec5f41..2de109c 100644 --- a/components/ban_search.js +++ b/components/ban_search.js @@ -5,20 +5,19 @@ export function SearchBar({ setErrorText }) { let type = document.getElementById('search-type').value; let query = document.getElementById('search-query').value; - if(mobile) { + if (mobile) { type = document.getElementById('search-type-mobile').value; query = document.getElementById('search-query-mobile').value; } const query_request = new XMLHttpRequest(); - query_request.open('get', - process.env.LITEBANS_API + '/check?name=' + query); + query_request.open('get', '/api/punishments/check?name=' + encodeURIComponent(query)); query_request.send(); query_request.onload = () => { try { const uuid_response = JSON.parse(query_request.response); - if(uuid_response.success) { + if (uuid_response.success) { // redirect to /punishments/[type]/[uuid_response.result.uuid]/1 window.location.href = `/punishments/${type}/${uuid_response.result.uuid}/1`; } else { @@ -33,45 +32,60 @@ export function SearchBar({ setErrorText }) { // there are two divs, one for mobile and one for desktop // yeah this is a hacky way to implement responsive design for the search bar // but I am too tired of dealing with css to merge them into one - return <> -
-
- -
-
- + return ( + <> +
+
+ +
+
+ +
+
+ +
-
-
- -
- -
- - - -
- ; - + + ); } // client-side search functions diff --git a/components/cart.js b/components/cart.js index 4d867c0..445aa5e 100644 --- a/components/cart.js +++ b/components/cart.js @@ -6,62 +6,66 @@ export default function Cart(props) { const getTotalCost = () => { let cost = 0; // eslint-disable-next-line no-unused-vars - for(const [_, - product] of Object.entries(JSON.parse(props.cart))) { - cost += product.price*product.quantity; + for (const [_, product] of Object.entries(JSON.parse(props.cart))) { + cost += product.price * product.quantity; } return cost; }; - return <> - - - - - - - - {props.canRemove && } - - - - - {Object.entries(JSON.parse(props.cart)).map(([id, - product]) => - - - - - {props.canRemove && } - )} - -
- Item - - Unit Price - - Quantity - - Total - - Remove -
- Name{product.name} - - Price{toReadablePrice(product.price)} - - Quantity{product.quantity} - - Total{toReadablePrice(product.price*product.quantity)} - - props.removeFromCart(product)}> - - -
-
-

Subtotal: {toReadablePrice(getTotalCost(props.cart))}

-
- ; + return ( + <> + + + + + + + + {props.canRemove && } + + + + {Object.entries(JSON.parse(props.cart)).map(([id, product]) => ( + + + + + + {props.canRemove && ( + + )} + + ))} + +
ItemUnit PriceQuantityTotalRemove
+ Name + {product.name} + + Price + {toReadablePrice(product.price)} + + Quantity + {product.quantity} + + Total + {toReadablePrice(product.price * product.quantity)} + + props.removeFromCart(product)} + > + + +
+
+

Subtotal: {toReadablePrice(getTotalCost(props.cart))}

+
+ + ); } Cart.propTypes = { diff --git a/components/footer.js b/components/footer.js index 2db01dc..ca6a144 100644 --- a/components/footer.js +++ b/components/footer.js @@ -1,66 +1,63 @@ import Link from 'next/link'; export function Footer() { - return <> -
- - ; - -} \ No newline at end of file + + + ); +} diff --git a/components/hero.js b/components/hero.js index 896e399..02bc547 100644 --- a/components/hero.js +++ b/components/hero.js @@ -1,33 +1,36 @@ import { useRouter } from 'next/router'; import PropTypes from 'prop-types'; -import { - useEffect, useState -} from 'react'; +import { useEffect, useState } from 'react'; export function Hero({ title }) { - const [bg, - setBg] = useState(null); + const [bg, setBg] = useState(null); const { pathname } = useRouter(); useEffect(() => { - setBg((Math.floor(Math.random()*3)+1).toString()); + setBg((Math.floor(Math.random() * 3) + 1).toString()); }, [pathname]); - return <> -
-
-
-
-
-

{title}

-
- ; + return ( + <> +
+
+
+
+
+

{title}

+
+ + ); } Hero.propTypes = { title: PropTypes.string }; -{ /** +{ + /** .green-filter:after { background: rgba(137, 201, 61, 0.64); background: linear-gradient(45deg, rgba(137, 201, 61, 0.85) 0%, rgba(76, 175, 80, 0.85) 100%); @@ -35,4 +38,5 @@ Hero.propTypes = { title: PropTypes.string }; background: -webkit-linear-gradient(135deg, rgba(137, 201, 61, 0.85) 0%, rgba(76, 175, 80, 0.85) 100%); } -*/ } \ No newline at end of file +*/ +} diff --git a/components/history.js b/components/history.js index 2d3c8a6..c97ba4a 100644 --- a/components/history.js +++ b/components/history.js @@ -3,43 +3,44 @@ import PropTypes from 'prop-types'; import { toReadablePrice } from '../utils/readable_price'; export default function History(props) { - return <> - - - - - - - - - - - {props.history.map(order => - - - - - )} - -
- Time - - Price - - Refund - - Recipt -
- Time{new Date(order.timestamp).toLocaleString()} - - Price{toReadablePrice(order.amount)} - - Refund{toReadablePrice(order.amount_refund)} - - ReciptView -
- ; + return ( + <> + + + + + + + + + + + {props.history.map((order) => ( + + + + + + + ))} + +
TimePriceRefundRecipt
+ Time + {new Date(order.timestamp).toLocaleString()} + + Price + {toReadablePrice(order.amount)} + + Refund + {toReadablePrice(order.amount_refund)} + + Recipt + + View + +
+ + ); } History.propTypes = { history: PropTypes.object }; - diff --git a/components/history_table.js b/components/history_table.js index 9cfc631..5e882b9 100644 --- a/components/history_table.js +++ b/components/history_table.js @@ -1,14 +1,14 @@ -import Link from 'next/link'; import Image from 'next/image'; +import Link from 'next/link'; import PropTypes from 'prop-types'; -export function HistoryTable({ - data, success, type -}) { - if(success === undefined) { - return <> -

Loading...

- ; +export function HistoryTable({ data, success, type }) { + if (success === undefined) { + return ( + <> +

Loading...

+ + ); } // let title = 'Bans'; // let action = 'Banned'; @@ -23,75 +23,82 @@ export function HistoryTable({ // // title = 'Warnings'; // action = 'Warned'; // } - if(!success || data.result.length === 0) { - return <> -
-

No results

-
-
-
- - - -
- ; + if (!success || data.result.length === 0) { + return ( + <> +
+

No results

+
+
+
+ + + +
+ + ); } const rows = []; - for(let i = 0; i < data.result.length; i += 1) { + for (let i = 0; i < data.result.length; i += 1) { rows.push(); } - return <> -
-
- - - - - - - - - - - - - - {rows} - -
TypePlayerPunished byReasonDateExpires
-
- - - - - - + + - -
-
-
- - - -
- ; - + + + +
+
+
+ + + +
+ + ); } HistoryTable.propTypes = { @@ -100,39 +107,76 @@ HistoryTable.propTypes = { type: PropTypes.array }; -function HistoryRow({ - action, rowData -}) { +function HistoryRow({ action, rowData }) { let removed_by = ''; - if(rowData.removed_by_name === '#expired') { + if (rowData.removed_by_name === '#expired') { removed_by = ' (Expired)'; } else if (rowData.removed_by_name !== null) { removed_by = ' (Removed by ' + rowData.removed_by_name + ')'; } let type = ''; - if(rowData.type === 'bans') { + if (rowData.type === 'bans') { type = 'Ban'; - } else if(rowData.type === 'mutes') { + } else if (rowData.type === 'mutes') { type = 'Mute'; - } else if(rowData.type === 'kicks') { + } else if (rowData.type === 'kicks') { type = 'Kick'; - } else if(rowData.type === 'warnings') { + } else if (rowData.type === 'warnings') { type = 'Warning'; } return ( - + - Type{type} - Player{rowData.name}
-
{rowData.name}
- {`${action} by`}{rowData.banned_by}
-
{rowData.banned_by}
- Reason{rowData.reason} - Date{new Date(rowData.time).toLocaleString()} - Expires{(rowData.until <= 0 ? 'Permanent' : new Date(rowData.until).toLocaleString()) + removed_by} + + Type + {type} + + + Player + {rowData.name} +
+
+ +
+ {rowData.name} +
+ + + {`${action} by`} + {rowData.banned_by} +
+
+ +
+ {rowData.banned_by} +
+ + + Reason + {rowData.reason} + + + Date + {new Date(rowData.time).toLocaleString()} + + + Expires + {(rowData.until <= 0 ? 'Permanent' : new Date(rowData.until).toLocaleString()) + removed_by} + ); diff --git a/components/homepage.js b/components/homepage.js index 882fb2e..3de20a2 100644 --- a/components/homepage.js +++ b/components/homepage.js @@ -1,71 +1,56 @@ -import PropTypes from 'prop-types'; -import { - useState, useEffect -} from 'react'; -import { Loader } from './loader'; -import { Footer } from './footer'; -import { PlayerHead } from './3d_head'; import Link from 'next/link'; +import PropTypes from 'prop-types'; +import { useEffect, useState } from 'react'; import { FaDiscord } from 'react-icons/fa'; +import { PlayerHead } from './3d_head'; +import { Footer } from './footer'; +import { Loader } from './loader'; export function Homepage(props) { // state to store whether homepage fully revealed - const [transitionComplete, - setTransitionComplete] = useState(false); + const [transitionComplete, setTransitionComplete] = useState(false); // state to store whether to fade out the text boxes - const [fadeTextbox, - setFadeTextBox] = useState(false); + const [fadeTextbox, setFadeTextBox] = useState(false); // state for copy to clipboard text - const [copyText, - setCopyText] = useState('Click to copy'); - + const [copyText, setCopyText] = useState('Click to copy'); // state to help with head facing direction - const [mousePos, - setMousePos] = useState({ + const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); - const [scroll, - setScroll] = useState(0); - const [prevScroll, - setPrevScroll] = useState(0); + const [scroll, setScroll] = useState(0); + const [prevScroll, setPrevScroll] = useState(0); // update the scrolling state - useEffect( - () => { - document.addEventListener('scroll', () => { + useEffect(() => { + document.addEventListener('scroll', () => { + setScroll(window.scrollY); + }); + + return () => { + document.removeEventListener('scroll', () => { setScroll(window.scrollY); }); - - return () => { - document.removeEventListener('scroll', () => { - setScroll(window.scrollY); - }); - }; - }, - [] - ); + }; + }, []); // whenever the user scrolls, update the mouse position, since it moves with the page - useEffect( - () => { - const delta = scroll - prevScroll; - setPrevScroll(scroll); + useEffect(() => { + const delta = scroll - prevScroll; + setPrevScroll(scroll); - if(!fadeTextbox && scroll > 240) { - setFadeTextBox(true); - } + if (!fadeTextbox && scroll > 240) { + setFadeTextBox(true); + } - setMousePos({ - x: mousePos.x, - y: mousePos.y + delta - }); - }, - [scroll] - ); + setMousePos({ + x: mousePos.x, + y: mousePos.y + delta + }); + }, [scroll]); if (!transitionComplete && props.loaded) { setTimeout(() => { @@ -73,40 +58,44 @@ export function Homepage(props) { }, 1000); } - if(!transitionComplete) { - return <> -
-
- -
- Left4Craft -

mc.left4craft.org

-
- + if (!transitionComplete) { + return ( + <> +
+
+ +
+ Left4Craft +

mc.left4craft.org

+
+ +
+

Loading...

-

Loading...

-
- ; + + ); } return ( -
{ +
{ setMousePos({ x: e.pageX, y: e.pageY }); // console.log(mousePos); - } - }> -
-
+ }} + > +
+
-

- Small Community Minecraft + Small Community Minecraft

-
{ const elem = document.getElementById('l4c-ip-address'); const select = window.getSelection(); @@ -115,17 +104,20 @@ export function Homepage(props) { range.selectNodeContents(elem); select.addRange(range); try { - navigator.clipboard.writeText('mc.left4craft.org').then(() => { - setCopyText('Copied!'); - setTimeout(() => { - setCopyText('Click to copy'); - }, 2000); - }, () => { - setCopyText('Failed to copy'); - setTimeout(() => { - setCopyText('Click to copy'); - }, 2000); - }); + navigator.clipboard.writeText('mc.left4craft.org').then( + () => { + setCopyText('Copied!'); + setTimeout(() => { + setCopyText('Click to copy'); + }, 2000); + }, + () => { + setCopyText('Failed to copy'); + setTimeout(() => { + setCopyText('Click to copy'); + }, 2000); + } + ); // in case the user has an extremely old browser // or clipboard permissions change in a future version of Chromium/Firefox } catch (e) { @@ -134,132 +126,135 @@ export function Homepage(props) { setCopyText('Click to copy'); }, 2000); } - }}> -
- mc.left4craft.org + }} + > +
+ mc.left4craft.org
- {copyText} + + {copyText} +
-
- +
-
240 ? 'animate-fade-in opacity-100' : `opacity-0 cursor-default ${fadeTextbox ? 'animate-fade-out' : ''}`} flex flex-wrap justify-center items-center text-center gap-8`}> +
240 ? 'animate-fade-in opacity-100' : `opacity-0 cursor-default ${fadeTextbox ? 'animate-fade-out' : ''}`} flex flex-wrap justify-center items-center text-center gap-8`} + >
-
- +
+
-

- Survival -

+

Survival

- Semi-vanilla Survival is by far the most popular game-mode. The server features - a balanced economy, advanced anti-griefing tools, and a vibrant community. + Semi-vanilla Survival is by far the most popular game-mode. The server features a balanced economy, advanced anti-griefing tools, and + a vibrant community.

-
+
-
- +
+
-

- Creative -

+

Creative

- Left4Craft's creative server offers large plots and free worldedit. Years of - builds have accumulated to provide plenty of inspiration. + Left4Craft's creative server offers large plots and free worldedit. Years of builds have accumulated to provide plenty of + inspiration.

-
+
-
- +
+
-

- Party Games -

+

Party Games

- Party Games is a completely custom gamemode designed for 4-8 players. Over - a dozen unique minigames provide hours of fast-paced fun. + Party Games is a completely custom gamemode designed for 4-8 players. Over a dozen unique minigames provide hours of fast-paced fun.

-
-
240 ? 'animate-fade-in opacity-100' : `opacity-0 cursor-default ${fadeTextbox ? 'animate-fade-out' : ''}`} flex flex-wrap justify-center items-center text-center gap-8`}> +
240 ? 'animate-fade-in opacity-100' : `opacity-0 cursor-default ${fadeTextbox ? 'animate-fade-out' : ''}`} flex flex-wrap justify-center items-center text-center gap-8`} + >
-
- +
+
-

- Since 2013 -

+

Since 2013

- Left4Craft has been operated continuously since 2013 and will continue to operate - for years to come. You can rest assured this community is here to stay. + Left4Craft has been operated continuously since 2013 and will continue to operate for years to come. You can rest assured this + community is here to stay.

-
+
-
- +
+
-

- Custom Code -

+

Custom Code

- A lot of the code, including this website, is completely custom written and open source. - You can read it on the official Left4Craft Github. + A lot of the code, including this website, is completely custom written and open source. You can read it on the{' '} + + official Left4Craft Github + + .

-
+
-
- +
+
-

- Blazing Fast -

+

Blazing Fast

- Left4Craft utilizes modern hardware and networking topology to deliver high TPS and low latency. We even publish - performance metrics on our status page. + Left4Craft utilizes modern hardware and networking topology to deliver high TPS and low latency. We even publish performance metrics + on our{' '} + + status page + + .

-
-
-
+
+

- - Ready to join Left4Craft? - + Ready to join Left4Craft?

-
{ const elem = document.getElementById('l4c-ip-address'); const select = window.getSelection(); @@ -268,17 +263,20 @@ export function Homepage(props) { range.selectNodeContents(elem); select.addRange(range); try { - navigator.clipboard.writeText('mc.left4craft.org').then(() => { - setCopyText('Copied!'); - setTimeout(() => { - setCopyText('Click to copy'); - }, 2000); - }, () => { - setCopyText('Failed to copy'); - setTimeout(() => { - setCopyText('Click to copy'); - }, 2000); - }); + navigator.clipboard.writeText('mc.left4craft.org').then( + () => { + setCopyText('Copied!'); + setTimeout(() => { + setCopyText('Click to copy'); + }, 2000); + }, + () => { + setCopyText('Failed to copy'); + setTimeout(() => { + setCopyText('Click to copy'); + }, 2000); + } + ); // in case the user has an extremely old browser // or clipboard permissions change in a future version of Chromium/Firefox } catch (e) { @@ -287,43 +285,44 @@ export function Homepage(props) { setCopyText('Click to copy'); }, 2000); } - }}> -
- mc.left4craft.org + }} + > +
+ mc.left4craft.org
- {copyText} + + {copyText} +
-
- -
+ +
- Discord + Discord
-
-
- -
+
+
-
); } diff --git a/components/info_table.js b/components/info_table.js index 164c7a4..1a30d3c 100644 --- a/components/info_table.js +++ b/components/info_table.js @@ -1,33 +1,37 @@ -import Link from 'next/link'; import Image from 'next/image'; +import Link from 'next/link'; import PropTypes from 'prop-types'; -export function InfoTable({ - data, success, type -}) { - if(success === undefined) { - return <> -

Loading...

- ; +export function InfoTable({ data, success, type }) { + if (success === undefined) { + return ( + <> +

Loading...

+ + ); } - if(!success || data.result === undefined) { - return <> -
-

No results

-
-
-
- - - -
- ; + if (!success || data.result === undefined) { + return ( + <> +
+

No results

+
+
+
+ + + +
+ + ); } let action = 'Banned'; - if(type[1] === 'mutes') { + if (type[1] === 'mutes') { // title = 'Mutes'; action = 'Muted'; } else if (type[1] === 'kicks') { @@ -39,82 +43,93 @@ export function InfoTable({ } let removed_by = ''; - if(data.result.removed_by_name === '#expired') { + if (data.result.removed_by_name === '#expired') { removed_by = ' (Expired)'; } else if (data.result.removed_by_name !== null) { removed_by = ' (Removed by ' + data.result.removed_by_name + ')'; } - return <> - -
-
-
-
- - - - - - + + + + +
Player -
- -
- {data.result.name} +
+
+
+
+ + + + + + + + + + + + + + + + + + - - - - + + + + + + - - - - - - - - - - - - - - - - - - -
Player +
+ +
{data.result.name}
- -
{action} by +
+ +
{data.result.banned_by}
+
+
Reason{data.result.reason}
{action} by
Date{new Date(data.result.time).toLocaleString()}
Expires -
- -
- {data.result.banned_by} -
-
+ {data.result.until <= 0 ? 'Permanent' : new Date(data.result.until).toLocaleString() + removed_by}
Reason{data.result.reason}
Date{new Date(data.result.time).toLocaleString()}
Expires{data.result.until <= 0 ? 'Permanent' : new Date(data.result.until).toLocaleString() + removed_by}
Origin Server{data.result.server_origin}
- -
-
-
-
-
-
- - - -
- - ; +
Origin Server{data.result.server_origin}
+
+
+
+
+
+
+ + + +
+ + ); } InfoTable.propTypes = { diff --git a/components/layout.js b/components/layout.js index 847b424..8f85e28 100644 --- a/components/layout.js +++ b/components/layout.js @@ -12,4 +12,4 @@ function Layout(props) { ); } -export default Layout; \ No newline at end of file +export default Layout; diff --git a/components/list_table.js b/components/list_table.js index 7920605..c269c50 100644 --- a/components/list_table.js +++ b/components/list_table.js @@ -1,34 +1,38 @@ -import Link from 'next/link'; import Image from 'next/image'; +import Link from 'next/link'; import PropTypes from 'prop-types'; -export function ListTable({ - data, success, type -}) { - if(success === undefined) { - return <> -

Loading...

- ; +export function ListTable({ data, success, type }) { + if (success === undefined) { + return ( + <> +

Loading...

+ + ); } - if(!success || data.result.length === 0) { - return <> -
-

No results

-
-
-
- - - -
- ; + if (!success || data.result.length === 0) { + return ( + <> +
+

No results

+
+
+
+ + + +
+ + ); } // let title = 'Bans'; let action = 'Banned'; - if(type[1] === 'mutes') { + if (type[1] === 'mutes') { // title = 'Mutes'; action = 'Muted'; } else if (type[1] === 'kicks') { @@ -39,74 +43,88 @@ export function ListTable({ action = 'Warned'; } const rows = []; - for(let i = 0; i < data.result.length; i += 1) { + for (let i = 0; i < data.result.length; i += 1) { rows.push(); } - return <> -
- - - - - - - - - - - - -
- - - - - - - - - - - - - {rows} - -
Player{`${action} by`}ReasonDateExpires
-
- - - - - - + + - -
- ; - + + + +
+ + ); } ListTable.propTypes = { @@ -115,11 +133,9 @@ ListTable.propTypes = { type: PropTypes.array }; -function ListRow({ - action, rowData, type -}) { +function ListRow({ action, rowData, type }) { let removed_by = ''; - if(rowData.removed_by_name === '#expired') { + if (rowData.removed_by_name === '#expired') { removed_by = ' (Expired)'; } else if (rowData.removed_by_name !== null) { removed_by = ' (Removed by ' + rowData.removed_by_name + ')'; @@ -127,13 +143,52 @@ function ListRow({ return ( - Player{rowData.name}
-
{rowData.name}
- {`${action} by`}{rowData.banned_by}
-
{rowData.banned_by}
- Reason{rowData.reason} - Date{new Date(rowData.time).toLocaleString()} - Expires{(rowData.until <= 0 ? 'Permanent' : new Date(rowData.until).toLocaleString()) + removed_by} + + Player + {rowData.name} +
+
+ +
+ {rowData.name} +
+ + + {`${action} by`} + {rowData.banned_by} +
+
+ +
+ {rowData.banned_by} +
+ + + Reason + {rowData.reason} + + + Date + {new Date(rowData.time).toLocaleString()} + + + Expires + {(rowData.until <= 0 ? 'Permanent' : new Date(rowData.until).toLocaleString()) + removed_by} + ); diff --git a/components/loader.js b/components/loader.js index 19169de..f8e6cfd 100644 --- a/components/loader.js +++ b/components/loader.js @@ -1,21 +1,28 @@ import PropTypes from 'prop-types'; export function Spinner() { - return - - - ; + return ( + + + + ); } Spinner.propTypes = { text: PropTypes.string }; export function Loader(props) { // https://github.com/tobiasahlin/SpinKit/blob/master/spinkit.css - return <> - -
-
-
-
-
-
-
-
-
-
-
- ; + `} + +
+
+
+
+
+
+
+
+
+
+
+ + ); } Loader.propTypes = { diff --git a/components/navbar.js b/components/navbar.js index b3fe0cc..fb5139b 100644 --- a/components/navbar.js +++ b/components/navbar.js @@ -1,74 +1,56 @@ -import React, { useState } from 'react'; -import PropTypes from 'prop-types'; import Image from 'next/image'; import Link from 'next/link'; +import PropTypes from 'prop-types'; +import React, { useState } from 'react'; -export function Navbar (props) { - const [isOpen, - setOpen] = useState(false); +export function Navbar(props) { + const [isOpen, setOpen] = useState(false); return (
-