Remix migration

This commit is contained in:
2023-10-19 16:49:09 -04:00
parent 5d2774fb2e
commit 04dcbb4181
33 changed files with 16545 additions and 1813 deletions

19
app/context.tsx Normal file
View File

@ -0,0 +1,19 @@
import { createContext } from "react";
export interface ServerStyleContextData {
key: string;
ids: Array<string>;
css: string;
}
export const ServerStyleContext = createContext<
ServerStyleContextData[] | null
>(null);
export interface ClientStyleContextData {
reset: () => void;
}
export const ClientStyleContext = createContext<ClientStyleContextData | null>(
null
);

View File

@ -0,0 +1,7 @@
import createCache from "@emotion/cache";
export const defaultCache = createEmotionCache();
export default function createEmotionCache() {
return createCache.default({ key: "cha" });
}

39
app/entry.client.tsx Normal file
View File

@ -0,0 +1,39 @@
import { CacheProvider } from "@emotion/react";
import { ClientStyleContext } from "./context.js";
import createEmotionCache, { defaultCache } from "./createEmotionCache.js";
import { hydrateRoot } from "react-dom/client";
import { Integrations } from "@sentry/tracing";
import { RemixBrowser } from "@remix-run/react";
import * as Sentry from "@sentry/react";
import { type ReactNode, StrictMode, useState } from "react";
Sentry.init({
dsn:
document.querySelector("meta[name='dsn']")?.getAttribute("content") ??
undefined,
integrations: [new Integrations.BrowserTracing()],
tracesSampleRate: 0.1,
});
function ClientCacheProvider({ children }: { children: ReactNode }) {
const [cache, setCache] = useState(defaultCache);
function reset() {
setCache(createEmotionCache());
}
return (
<ClientStyleContext.Provider value={{ reset }}>
<CacheProvider value={cache}>{children}</CacheProvider>
</ClientStyleContext.Provider>
);
}
hydrateRoot(
document,
<StrictMode>
<ClientCacheProvider>
<RemixBrowser />
</ClientCacheProvider>
</StrictMode>
);

41
app/entry.server.tsx Normal file
View File

@ -0,0 +1,41 @@
import { CacheProvider } from "@emotion/react";
import createEmotionCache from "./createEmotionCache.js";
import { createEmotionServer } from "../emotion-server.js";
import { type EntryContext } from "@remix-run/cloudflare";
import { RemixServer } from "@remix-run/react";
import { renderToString } from "react-dom/server";
import { ServerStyleContext } from "./context.js";
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
const cache = createEmotionCache();
const { extractCriticalToChunks } = createEmotionServer(cache);
const html = renderToString(
<ServerStyleContext.Provider value={null}>
<CacheProvider value={cache}>
<RemixServer context={remixContext} url={request.url} />
</CacheProvider>
</ServerStyleContext.Provider>
);
const chunks = extractCriticalToChunks(html);
const markup = renderToString(
<ServerStyleContext.Provider value={chunks.styles}>
<CacheProvider value={cache}>
<RemixServer context={remixContext} url={request.url} />
</CacheProvider>
</ServerStyleContext.Provider>
);
responseHeaders.set("content-type", "text/html;charset=utf-8");
return new Response("<!DOCTYPE html>" + markup, {
headers: responseHeaders,
status: responseStatusCode,
});
}

177
app/root.tsx Normal file
View File

@ -0,0 +1,177 @@
import {
ChakraProvider,
Container,
cookieStorageManagerSSR,
Heading,
Link,
Text,
} from "@chakra-ui/react";
import { ClientStyleContext, ServerStyleContext } from "./context.js";
import fontStyle from "@fontsource/plus-jakarta-sans/index.css";
import Forbidden from "../components/Forbidden.js";
import globalStyles from "../index.css";
import { HelmetProvider } from "react-helmet-async";
import {
Links,
LiveReload,
Outlet,
Scripts,
useCatch,
useLoaderData,
} from "@remix-run/react";
import { LinksFunction } from "@remix-run/cloudflare";
import Login from "../components/Login.js";
import Navigation from "../components/Navigation.js";
import { type ReactNode, StrictMode, useContext, useEffect } from "react";
import theme from "../theme.js";
import { withEmotionCache } from "@emotion/react";
export function CatchBoundary() {
const { status } = useCatch();
switch (status) {
case 303:
return "";
case 401:
return getMarkup({ hide: true }, <Login />);
case 403:
return getMarkup({ hide: true }, <Forbidden />);
case 404:
return getMarkup(
{ hide: true },
<Container maxW="container.lg" pt="8vh" textAlign="left">
<Heading size="4xl">404</Heading>
<br />
<Text fontSize="xl">There is nothing to find here.</Text>
<br />
<br />
<br />
<Link color="#646cff" onClick={() => history.go(-1)}>
Go back
</Link>
</Container>
);
default:
return getMarkup(
{ hide: true },
<Container maxW="container.lg" pt="8vh" textAlign="left">
<Heading size="4xl">500</Heading>
<br />
<Text fontSize="xl">S̶̡͈̠̗̠͖͙̭o̶̶͕͚̥͍̪̤m̸̨͏͈͔̖͚̖̰̱͞e҉̵͖͚͇̀t̕͟͠͏͎̺̯̲̱̣̤̠̟͙̠̙̫̬ḩ̸̭͓̬͎̙̀į̞̮͉͖̰̥̹͚̫̙̪̗̜̳̕ͅn҉͔̯̪̗̝̝͖̲͇͍͎̲̲̤̖̫͈̪͡g̴̰̻̙̝͉̭͇̖̰̝̙͕̼͙͘͜ ̵̶̫̥̳̲̘̻̗͈͕̭̲͇̘̜̺̟̥̖̥b̴̙̭̹͕̞͠r̞͎̠̩͈̖̰̞̯̯͢͢͠ͅo̝̯̗̹̳͍̰͉͕̘̰̠̺̥̰͔̕ͅk̵̸̻̠͕̺̦̦͖̲̺̦̞̝̞͞͡e̶͏̤̼̼͔̘̰̰̭͈̀͞͡</Text>
<br />
<br />
<br />
<Link color="#646cff" onClick={() => location.reload()}>
Reload
</Link>
</Container>
);
}
}
export const links: LinksFunction = () => {
return [
{ href: "/favicon.ico", rel: "icon" },
{ href: "/files/logo192.png", rel: "apple-touch-icon", type: "image/png" },
{ href: fontStyle, rel: "stylesheet " },
{ href: globalStyles, rel: "stylesheet" },
];
};
export async function loader({
context,
}: {
context: RequestContext;
}): Promise<{ [k: string]: any }> {
let data: { [k: string]: string } = {};
if (context.data.current_user) data = { ...context.data.current_user };
if (context.env.DSN) data.dsn = context.env.DSN;
if (context.data.theme) data.theme = context.data.theme;
return data;
}
function getMarkup(
loaderData: { [k: string]: any },
child: ReactNode
): JSX.Element {
const Document = withEmotionCache(
({ children }: { children: ReactNode }, emotionCache) => {
const serverStyleData = useContext(ServerStyleContext);
const clientStyleData = useContext(ClientStyleContext);
useEffect(() => {
emotionCache.sheet.container = document.head;
const tags = emotionCache.sheet.tags;
emotionCache.sheet.flush();
tags.forEach((tag) => {
(emotionCache.sheet as any)._insertTag(tag);
});
clientStyleData?.reset();
}, []);
const helmetContext: { [k: string]: any } = {};
const body = (
<StrictMode>
<ChakraProvider
colorModeManager={cookieStorageManagerSSR(
typeof document === "undefined" ? "" : document.cookie
)}
theme={theme}
>
<HelmetProvider>
<div className="App">
<Navigation {...loaderData} />
{children}
<Scripts />
<LiveReload />
</div>
</HelmetProvider>
</ChakraProvider>
</StrictMode>
);
const { helmet } = helmetContext;
return (
<html lang="en-US">
<head>
<Links />
{serverStyleData?.map(({ key, ids, css }) => (
<style
key={key}
data-emotion={`${key} ${ids.join(" ")}`}
dangerouslySetInnerHTML={{ __html: css }}
/>
))}
<meta charSet="UTF-8" />
{loaderData.dsn ? (
<meta name="dsn" content={loaderData.dsn} />
) : null}
<meta name="theme-color" content="#00a8f8" />
{helmet.meta?.toString()}
{helmet.title?.toString() ?? <title>Car Crushers</title>}
</head>
<body>{body}</body>
</html>
);
}
);
return <Document>{child}</Document>;
}
export default function () {
const loaderData = useLoaderData<typeof loader>();
return getMarkup(loaderData, <Outlet />);
}

16
app/routes/_index.tsx Normal file
View File

@ -0,0 +1,16 @@
import { Box, Container, Text } from "@chakra-ui/react";
export default function () {
return (
<>
<Box alignContent="left">
<Container maxW="container.lg" paddingTop="8vh" textAlign="left">
<Text>
srfidukjghdiuftgrteutgrtsu,k jhsrte h hjgtsredbfdgns srthhfg h fgdyh
y
</Text>
</Container>
</Box>
</>
);
}

231
app/routes/appeals.tsx Normal file
View File

@ -0,0 +1,231 @@
import {
Alert,
AlertDescription,
AlertIcon,
AlertTitle,
Box,
Button,
Container,
Flex,
Heading,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Spacer,
Text,
Textarea,
useDisclosure,
useToast,
} from "@chakra-ui/react";
import { useLoaderData } from "@remix-run/react";
import { useState } from "react";
import Success from "../../components/Success.js";
export async function loader({ context }: { context: RequestContext }) {
if (!context.data.current_user)
throw new Response(null, {
status: 401,
});
const { current_user: currentUser } = context.data;
const dataKV = context.env.DATA;
const disabled = await dataKV.get("appeal_disabled");
return {
can_appeal:
!Boolean(disabled) &&
!Boolean(await dataKV.get(`blockedappeal_${currentUser.id}`)) &&
!Boolean(
(
await dataKV.list({
prefix: `appeal_${currentUser.id}`,
})
).keys.find((appeal) => (appeal.metadata as { [k: string]: any }).open)
),
can_toggle:
currentUser.permissions & (1 << 0) || currentUser.permissions & (1 << 11),
disabled: Boolean(disabled),
};
}
export default function () {
const pageProps = useLoaderData<typeof loader>();
const { isOpen, onClose, onOpen } = useDisclosure();
const [showSuccess, setShowSuccess] = useState(false);
const toast = useToast();
async function submit() {
const learned = (document.getElementById("learned") as HTMLInputElement)
.value;
const whyBanned = (document.getElementById("whyBanned") as HTMLInputElement)
.value;
const whyUnban = (document.getElementById("whyUnban") as HTMLInputElement)
.value;
const submitReq = await fetch("/api/appeals/submit", {
body: JSON.stringify({
learned,
whyBanned,
whyUnban,
}),
headers: {
"content-type": "application/json",
},
method: "POST",
}).catch(() => {});
if (!submitReq)
return toast({
description: "Please check your internet and try again",
duration: 10000,
isClosable: true,
status: "error",
title: "Request Failed",
});
if (!submitReq.ok)
return toast({
description: ((await submitReq.json()) as { error: string }).error,
duration: 10000,
isClosable: true,
status: "error",
title: "Error",
});
setShowSuccess(true);
}
async function toggle(active: boolean) {
const toggleReq = await fetch("/api/appeals/toggle", {
body: JSON.stringify({ active }),
headers: {
"content-type": "application/json",
},
method: "POST",
});
if (!toggleReq.ok)
return toast({
description: ((await toggleReq.json()) as { error: string }).error,
duration: 10000,
isClosable: true,
status: "error",
title: "Error",
});
toast({
description: `The appeals form is now ${active ? "opened" : "closed"}.`,
isClosable: true,
status: "success",
title: `Appeals ${active ? "enabled" : "disabled"}`,
});
onClose();
await new Promise((p) => setTimeout(p, 5000));
}
return showSuccess ? (
<Success
heading="Appeal Submitted"
message="You will receive an email when we reach a decision."
/>
) : (
<Container maxW="container.md" pt="4vh" textAlign="start">
<Alert
borderRadius="8px"
display={pageProps.disabled ? "flex" : "none"}
mb="16px"
status="error"
>
<AlertIcon />
<Box>
<AlertTitle>Appeals Closed</AlertTitle>
<AlertDescription>
We are currently not accepting appeals.
</AlertDescription>
</Box>
</Alert>
<Flex>
<Spacer />
<Button display={pageProps.can_toggle ? "" : "none"} onClick={onOpen}>
{pageProps.disabled ? "Enable" : "Disable"} Appeals
</Button>
</Flex>
<br />
<Modal isCentered isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Toggle appeals?</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Text>
Are you sure you want to{" "}
{pageProps.disabled ? "enable" : "disable"} appeals?
</Text>
</ModalBody>
<ModalFooter style={{ gap: "8px" }}>
<Button onClick={onClose} variant="ghost">
No
</Button>
<Button
onClick={async () => await toggle(pageProps.disabled)}
variant="danger"
>
Yes
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Heading size="xl">Discord Appeals</Heading>
<br />
<Text fontSize="md">
This is for Discord bans only! See the support page if you were banned
from the game.
</Text>
<br />
<br />
<Heading size="md">Why were you banned?</Heading>
<br />
<Textarea
disabled={!pageProps.can_appeal}
id="whyBanned"
maxLength={500}
placeholder="Your response"
/>
<br />
<br />
<br />
<Heading size="md">Why should we unban you?</Heading>
<br />
<Textarea
disabled={!pageProps.can_appeal}
id="whyUnban"
maxLength={2000}
placeholder="Your response"
/>
<br />
<br />
<br />
<Heading size="md">What have you learned from your mistake?</Heading>
<br />
<Textarea
disabled={!pageProps.can_appeal}
id="learned"
maxLength={2000}
placeholder="Your response"
/>
<br />
<br />
<Button
disabled={pageProps.can_appeal}
onClick={async () => await submit()}
>
Submit
</Button>
</Container>
);
}

140
app/routes/mod-queue.tsx Normal file
View File

@ -0,0 +1,140 @@
import {
Box,
Container,
Flex,
Select,
useBreakpointValue,
useToast,
VStack,
} from "@chakra-ui/react";
import { lazy, useState } from "react";
import AppealCard from "../../components/AppealCard.js";
import Login from "../../components/Login.js";
import { useLoaderData } from "@remix-run/react";
export async function loader({ context }: { context: RequestContext }) {
const { current_user: currentUser } = context.data;
if (!currentUser)
throw new Response(null, {
status: 401,
});
const newItemPermissions = {
game_ban: [1 << 5],
inactivity: [1 << 2, 1 << 9, 1 << 10],
infraction: [1 << 0, 1 << 2, 1 << 6, 1 << 7],
};
const newItemNames: { [k: string]: string } = {
game_ban: "Game Ban",
inactivity: "Inactivity Notice",
infraction: "Infraction",
};
const typePermissions = {
appeal: [1 << 0, 1 << 1],
gma: [1 << 5],
report: [1 << 5],
};
const typeNames: { [k: string]: string } = {
appeal: "Discord Appeals",
gma: "Game Appeals",
report: "Game Reports",
};
const allowedNewItems = [];
const allowedTypes = [];
for (const [item, ints] of Object.entries(newItemPermissions)) {
if (ints.find((i) => currentUser.permissions & i))
allowedNewItems.push({ name: newItemNames[item], value: item });
}
for (const [type, ints] of Object.entries(typePermissions)) {
if (ints.find((i) => currentUser.permissions & i))
allowedTypes.push({ name: typeNames[type], value: type });
}
if (!allowedTypes.length)
throw new Response(null, {
status: 403,
});
return {
entry_types: allowedTypes,
item_types: allowedNewItems,
};
}
export default function () {
const pageProps = useLoaderData<typeof loader>();
const isDesktop = useBreakpointValue({ base: false, lg: true });
const entryTypes = [];
const [entries, setEntries] = useState([] as JSX.Element[]);
for (const type of pageProps.entry_types)
entryTypes.push(<option value={type.value}>{type.name}</option>);
async function updateQueue(
queue_type: string,
show_closed: boolean = false
): Promise<void> {
const queueReq = await fetch(
`/api/mod-queue/list?type=${queue_type}&showClosed=${show_closed}`
);
if (!queueReq.ok) {
const errorData: { error: string } = await queueReq.json();
useToast()({
description: errorData.error,
duration: 10000,
isClosable: true,
status: "error",
title: "Failed to load queue",
});
return;
}
const entryData: { [k: string]: any }[] = await queueReq.json();
const newEntries = [];
for (const entry of entryData) {
switch (queue_type) {
case "appeal":
newEntries.push(
<AppealCard
{...(entry as {
ban_reason: string;
createdAt: number;
discriminator: string;
id: string;
learned: string;
reason_for_unban: string;
username: string;
})}
/>
);
}
}
setEntries(newEntries);
}
return (
<Container maxW="container.lg">
<Flex>
<VStack w="container.md">{entries}</VStack>
<Box display={isDesktop ? undefined : "none"} w="256px">
<Select placeholder="Entry Type">
<option value="">All</option>
{entryTypes}
</Select>
</Box>
</Flex>
</Container>
);
}

238
app/routes/privacy.tsx Normal file
View File

@ -0,0 +1,238 @@
import { Container, Heading, Link, Text } from "@chakra-ui/react";
export default function () {
return (
<Container maxW="container.lg" pb="8vh" pt="4vh" textAlign="start">
<Heading>Privacy Policy</Heading>
<br />
<Text>Last Updated: 2023-01-07</Text>
<br />
<hr />
<br />
<Heading size="lg">Information We Collect</Heading>
<br />
<ul>
<li>
<strong>Discord Profile Information</strong>: We receive account
information from Discord, Inc. when you sign in such as your username,
discriminator, avatar, and banner in order to authenticate you and
authorize requests. A list of information available can be found at{" "}
<Link
color="#646cff"
href="https://discord.com/developers/docs/resources/user"
target="_blank"
>
Discord's Developer Portal
</Link>
.
</li>
<li>
<strong>Email Addresses</strong>: We receive your email address from
your Discord account in order to allow us to contact you as necessary.
</li>
<li>
<strong>Member Information from Our Discord Server</strong>: We
receive a list of your roles within our Discord Server in order to
determine your ability to access certain parts of this site.
</li>
<li>
<strong>Error Reports</strong>: We collect error reports to aid in
fixing bugs and ensuring site stability.
</li>
<li>
<strong>Uploaded Files and Reports</strong>: We store uploaded files
and user reports to aid in moderating exploiters from Car Crushers 2.
</li>
<li>
<strong>Appeals</strong>: We store appeal requests to review them.
</li>
</ul>
<br />
<Heading size="lg">
Legal Basis for Processing of Your Personal Data
</Heading>
<br />
<ul>
<li>
<strong>To fulfill contractual commitments</strong>: E.g allow you to
use the site as intended.
</li>
<li>
<strong>Legitimate interests</strong>: In some cases, we continue to
process data on the grounds that our legitimate interests override the
interests or rights and freedoms of affected individuals. These
interests may include but are not limited to: Protecting ourselves and
our users, and preventing spam.
</li>
<li>
<strong>Consent</strong>: Where required by law, and in some other
cases, we handle personal data on the basis of your implied or express
consent.
</li>
<li>
<strong>Legal Requirements</strong>: We need to use and disclose
personal data in certain ways to comply with our legal obligations.
</li>
</ul>
<br />
<Heading size="lg">Disclosure of Your Information</Heading>
<br />
<Text>
While we have no intention of giving your personal data to Mark
Zuckerberg, in certain circumstances we may share your information with
third parties, as set forth below.
</Text>
<br />
<ul>
<li>
<strong>Business Transfers</strong>: In the event of a corporate sale,
merger, reorganization, bankruptcy, dissolution or etc., your
information may be part of the transferred assets.
</li>
<li>
<strong>Consent</strong>: We may transfer your information with your
consent.
</li>
<li>
<strong>Agents and Related Third Parties</strong>: We enlist the help
of other entities to perform certain tasks related to this site, such
as: Data Storage, Error Tracking and Reporting, and Hosting.
</li>
</ul>
<br />
<Heading size="lg">Our Partners</Heading>
<br />
<ul>
<li>
<strong>Cloudflare, Inc. (San Francisco, CA)</strong>: Hosting and
data storage provider.
</li>
<li>
<strong>Google, LLC. (Mountain View, CA)</strong>: File storage.
</li>
<li>
<strong>Mailgun Technologies, Inc. (San Antonio, TX)</strong>: Email
solutions.
</li>
<li>
<strong>
Functional Software, Inc. d/b/a Sentry (San Francisco, CA)
</strong>
: Error reporting.
</li>
</ul>
<br />
<Heading size="lg">Security</Heading>
<br />
<Text>
We take reasonable steps to protect the information provided via the
Services from loss, misuse, and unauthorized access, disclosure,
alteration, or destruction. However, no Internet or email transmission
is ever fully secure or error free. In particular, email sent to or from
the Services may not be secure. Therefore, you should take special care
in deciding what information you send to us via email or forms. Please
keep this in mind when disclosing any information via the Internet.
</Text>
<br />
<Heading size="lg">Cookies</Heading>
<br />
<Text>
All cookies by this site are necessary for the site to function. We do
not set any marketing or tracking cookies.
</Text>
<br />
<Heading size="lg">Children</Heading>
<br />
<Text>
Our services are meant for users who meet or exceed the age of digital
consent under relevant laws (such as COPPA and GDPR).
</Text>
<br />
<Heading size="lg">Links to Other Websites</Heading>
<br />
<Text>
This privacy policy only applies to this site and not other sites.
</Text>
<br />
<Heading size="lg">Data Retention</Heading>
<br />
<Text>
All data is retained as stated in the section below and is necessary to
conduct operations.
</Text>
<br />
<Heading size="lg">Data Retention Periods</Heading>
<br />
<ul>
<li>
<strong>Session Data</strong>: Maximum of one week (or on logout).
</li>
<li>
<strong>Email Addresses</strong>: From the time that you submit a form
to the time that it is marked as closed (i.e when your appeal is
reviewed).
</li>
<li>
<strong>Profile, Server, and Permission Data</strong>: See the Session
Data entry.
</li>
<li>
<strong>Error Reports</strong>: Error reports collected by Sentry are
deleted after 90 days. Sensitive information such as IP addresses are
not retained with logs.
</li>
<li>
<strong>Uploaded Files and Report Submissions</strong>: Report
submissions and uploaded media as part of a report submission may be
retained indefinitely. Email addresses are removed after report
review.
</li>
<li>
<strong>Appeals</strong>: Appeal submissions are retained for 3 years
after closure date.
</li>
</ul>
<br />
<Heading size="lg">Data Rights and Choices</Heading>
<br />
<Text>You have the right to:</Text>
<br />
<ul>
<li>
Access your personal information (or export it in a portable format)
</li>
<li>Delete your personal information</li>
<li>
Request restrictions on the processing of your personal information
</li>
<li>Lodge a complaint with a supervisory authority</li>
</ul>
<br />
<Text>
Some information as specified in the Data Retention section may be
retained even after deleting your data. The rights and options listed
above are subject to limitations based on applicable law.
</Text>
<br />
<Heading size="lg">Contact Us</Heading>
<br />
<Text>
By Email:{" "}
<Link color="#646cff" href="mailto:privacy@ccdiscussion.com">
privacy@ccdiscussion.com
</Link>
</Text>
<br />
<Text>
Discord:{" "}
<Link
color="#646cff"
href="https://discord.com/invite/carcrushers"
target="_blank"
>
https://discord.com/invite/carcrushers
</Link>
</Text>
</Container>
);
}

254
app/routes/report.tsx Normal file
View File

@ -0,0 +1,254 @@
import {
Button,
CircularProgress,
CircularProgressLabel,
Container,
FormControl,
FormLabel,
Heading,
HStack,
Input,
Link,
Text,
useToast,
} from "@chakra-ui/react";
import { useEffect, useState } from "react";
import { useLoaderData } from "@remix-run/react";
import Success from "../../components/Success.js";
export async function loader({
context,
}: {
context: RequestContext;
}): Promise<{ logged_in: boolean; site_key: string }> {
return {
logged_in: Boolean(context.data.current_user),
site_key: context.env.TURNSTILE_SITEKEY,
};
}
export default function () {
const [fileProgress, setFileProgress] = useState(0);
const [showSuccess, setShowSuccess] = useState(false);
const [supportsRequestStreams, setSupportsRequestStreams] = useState(false);
const toast = useToast();
const [uploading, setUploading] = useState(false);
useEffect(() => {
if (sessionStorage.getItem("REPORT_SUCCESS")) {
sessionStorage.removeItem("REPORT_SUCCESS");
return setShowSuccess(true);
}
setSupportsRequestStreams(
(() => {
let duplexAccessed = false;
const hasContentType = new Request("", {
body: new ReadableStream(),
method: "POST",
// @ts-ignore
get duplex() {
duplexAccessed = true;
return "half";
},
}).headers.has("Content-Type");
return duplexAccessed && !hasContentType;
})()
);
}, []);
async function submit() {
const usernames = (
document.getElementById("usernames") as HTMLInputElement
).value
.replaceAll(" ", "")
.split(",");
const file = (
document.getElementById("evidence") as HTMLInputElement
).files?.item(0);
if (!usernames.length)
return toast({
description: "Must provide at least one username",
isClosable: true,
status: "error",
title: "Error",
});
if (!file)
return toast({
description: "Must attach a file",
isClosable: true,
status: "error",
title: "Error",
});
if (usernames.length > 20)
return toast({
description: "Only up to twenty users can be reported at a time",
isClosable: true,
status: "error",
title: "Too Many Usernames",
});
const submitReq = await fetch("/api/reports/submit", {
body: JSON.stringify({
filename: file.name,
filesize: file.size,
usernames,
}),
headers: {
"content-type": "application/json",
},
method: "POST",
});
if (!submitReq.ok)
return toast({
description: ((await submitReq.json()) as { error: string }).error,
isClosable: true,
status: "error",
title: "Error",
});
const { id, upload_url }: { id: string; upload_url: string } =
await submitReq.json();
setUploading(true);
const reader = file.stream().getReader();
let bytesRead = 0;
const uploadReq = await fetch(upload_url, {
body: supportsRequestStreams
? new ReadableStream({
async pull(controller) {
const chunk = await reader.read();
if (chunk.done) {
controller.close();
setUploading(false);
return;
}
controller.enqueue(chunk.value);
bytesRead += chunk.value.length;
setFileProgress(Math.floor((bytesRead / file.size) * 100));
},
})
: file,
// @ts-expect-error
duplex: supportsRequestStreams ? "half" : undefined,
headers: {
"content-type": file.type,
},
method: "PUT",
}).catch(console.error);
if (!uploadReq?.ok) {
await fetch("/api/reports/recall", {
body: JSON.stringify({ id }),
headers: {
"content-type": "application/json",
},
method: "POST",
});
return toast({
description: "Failed to upload file",
isClosable: true,
status: "error",
title: "Error",
});
}
await fetch("/api/reports/complete", {
body: JSON.stringify({ id }),
headers: {
"content-type": "application/json",
},
method: "POST",
});
sessionStorage.setItem("REPORT_SUCCESS", "1");
}
const { logged_in, site_key } = useLoaderData<typeof loader>();
return showSuccess ? (
<Success
heading="Report Submitted"
message="We will review it as soon as possible."
/>
) : (
<>
<Container maxW="container.md" pt="4vh" textAlign="start">
<Heading mb="4vh">Report an Exploiter</Heading>
<br />
<FormControl isRequired>
<FormLabel>
Username(s) - To specify more than one, provide a comma-delimited
list (User1, User2, User3...)
</FormLabel>
<Input id="usernames" placeholder="builderman" />
</FormControl>
<br />
<FormControl isRequired>
<FormLabel>Your Evidence (Max Size: 512MB)</FormLabel>
<Button
colorScheme="blue"
mr="8px"
onClick={() => document.getElementById("evidence")?.click()}
>
Select File
</Button>
<input id="evidence" type="file" />
</FormControl>
<br />
<br />
<Text>
By submitting this form, you agree to the{" "}
<Link color="#646cff" href="/terms">
Terms of Service
</Link>{" "}
and{" "}
<Link color="#646cff" href="/privacy">
Privacy Policy
</Link>
.
</Text>
<br />
<HStack>
<Button
disabled={uploading}
mr="8px"
onClick={async () => await submit()}
>
Submit
</Button>
<CircularProgress
display={uploading ? "" : "none"}
isIndeterminate={!supportsRequestStreams}
value={supportsRequestStreams ? fileProgress : undefined}
>
{supportsRequestStreams ? (
<CircularProgressLabel>{fileProgress}%</CircularProgressLabel>
) : null}
</CircularProgress>
</HStack>
<div
className="cf-turnstile"
data-sitekey={useLoaderData<typeof loader>()}
></div>
</Container>
{logged_in ? null : (
<script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
async
defer
></script>
)}
</>
);
}

136
app/routes/support.tsx Normal file
View File

@ -0,0 +1,136 @@
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
Container,
Heading,
Link,
Spacer,
VStack,
} from "@chakra-ui/react";
export default function () {
return (
<Container
borderRadius="12px"
borderWidth="1px"
maxW="container.md"
mt="8vh"
>
<VStack w="100%" spacing={3}>
<Spacer />
<Heading alignSelf="start" pl="2.5%" size="md">
What do you need help with?
</Heading>
<Spacer />
<Accordion textAlign="left" w="100%">
<AccordionItem>
<h2>
<AccordionButton>
<Box as="span" flex="1" textAlign="left">
I want to report someone exploiting
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel>
To report a player,{" "}
<Link color="#646cff" href="/report">
head to our report page.
</Link>
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<h2>
<AccordionButton>
<Box as="span" flex="1" textAlign="left">
I want a data rollback or transfer
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel>
Please join our{" "}
<Link
color="#646cff"
href="https://discord.com/invite/carcrushers"
>
Discord server
</Link>{" "}
and contact ModMail.
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<h2>
<AccordionButton>
<Box as="span" flex="1" textAlign="left">
I want to appeal my ban
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel>
If you were banned from our Discord server,{" "}
<Link color="#646cff" href="/appeals">
use this form
</Link>
. If you were banned from the game,{" "}
<Link
color="#646cff"
href="https://www.roblox.com/games/527921900/Car-Crushers-2-Appeals"
>
fill out the form here
</Link>
.
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<h2>
<AccordionButton>
<Box as="span" flex="1" textAlign="left">
I want to apply for a staff position
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel>
Most staff position openings will be announced in our{" "}
<Link
color="#646cff"
href="https://discord.com/invite/carcrushers"
>
Discord server
</Link>
. Forum mod openings are generally announced on the forum.
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<h2>
<AccordionButton>
<Box as="span" flex="1" textAlign="left">
My problem is not listed
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel>
Join our{" "}
<Link
color="#646cff"
href="https://discord.com/invite/carcrushers"
>
Discord server
</Link>{" "}
and open a ticket with ModMail.
</AccordionPanel>
</AccordionItem>
</Accordion>
<Spacer />
<Spacer />
</VStack>
</Container>
);
}

54
app/routes/team.tsx Normal file
View File

@ -0,0 +1,54 @@
import {
Card,
CardFooter,
Code,
Container,
Divider,
Heading,
Image,
Stack,
Text,
} from "@chakra-ui/react";
import team from "../../data/team.json";
export default function () {
return (
<Container maxW="container.xl" pt="4vh">
<Heading textAlign="start">Our Team</Heading>
<br />
<Text textAlign="start">
Please respect our staff, and <u>do not send direct messages</u> or
friend requests in place of official channels.
</Text>
<br />
<div
style={{
display: "grid",
gap: "1rem",
gridTemplateColumns: "repeat(auto-fill, minmax(16rem, 1fr))",
width: "100%",
}}
>
{team.map((member) => (
<Card key={member.id} maxW="xs" p="12px">
<Image
alt={member.tag + "'s avatar"}
borderRadius="50%"
src={`/files/avatars/${member.id}.webp`}
/>
<Stack mb="8" mt="6" spacing="3">
<b>
<Heading size="md">{member.tag}</Heading>
</b>
<Text>{member.position}</Text>
</Stack>
<Divider />
<CardFooter justifyContent="center">
<Code>ID: {member.id}</Code>
</CardFooter>
</Card>
))}
</div>
</Container>
);
}

120
app/routes/terms.tsx Normal file
View File

@ -0,0 +1,120 @@
import { Container, Heading, Link, Text } from "@chakra-ui/react";
export default function () {
return (
<Container maxW="container.lg" pb="8vh" pt="4vh" textAlign="start">
<Heading>Terms and Conditions</Heading>
<br />
<Text>Last Updated: 2023-01-07</Text>
<br />
<Text>Yes, we know this shit is boring to read, but it's important.</Text>
<br />
<hr />
<br />
<Text>These terms govern your use of the Car Crushers website.</Text>
<br />
<Text>
You would think people have common sense but sadly many don't.
</Text>
<br />
<Text>For this reason, we have to create this document.</Text>
<br />
<br />
<Heading size="lg">Definitions</Heading>
<br />
<ul>
<li>We, Us: Car Crushers (the operator of this website)</li>
<li>You: The person currently reading this document</li>
</ul>
<br />
<br />
<Heading size="lg">General Rules</Heading>
<br />
<ul>
<li>Do not upload malicious files to this site</li>
<li>Do not submit spam using forms on this site</li>
<li>Do not upload any content illegal under the laws of Sweden</li>
<li>You must be at least 13 years old to use this site</li>
<li>
You may not automate access to this site by any means (except a public
search crawler if you operate one)
</li>
<li>
You may not falsely imply that you are affiliated with or endorsed by
Car Crushers
</li>
<li>
<Link color="#646cff" href="/files/why.jpg" target="_blank">
All visitors from New Jersey must explain why
</Link>
</li>
</ul>
<br />
<br />
<Heading size="lg">Enforcement</Heading>
<br />
<Text>
We may investigate and prosecute violations of these terms to the
fullest legal extent. We may notify and cooperate with law enforcement
authorities in prosecuting violations of the law and these terms.
</Text>
<br />
<br />
<Heading size="lg">Your Content</Heading>
<br />
<Text>
Nothing in these terms grant us ownership rights to any content that you
submit to this site. Nothing in these terms grants you ownership rights
to our intellectual property either. Any content you submit to this site
is your responsibility. Content you submit to us belongs to you. But at
a minimum, you license us to store the content and display it to
authorized users.
</Text>
<br />
<br />
<Heading size="lg">Warranty and Disclaimer</Heading>
<br />
<Text>
WE DO NOT GUARANTEE A BUG-FREE SITE, THAT IS IMPOSSIBLE. THERE IS
ABSOLUTELY NO WARRANTY WHATSOEVER, EXPRESS OR IMPLIED. YOUR USE OF THIS
SITE IS AT YOUR OWN RISK. THERE ARE NO GUARANTEES ON ANYTHING, NOT EVEN
THAT THIS SITE WILL EXIST TOMORROW.
</Text>
<br />
<br />
<Heading size="lg">Termination</Heading>
<br />
<Text>
We may terminate or suspend your access to the Service immediately,
without prior notice or liability, under our sole discretion, for any
reason whatsoever and without limitation, including but not limited to a
breach of the Terms.
</Text>
<br />
<Text>
The contract is fully terminated when all user data (including every
copy of every file uploaded) is fully deleted from our service.
</Text>
<br />
<br />
<Heading size="lg">Indemnification</Heading>
<br />
<Text>
You agree to defend, indemnify and hold harmless Car Crushers and its
licensee and licensors, and their employees, contractors, agents,
officers and directors, from and against any and all claims, damages,
obligations, losses, liabilities, costs or debt, and expenses (including
but not limited to attorney's fees), resulting from or arising out of a)
your use and access of the Service, or b) a breach of these Terms.
</Text>
<br />
<br />
<Heading size="lg">Governing Law</Heading>
<br />
<Text>
These terms shall be governed and construed in accordance with the laws
of Västmanland, Sweden.
</Text>
</Container>
);
}