Compare commits
34 Commits
017ce48835
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
26bef47403
|
|||
|
47df3dc55f
|
|||
|
994a7a7a58
|
|||
|
2ca8cc163d
|
|||
|
8e34e2ce24
|
|||
|
7352d0bb43
|
|||
|
291afd5eaa
|
|||
|
e0eceffeb3
|
|||
|
187ba2f0f6
|
|||
|
7490659467
|
|||
|
18947e8446
|
|||
|
c9b32cb581
|
|||
|
ddab8a9a71
|
|||
|
35612f34dd
|
|||
|
d09360c390
|
|||
|
9ab695c6a7
|
|||
|
7d29433c36
|
|||
|
d613cc1801
|
|||
|
f5e2110ff4
|
|||
|
97d84bdc17
|
|||
|
c2251ecfd4
|
|||
|
703510afa7
|
|||
|
6152dd27e0
|
|||
|
09fa78e7e0
|
|||
|
ba88360636
|
|||
|
c3eda8245f
|
|||
|
a9936afba2
|
|||
|
704ffd280b
|
|||
|
b054fb7fd5
|
|||
|
7c7448137a
|
|||
|
f2f01f42c2
|
|||
|
e5a00d69a6
|
|||
|
25c9049563
|
|||
|
81b845c9d1
|
94
.gitea/workflows/build-publish.yaml
Normal file
@@ -0,0 +1,94 @@
|
||||
name: Test, Build, Deploy
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
Test-Build-Deploy:
|
||||
name: Test, Build, and Deploy
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_PROJECT_NAME: ${{ vars.CLOUDFLARE_PROJECT_NAME }}
|
||||
COMMIT_SHA: ${{ gitea.sha }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
|
||||
- name: Install Wrangler
|
||||
run: npm install -g wrangler
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci --include=dev
|
||||
|
||||
- name: Check Formatting
|
||||
run: npm run check-format
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Set Version as Var
|
||||
run: |
|
||||
curl https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/pages/projects/$CLOUDFLARE_PROJECT_NAME \
|
||||
-X PATCH \
|
||||
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"deployment_configs": {
|
||||
"production": {
|
||||
"env_vars": {
|
||||
"COMMIT_SHA": {
|
||||
"type": "plain_text",
|
||||
"value": "'"$COMMIT_SHA"'"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}'
|
||||
|
||||
- name: Deploy
|
||||
run: wrangler pages deploy public --project-name $CLOUDFLARE_PROJECT_NAME
|
||||
|
||||
Sentry-Release:
|
||||
name: Create Sentry Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: Test-Build-Deploy
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
|
||||
|
||||
- name: Install Sentry SDK
|
||||
run: |
|
||||
if ! [ -x "$(command -v sentry-cli)" ]; then
|
||||
curl -sL https://sentry.io/get-cli/ | bash
|
||||
fi
|
||||
|
||||
- name: Create Remix Release
|
||||
run: |
|
||||
VERSION=$(sentry-cli releases propose-version)
|
||||
sentry-cli releases new $VERSION
|
||||
sentry-cli releases set-commits --auto $VERSION --ignore-missing
|
||||
sentry-cli releases finalize $VERSION
|
||||
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ vars.SENTRY_REMIX_PROJECT }}
|
||||
|
||||
- name: Create API Release
|
||||
run: |
|
||||
VERSION=$(sentry-cli releases propose-version)
|
||||
sentry-cli releases new $VERSION
|
||||
sentry-cli releases set-commits --auto $VERSION --ignore-missing
|
||||
sentry-cli releases finalize $VERSION
|
||||
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ vars.SENTRY_API_PROJECT }}
|
||||
@@ -1 +1 @@
|
||||
v22.20.0
|
||||
v24.14.0
|
||||
@@ -2,16 +2,35 @@ import { CacheProvider } from "@emotion/react";
|
||||
import { ClientStyleContext } from "./context.js";
|
||||
import createEmotionCache from "./createEmotionCache.js";
|
||||
import { hydrateRoot } from "react-dom/client";
|
||||
import { RemixBrowser } from "@remix-run/react";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { type ReactNode, StrictMode, useState } from "react";
|
||||
import { RemixBrowser, useLocation, useMatches } from "@remix-run/react";
|
||||
import * as Sentry from "@sentry/remix";
|
||||
import { type ReactNode, StrictMode, useEffect, useState } from "react";
|
||||
|
||||
Sentry.init({
|
||||
dsn:
|
||||
document.querySelector("meta[name='dsn']")?.getAttribute("content") ??
|
||||
undefined,
|
||||
integrations: [Sentry.browserTracingIntegration()],
|
||||
integrations: [
|
||||
Sentry.browserTracingIntegration({
|
||||
useEffect,
|
||||
useLocation,
|
||||
useMatches,
|
||||
}),
|
||||
Sentry.replayIntegration({
|
||||
blockAllMedia: false,
|
||||
maskAllInputs: false,
|
||||
maskAllText: false,
|
||||
}),
|
||||
],
|
||||
replaysOnErrorSampleRate: 1,
|
||||
replaysSessionSampleRate: 0.02,
|
||||
release:
|
||||
document
|
||||
.querySelector("meta[name='commit_sha']")
|
||||
?.getAttribute("content") ?? undefined,
|
||||
sendDefaultPii: true,
|
||||
tracesSampleRate: 0.1,
|
||||
tunnel: "/api/st",
|
||||
});
|
||||
|
||||
function ClientCacheProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { type EntryContext } from "@remix-run/cloudflare";
|
||||
import { RemixServer } from "@remix-run/react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { ServerStyleContext } from "./context.js";
|
||||
import * as Sentry from "@sentry/remix";
|
||||
|
||||
export default function handleRequest(
|
||||
request: Request,
|
||||
@@ -39,3 +40,5 @@ export default function handleRequest(
|
||||
status: responseStatusCode,
|
||||
});
|
||||
}
|
||||
|
||||
export const handleError = Sentry.sentryHandleError;
|
||||
|
||||
144
app/root.tsx
@@ -18,6 +18,7 @@ import {
|
||||
Scripts,
|
||||
useLoaderData,
|
||||
useRouteError,
|
||||
useRouteLoaderData,
|
||||
} from "@remix-run/react";
|
||||
import { type ErrorResponse } from "@remix-run/router";
|
||||
import { LinksFunction } from "@remix-run/cloudflare";
|
||||
@@ -26,71 +27,89 @@ import Navigation from "../components/Navigation.js";
|
||||
import { type ReactNode, StrictMode, useContext, useEffect } from "react";
|
||||
import theme from "../theme.js";
|
||||
import { withEmotionCache } from "@emotion/react";
|
||||
import {
|
||||
captureRemixErrorBoundaryError,
|
||||
setUser,
|
||||
withSentry,
|
||||
} from "@sentry/remix";
|
||||
|
||||
export function ErrorBoundary() {
|
||||
const error = useRouteError() as ErrorResponse;
|
||||
|
||||
if (!isRouteErrorResponse(error))
|
||||
return getMarkup(
|
||||
{ hide: true },
|
||||
<Container maxW="container.lg" pt="8vh" textAlign="left">
|
||||
<Heading size="4xl">???</Heading>
|
||||
<br />
|
||||
<Text fontSize="xl">Something bad happened!</Text>
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<Text>Details: {error}</Text>
|
||||
<br />
|
||||
<br />
|
||||
<Link color="#646cff" onClick={() => location.reload()}>
|
||||
Refresh
|
||||
</Link>
|
||||
</Container>,
|
||||
return (
|
||||
<DocumentWrapper loaderData={{ hide: true }}>
|
||||
<Container maxW="container.lg" pt="8vh" textAlign="left">
|
||||
<Heading size="4xl">???</Heading>
|
||||
<br />
|
||||
<Text fontSize="xl">Something bad happened!</Text>
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<Text>Details: {error}</Text>
|
||||
<br />
|
||||
<br />
|
||||
<Link color="#646cff" onClick={() => location.reload()}>
|
||||
Refresh
|
||||
</Link>
|
||||
</Container>
|
||||
</DocumentWrapper>
|
||||
);
|
||||
|
||||
const { status } = error;
|
||||
const loaderData = useRouteLoaderData<typeof loader>("root") || {};
|
||||
|
||||
switch (status) {
|
||||
case 303:
|
||||
return "";
|
||||
|
||||
case 401:
|
||||
return getMarkup({ hide: true }, <Login />);
|
||||
return (
|
||||
<DocumentWrapper loaderData={loaderData}>
|
||||
<Login />
|
||||
</DocumentWrapper>
|
||||
);
|
||||
|
||||
case 403:
|
||||
return getMarkup({ hide: true }, <Forbidden />);
|
||||
return (
|
||||
<DocumentWrapper loaderData={loaderData}>
|
||||
<Forbidden />
|
||||
</DocumentWrapper>
|
||||
);
|
||||
|
||||
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>,
|
||||
return (
|
||||
<DocumentWrapper loaderData={{ ...loaderData, 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>
|
||||
</DocumentWrapper>
|
||||
);
|
||||
|
||||
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>,
|
||||
captureRemixErrorBoundaryError(useRouteError());
|
||||
return (
|
||||
<DocumentWrapper loaderData={loaderData}>
|
||||
<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>
|
||||
</DocumentWrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -111,8 +130,9 @@ export async function loader({
|
||||
}): Promise<{ [k: string]: any }> {
|
||||
let data: { [k: string]: string } = {};
|
||||
|
||||
if (context.env.COMMIT_SHA) data.commit_sha = context.env.COMMIT_SHA;
|
||||
if (context.data.current_user) data = { ...context.data.current_user };
|
||||
if (context.env.DSN) data.dsn = context.env.DSN;
|
||||
if (context.env.REMIX_DSN) data.dsn = context.env.REMIX_DSN;
|
||||
if (context.data.nonce) data.nonce = context.data.nonce;
|
||||
if (context.data.theme) data.theme = context.data.theme;
|
||||
|
||||
@@ -123,10 +143,11 @@ export function meta() {
|
||||
return [{ title: "Car Crushers" }];
|
||||
}
|
||||
|
||||
function getMarkup(
|
||||
loaderData: { [k: string]: any },
|
||||
child: ReactNode,
|
||||
): JSX.Element {
|
||||
function DocumentWrapper(props: {
|
||||
loaderData: { [k: string]: any };
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { children: child, loaderData } = props;
|
||||
const Document = withEmotionCache(
|
||||
({ children }: { children: ReactNode }, emotionCache) => {
|
||||
const serverStyleData = useContext(ServerStyleContext);
|
||||
@@ -182,6 +203,9 @@ function getMarkup(
|
||||
/>
|
||||
))}
|
||||
<meta charSet="UTF-8" />
|
||||
{loaderData.commit_sha ? (
|
||||
<meta name="commit_sha" content={loaderData.commit_sha} />
|
||||
) : null}
|
||||
{loaderData.dsn ? (
|
||||
<meta name="dsn" content={loaderData.dsn} />
|
||||
) : null}
|
||||
@@ -207,8 +231,26 @@ function getMarkup(
|
||||
return <Document>{child}</Document>;
|
||||
}
|
||||
|
||||
export default function () {
|
||||
function App() {
|
||||
const loaderData = useLoaderData<typeof loader>();
|
||||
|
||||
return getMarkup(loaderData, <Outlet />);
|
||||
if (
|
||||
loaderData.id &&
|
||||
[0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].find(
|
||||
(p) => loaderData.permissions & (1 << p),
|
||||
)
|
||||
)
|
||||
setUser({
|
||||
email: loaderData.email,
|
||||
id: loaderData.id,
|
||||
username: loaderData.username,
|
||||
});
|
||||
|
||||
return (
|
||||
<DocumentWrapper loaderData={loaderData}>
|
||||
<Outlet />
|
||||
</DocumentWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default withSentry(App);
|
||||
|
||||
@@ -7,4 +7,4 @@ export default function () {
|
||||
message="Your request is now being processed; this normally takes 1-2 weeks."
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,4 +31,4 @@ export default function () {
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,29 +35,24 @@ export async function loader({ context }: { context: RequestContext }) {
|
||||
month = 12;
|
||||
}
|
||||
|
||||
const eventMemberQuery = await context.env.D1.prepare(
|
||||
"SELECT id, name, roblox_id FROM et_members;",
|
||||
).all();
|
||||
const inactivityQuery: D1Result<Record<string, any>> =
|
||||
await context.env.D1.prepare(
|
||||
"SELECT decisions, json_extract(user, '$.id') AS uid FROM inactivity_notices WHERE (end BETWEEN DATE('now', 'start of month', '-1 month') AND DATE('now', 'start of month', '-1 day')) OR (start BETWEEN DATE('now', 'start of month', '-1 month') AND DATE('now', 'start of month', '-1 day'));",
|
||||
).all();
|
||||
const batchStatements: D1Result<Record<string, any>>[] =
|
||||
await context.env.D1.batch([
|
||||
context.env.D1.prepare("SELECT id, name, roblox_id FROM et_members;"),
|
||||
context.env.D1.prepare(
|
||||
"SELECT decisions, json_extract(user, '$.id') AS uid FROM inactivity_notices WHERE (end BETWEEN DATE('now', 'start of month', '-1 month') AND DATE('now', 'start of month', '-1 day')) OR (start BETWEEN DATE('now', 'start of month', '-1 month') AND DATE('now', 'start of month', '-1 day'));",
|
||||
),
|
||||
context.env.D1.prepare(
|
||||
"SELECT approved, answered_at, created_by, performed_at, reached_minimum_player_count, type FROM events WHERE month = ? AND year = ?;",
|
||||
).bind(month, year),
|
||||
]);
|
||||
|
||||
const eventsQuery = await context.env.D1.prepare(
|
||||
"SELECT approved, answered_at, created_by, performed_at, reached_minimum_player_count, type FROM events WHERE month = ? AND year = ?;",
|
||||
)
|
||||
.bind(month, year)
|
||||
.all();
|
||||
const memberMap = Object.fromEntries(
|
||||
eventMemberQuery.results.map((entry) => {
|
||||
return [
|
||||
entry.id,
|
||||
{ name: entry.name, points: 0, roblox_id: entry.roblox_id },
|
||||
];
|
||||
batchStatements[0].results.map((e) => {
|
||||
return [e.id, { name: e.name, points: 0, roblox_id: e.roblox_id }];
|
||||
}),
|
||||
);
|
||||
|
||||
for (const event of eventsQuery.results as {
|
||||
for (const event of batchStatements[2].results as {
|
||||
approved: number;
|
||||
answered_at: number;
|
||||
created_by: string;
|
||||
@@ -77,10 +72,10 @@ export async function loader({ context }: { context: RequestContext }) {
|
||||
for (const member of Object.keys(memberMap))
|
||||
if (
|
||||
(memberMap[member].points < 50 ||
|
||||
eventsQuery.results.filter(
|
||||
batchStatements[2].results.filter(
|
||||
(e) => e.type === "gamenight" && e.created_by === member,
|
||||
).length === 0) &&
|
||||
!inactivityQuery.results.find(
|
||||
!batchStatements[1].results.find(
|
||||
(i) => i.uid === member && JSON.parse(i.decisions).et,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -33,37 +33,31 @@ export async function loader({ context }: { context: RequestContext }) {
|
||||
|
||||
if (!currentUser) throw new Response(null, { status: 401 });
|
||||
|
||||
const d1Promises = [];
|
||||
const batchStatements = [];
|
||||
|
||||
for (const itemType of ["appeals", "inactivity_notices", "reports"])
|
||||
d1Promises.push(
|
||||
for (const itemType of ["appeals", "inactivity_notices", "reports"]) {
|
||||
batchStatements.push(
|
||||
context.env.D1.prepare(
|
||||
`SELECT *
|
||||
FROM ${itemType}
|
||||
WHERE json_extract(user, '$.id') = ?
|
||||
ORDER BY created_at DESC;`,
|
||||
)
|
||||
.bind(currentUser.id)
|
||||
.all(),
|
||||
`SELECT * FROM ${itemType} WHERE json_extract(user, '$.id') = ? ORDER BY created_at DESC;`,
|
||||
).bind(currentUser.id),
|
||||
);
|
||||
|
||||
const settledPromises = await Promise.allSettled(d1Promises);
|
||||
let etData: { [k: string]: any } | null = null;
|
||||
|
||||
if (currentUser.permissions & (1 << 3)) {
|
||||
etData = await context.env.D1.prepare(
|
||||
"SELECT name, points, roblox_id FROM et_members WHERE id = ?;",
|
||||
)
|
||||
.bind(currentUser.id)
|
||||
.first();
|
||||
}
|
||||
|
||||
return {
|
||||
etData,
|
||||
items: settledPromises.map((p) => {
|
||||
if (p.status === "fulfilled") return p.value.results;
|
||||
if (currentUser.permissions & (1 << 3))
|
||||
batchStatements.push(
|
||||
context.env.D1.prepare(
|
||||
"SELECT name, points, roblox_id FROM et_members WHERE id = ? LIMIT 1;",
|
||||
).bind(currentUser.id),
|
||||
);
|
||||
|
||||
return null;
|
||||
const batchResults = await context.env.D1.batch(batchStatements);
|
||||
|
||||
return {
|
||||
etData: batchResults.at(3)?.results?.at(0) as { [k: string]: any } | null,
|
||||
items: batchResults.map((r) => {
|
||||
if (r.success) return r.results;
|
||||
|
||||
return [];
|
||||
}) as any as ({ [k: string]: any }[] | null)[],
|
||||
permissions: currentUser.permissions as number,
|
||||
};
|
||||
|
||||
@@ -116,7 +116,8 @@ export default function () {
|
||||
data storage provider.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Google, LLC. (Mountain View, CA)</strong>: Push notifications (if you use the app).
|
||||
<strong>Google, LLC. (Mountain View, CA)</strong>: Push notifications
|
||||
(if you use the app).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Mailgun Technologies, Inc. (San Antonio, TX)</strong>: Email
|
||||
|
||||
78
app/routes/proj-disp.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
Container,
|
||||
Divider,
|
||||
Heading,
|
||||
Image,
|
||||
Link,
|
||||
Text,
|
||||
} from "@chakra-ui/react";
|
||||
import projects from "../../data/public-projects.json";
|
||||
|
||||
export function meta() {
|
||||
return [
|
||||
{
|
||||
title: "For Hiring Teams - Car Crushers",
|
||||
},
|
||||
{
|
||||
name: "robots",
|
||||
content: "noindex",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default function () {
|
||||
return (
|
||||
<Container maxW="container.xl">
|
||||
<Heading textAlign="start">About Evan</Heading>
|
||||
<br />
|
||||
<Text textAlign="start">
|
||||
Hello! Thank you for your interest in me as a candidate. Are you
|
||||
wondering if I did all those things I mentioned on my resume? If so,
|
||||
yes, I did. Below is a list of some things I have worked on during my
|
||||
time here, some of them more strongly related than others. Thank you for
|
||||
taking the time to look at a history of my past and current work.
|
||||
</Text>
|
||||
<br />
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: "1rem",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(16rem, 1fr))",
|
||||
justifyItems: "center",
|
||||
}}
|
||||
>
|
||||
{projects.map((project) => (
|
||||
<Card borderRadius="36px" key={project.name} p="12px" w="100%">
|
||||
<Image
|
||||
alt={`Logo for ${project.name}`}
|
||||
borderRadius="36px"
|
||||
src={`/files/${project.image}`}
|
||||
/>
|
||||
<CardHeader>
|
||||
<b>
|
||||
<Heading size="md">{project.name}</Heading>
|
||||
</b>
|
||||
<Text>Time Span: {project.timespan}</Text>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Text>{project.description}</Text>
|
||||
</CardBody>
|
||||
<Divider />
|
||||
<CardFooter gap="16px" justifyContent="center">
|
||||
{project.work_link ? (
|
||||
<Link href={project.work_link}>Project Work</Link>
|
||||
) : null}
|
||||
{project.landing_link ? (
|
||||
<Link href={project.landing_link}>Project Site</Link>
|
||||
) : null}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -229,8 +229,10 @@ export default function () {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
// @ts-expect-error
|
||||
turnstile.reset();
|
||||
if (!logged_in) {
|
||||
// @ts-expect-error
|
||||
turnstile.reset();
|
||||
}
|
||||
|
||||
return toast({
|
||||
description: "Failed to upload file",
|
||||
|
||||
@@ -34,11 +34,11 @@ export default function () {
|
||||
display: "grid",
|
||||
gap: "1rem",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(16rem, 1fr))",
|
||||
width: "100%",
|
||||
justifyItems: "center",
|
||||
}}
|
||||
>
|
||||
{team.map((member) => (
|
||||
<Card borderRadius="36px" key={member.id} maxW="xs" p="12px">
|
||||
<Card borderRadius="36px" key={member.id} p="12px" width="100%">
|
||||
<Image
|
||||
alt={member.tag + "'s avatar"}
|
||||
borderRadius="50%"
|
||||
|
||||
40
data/public-projects.json
Normal file
@@ -0,0 +1,40 @@
|
||||
[
|
||||
{
|
||||
"name": "RoVer",
|
||||
"image": "public-projects-icons/rover.webp",
|
||||
"work_link": "https://github.com/evaera/RoVer/commits?author=Regalijan",
|
||||
"landing_link": "https://rover.link/",
|
||||
"timespan": "2020 - 2021",
|
||||
"description": "I contributed to RoVer, a Discord bot that links Roblox accounts to manage server access. Working on the very first version gave me hands-on experience shipping production software and collaborating in an open-source project. The current iteration supports 370k+ servers and 15M+ users, underscoring the importance of maintainable, scalable code."
|
||||
},
|
||||
{
|
||||
"name": "Car Crushers Web (This Website)",
|
||||
"image": "logo512.png",
|
||||
"landing_link": "/about",
|
||||
"timespan": "2021 - Present",
|
||||
"description": "A full-stack web platform I designed and built end-to-end, evolving from a basic HTML/Bootstrap prototype into a modern Remix v2 + React application powered by Cloudflare Workers. It supports internal and community tools for scheduling events, handling moderation workflows (reports, bans, and appeals), and managing time-off requests, with access control driven by Discord roles following the principle of least privilege."
|
||||
},
|
||||
{
|
||||
"name": "Noblox.js",
|
||||
"image": "public-projects-icons/noblox.png",
|
||||
"work_link": "https://github.com/noblox/noblox.js",
|
||||
"landing_link": "https://noblox.js.org/",
|
||||
"timespan": "2023 - 2025",
|
||||
"description": "Noblox is an open-source JavaScript library designed to simplify interactions with the Roblox API. My work on this project focused on improving functionality, code reliability, and documentation for the developer community. In 2024, I became a maintainer, taking on responsibilities such as reviewing pull requests, managing contributions, and coordinating with other developers to ensure code quality and consistency. Through this experience, I strengthened my skills in asynchronous JavaScript, API design, and open-source collaboration workflows. While active development has slowed as Roblox’s official APIs and documentation have matured, the project remains a valuable tool for developers and a meaningful example of community-driven software maintenance."
|
||||
},
|
||||
{
|
||||
"name": "Car Crushers Forum",
|
||||
"image": "public-projects-icons/cc-forums.png",
|
||||
"landing_link": "https://ccdiscussion.com/",
|
||||
"timespan": "2019 - Present",
|
||||
"description": "I set up and operated a Discourse forum on GCP using Debian, handling both application-level configuration and cloud infrastructure. Managed deployments, upgrades, and performance tuning while ensuring a secure, stable, and scalable environment for active users."
|
||||
},
|
||||
{
|
||||
"name": "Various Discourse Forum Plugins",
|
||||
"image": "public-projects-icons/discourse.png",
|
||||
"work_link": "https://github.com/Regalijan?tab=repositories&language=ruby",
|
||||
"landing_link": "https://meta.discourse.org/search?expanded=true&q=%40Wolftallemo%20%23plugin%20in%3Afirst",
|
||||
"timespan": "2021 - Present",
|
||||
"description": "While operating the Car Crushers Forum, I authored and maintained multiple Discourse plugins, and contributed patches upstream. Publicly available on Discourse Meta, these plugins improved user experience, made life better for forum administrators, and showcased a strong commitment to community-driven software."
|
||||
}
|
||||
]
|
||||
@@ -14,11 +14,6 @@
|
||||
"tag": "SkilledOn",
|
||||
"position": "Community Manager/Developer"
|
||||
},
|
||||
{
|
||||
"id": "272138724488249345",
|
||||
"tag": "Exoniphy",
|
||||
"position": "Developer"
|
||||
},
|
||||
{
|
||||
"id": "396347223736057866",
|
||||
"tag": "Regalijan",
|
||||
@@ -34,11 +29,6 @@
|
||||
"tag": "Dan",
|
||||
"position": "Head of Discord Moderation"
|
||||
},
|
||||
{
|
||||
"id": "385572238331478016",
|
||||
"tag": "Nahu",
|
||||
"position": "Head of Discord Moderation"
|
||||
},
|
||||
{
|
||||
"id": "151098768807165952",
|
||||
"tag": "Redious",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"DEVIL-DOGS",
|
||||
"DOUBT",
|
||||
"DREADNOUGHT",
|
||||
"DRUM-SHOW",
|
||||
"DUNDEE",
|
||||
"DYING-IN-LA",
|
||||
"EMERGENCY-CONTACT",
|
||||
@@ -23,6 +24,7 @@
|
||||
"FLAWLESS-EXECUTION",
|
||||
"FLU-GAME",
|
||||
"FORMIDABLE",
|
||||
"FROZEN-PISS-2",
|
||||
"GATES-OF-GLORY",
|
||||
"GIRLS-GIRLS-BOYS",
|
||||
"GONER",
|
||||
@@ -47,8 +49,11 @@
|
||||
"NOT-TODAY",
|
||||
"NO-CHANCES",
|
||||
"OVERCOMPENSATE",
|
||||
"P.A.R.T.Y.",
|
||||
"POLARIZE",
|
||||
"PSYCHO",
|
||||
"RAINBOW-VEINS",
|
||||
"RAWFEAR",
|
||||
"ROMANCE",
|
||||
"SAD-CLOWN",
|
||||
"SATURDAY",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import getPermissions from "./permissions.js";
|
||||
import { jsonError } from "./common.js";
|
||||
import * as Sentry from "@sentry/cloudflare";
|
||||
|
||||
async function constructHTML(context: RequestContext) {
|
||||
const { pathname } = new URL(context.request.url);
|
||||
@@ -30,8 +31,21 @@ async function generateTokenHash(token: string) {
|
||||
async function refreshAuth(context: RequestContext) {
|
||||
const { current_user: currentUser } = context.data;
|
||||
|
||||
if (!currentUser || currentUser.refresh_at > Date.now())
|
||||
return await context.next();
|
||||
if (!currentUser) return await context.next();
|
||||
|
||||
if (currentUser.refresh_at > Date.now()) {
|
||||
if (
|
||||
[0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].find(
|
||||
(p) => currentUser.permissions & (1 << p),
|
||||
)
|
||||
)
|
||||
Sentry.setUser({
|
||||
email: currentUser.email,
|
||||
id: currentUser.id,
|
||||
ip_address: context.request.headers.get("cf-connecting-ip"),
|
||||
username: currentUser.username,
|
||||
});
|
||||
}
|
||||
|
||||
const oauthData = await context.env.DATA.get(
|
||||
`oauthcredentials_${currentUser.id}`,
|
||||
@@ -94,6 +108,18 @@ async function refreshAuth(context: RequestContext) {
|
||||
: undefined,
|
||||
);
|
||||
|
||||
if (
|
||||
[0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].find(
|
||||
(p) => currentUser.permissions & (1 << p),
|
||||
)
|
||||
)
|
||||
Sentry.setUser({
|
||||
email: currentUser.email,
|
||||
id: currentUser.id,
|
||||
ip_address: context.request.headers.get("cf-connecting-ip"),
|
||||
username: currentUser.username,
|
||||
});
|
||||
|
||||
const tokenHash = await generateTokenHash(context.data.sid);
|
||||
|
||||
await context.env.DATA.put(`auth_${tokenHash}`, JSON.stringify(userData), {
|
||||
@@ -272,7 +298,8 @@ async function setAuth(context: RequestContext) {
|
||||
async function setBody(context: RequestContext) {
|
||||
if (
|
||||
["PATCH", "POST", "PUT"].includes(context.request.method) &&
|
||||
!context.request.url.endsWith("/api/infractions/new")
|
||||
!context.request.url.endsWith("/api/infractions/new") &&
|
||||
!context.request.url.endsWith("/api/st")
|
||||
) {
|
||||
if (
|
||||
!context.request.headers
|
||||
@@ -354,6 +381,11 @@ async function setTheme(context: RequestContext) {
|
||||
}
|
||||
|
||||
export const onRequest = [
|
||||
Sentry.sentryPagesPlugin((context: RequestContext) => ({
|
||||
dsn: context.env.FUNCTIONS_DSN,
|
||||
release: context.env.COMMIT_SHA,
|
||||
sendDefaultPii: true,
|
||||
})),
|
||||
setAuth,
|
||||
refreshAuth,
|
||||
setTheme,
|
||||
|
||||
@@ -19,5 +19,7 @@ export async function onRequestPost(context: RequestContext) {
|
||||
|
||||
await context.env.D1.prepare(
|
||||
"INSERT INTO game_mod_notes (content, created_at, created_by, id, target) VALUES (?, ?, ?, ?, ?);",
|
||||
).bind(content, Date.now(), context.data.current_user.id, id, target).first();
|
||||
)
|
||||
.bind(content, Date.now(), context.data.current_user.id, id, target)
|
||||
.first();
|
||||
}
|
||||
|
||||
@@ -75,13 +75,22 @@ export async function onRequestPost(context: RequestContext) {
|
||||
);
|
||||
|
||||
for (const username of usernames) {
|
||||
// Roblox thought this name was inappropriate, the standard username rules may not apply.
|
||||
const isResetUsername = username.match(/roblox_user_\d+/);
|
||||
|
||||
if (
|
||||
username.length < 3 ||
|
||||
username.length > 20 ||
|
||||
username.match(/_/g)?.length > 1 ||
|
||||
username.match(/\W/)
|
||||
!isResetUsername &&
|
||||
(username.length < 3 ||
|
||||
username.length > 20 ||
|
||||
username.match(/_/g)?.length > 1 ||
|
||||
username.match(/\W/))
|
||||
)
|
||||
return jsonError(`Username "${username}" is invalid`, 400);
|
||||
else if (isResetUsername && username.length > 30)
|
||||
return jsonError(
|
||||
`Username "${username}" is not a valid reset username`,
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const rbxSearchReq = await fetch(
|
||||
@@ -185,8 +194,7 @@ export async function onRequestPost(context: RequestContext) {
|
||||
|
||||
const reportId = `${Date.now()}${context.request.headers
|
||||
.get("cf-ray")
|
||||
?.split("-")
|
||||
?.at(0)}${crypto.randomUUID().replaceAll("-", "")}`;
|
||||
?.replace("-", "")}${crypto.randomUUID().replaceAll("-", "")}`;
|
||||
|
||||
const { current_user: currentUser } = context.data;
|
||||
if (filesToProcess.length)
|
||||
|
||||
35
functions/api/st.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { jsonError } from "../common.js";
|
||||
|
||||
export async function onRequestPost(context: RequestContext) {
|
||||
const clonedRequest = context.request.clone();
|
||||
const header = (await context.request.text()).split("\n").at(0);
|
||||
|
||||
if (!header) return jsonError("Failed to parse envelope header", 500);
|
||||
|
||||
const { dsn } = JSON.parse(header);
|
||||
|
||||
if (context.env.REMIX_DSN !== dsn) return jsonError("Bad or no DSN", 400);
|
||||
|
||||
const sentryUrl = new URL(dsn);
|
||||
|
||||
const resp = await fetch(
|
||||
`https://${sentryUrl.host}/api${sentryUrl.pathname}/envelope/`,
|
||||
{
|
||||
body: clonedRequest.body,
|
||||
headers: {
|
||||
"content-type": "application/x-sentry-envelope",
|
||||
"x-forwarded-for": context.request.headers.get(
|
||||
"cf-connecting-ip",
|
||||
) as string,
|
||||
},
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
|
||||
return new Response(await resp.text(), {
|
||||
headers: {
|
||||
"content-type": resp.headers.get("content-type") || "text/plain",
|
||||
},
|
||||
status: resp.status,
|
||||
});
|
||||
}
|
||||
4431
package-lock.json
generated
26
package.json
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "remix build --sourcemap",
|
||||
"check-format": "prettier -c .",
|
||||
"format": "prettier -wc .",
|
||||
"publish": "remix build --sourcemap && wrangler pages deploy public"
|
||||
},
|
||||
@@ -13,29 +14,30 @@
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@fontsource-variable/plus-jakarta-sans": "^5.2.8",
|
||||
"@remix-run/cloudflare": "^2.17.1",
|
||||
"@remix-run/cloudflare-pages": "^2.17.1",
|
||||
"@remix-run/react": "^2.17.1",
|
||||
"@sentry/react": "^10.21.0",
|
||||
"@remix-run/cloudflare": "^2.17.4",
|
||||
"@remix-run/cloudflare-pages": "^2.17.4",
|
||||
"@remix-run/react": "^2.17.4",
|
||||
"@sentry/cloudflare": "^10.43.0",
|
||||
"@sentry/remix": "^10.43.0",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"dayjs": "^1.11.18",
|
||||
"framer-motion": "^12.23.24",
|
||||
"dayjs": "^1.11.19",
|
||||
"framer-motion": "^12.35.2",
|
||||
"react": "^18.3.1",
|
||||
"react-big-calendar": "^1.19.4",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@remix-run/dev": "^2.17.1",
|
||||
"@types/node": "^24.9.1",
|
||||
"@types/react": "^18.3.26",
|
||||
"@remix-run/dev": "^2.17.4",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^18.3.28",
|
||||
"@types/react-big-calendar": "^1.16.3",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"dotenv": "^17.2.3",
|
||||
"prettier": "^3.6.2",
|
||||
"dotenv": "^17.3.1",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"overrides": {
|
||||
"@cloudflare/workers-types": "^4.20251014.0"
|
||||
"@cloudflare/workers-types": "^4.20260310.1"
|
||||
},
|
||||
"prettier": {
|
||||
"endOfLine": "auto"
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 314 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 29 KiB |
BIN
public/files/public-projects-icons/cc-forums.png
Normal file
|
After Width: | Height: | Size: 217 KiB |
BIN
public/files/public-projects-icons/discourse.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
public/files/public-projects-icons/noblox.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
public/files/public-projects-icons/rover.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |