Initial release

This commit is contained in:
Rambo6Glaz 2023-07-01 22:21:59 +02:00
parent e52736b576
commit 9233ddec1c
33 changed files with 4405 additions and 483 deletions

View file

@ -1,3 +1,3 @@
{
"extends": "next/core-web-vitals"
}
}

2
.gitignore vendored
View file

@ -33,3 +33,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
app.config.ts

View file

@ -1,34 +1,11 @@
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).
# MK8 Pretendo website
## Getting Started
# Instructions
First, run the development server:
- Rename ``app.config.example.ts`` to ``app.config.ts`` and edit the configuration file to match your running env.
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [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.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
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.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
```shell
npm install
npm run build
npm run start
```

15
app.config.example.ts Normal file
View file

@ -0,0 +1,15 @@
interface AppConfig {
jwt_secret: string;
grpc_host: string;
grpc_port: number;
grpc_api_key: string;
}
const app_config: AppConfig = {
jwt_secret: "qsdqsdqsdqsdqsdqsdqsd",
grpc_host: "localhost",
grpc_port: 50051,
grpc_api_key: "azeazeaz"
}
export default app_config;

3
app/admin/page.tsx Normal file
View file

@ -0,0 +1,3 @@
export default async function AdminPage() {
return <h1>Hello, Admin Page!</h1>
}

View file

@ -0,0 +1,26 @@
import app_config from "@/app.config";
import { amkj_grpc_client } from "@/helpers/grpc";
import { GetAllGatheringsResponse } from "@/helpers/proto/amkj_service";
import { NextResponse } from "next/server";
import { Metadata } from "nice-grpc";
var allGatherings: GetAllGatheringsResponse | null = null;
var lastAllGatheringsTime: Date = new Date();
export async function GET(request: Request) {
request.url; // https://nextjs.org/docs/app/building-your-application/routing/router-handlers#dynamic-route-handlers
try {
if (!allGatherings || ((new Date().getTime() - lastAllGatheringsTime.getTime()) > 5000)) {
allGatherings = await amkj_grpc_client.getAllGatherings({ offset: 0, limit: -1 }, {
metadata: Metadata({
"X-API-Key": app_config.grpc_api_key
})
});
lastAllGatheringsTime = new Date();
}
return NextResponse.json(allGatherings);
} catch (err) {
return new NextResponse("{}", { status: 500 });
}
}

26
app/api/status/route.ts Normal file
View file

@ -0,0 +1,26 @@
import app_config from "@/app.config";
import { amkj_grpc_client } from "@/helpers/grpc";
import { GetServerStatusResponse } from "@/helpers/proto/amkj_service";
import { NextResponse } from "next/server";
import { Metadata } from "nice-grpc";
var status: GetServerStatusResponse | null = null;
var lastStatusTime: Date = new Date();
export async function GET(request: Request) {
request.url; // https://nextjs.org/docs/app/building-your-application/routing/router-handlers#dynamic-route-handlers
try {
if (!status || ((new Date().getTime() - lastStatusTime.getTime()) > 5000)) {
status = await amkj_grpc_client.getServerStatus({}, {
metadata: Metadata({
"X-API-Key": app_config.grpc_api_key
})
});
lastStatusTime = new Date();
}
return NextResponse.json(status);
} catch (err) {
return new NextResponse("{}", { status: 500 });
}
}

3
app/dashboard/page.tsx Normal file
View file

@ -0,0 +1,3 @@
export default async function DashboardPage() {
return <h1>Hello, Dashboard Page!</h1>
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

109
app/gatherings/page.tsx Normal file
View file

@ -0,0 +1,109 @@
'use client'
import { GatheringEntry } from '@/components/GatheringEntry';
import { Gathering } from '@/helpers/proto/amkj_service';
import { useEffect, useState } from 'react';
import { Alert, Spinner } from 'react-bootstrap';
export default function GatheringsPage() {
const updateTime = 20;
const [allGatheringsResponse, setAllGatheringsResponse] = useState<Response | null>(null);
const [allGatherings, setAllGatherings] = useState<Gathering[]>([]);
const [timer, setTimer] = useState<number>(updateTime - 1);
const fetchAllGatherings = async () => {
try {
const response = await fetch('/api/gatherings', { cache: "no-store" });
setAllGatheringsResponse(response);
const data = await response.json();
setAllGatherings(data.gatherings);
} catch (error) {
console.error('Failed to fetch all gatherings:', error);
setAllGatheringsResponse(null);
}
}
useEffect(() => {
fetchAllGatherings();
const fetchInterval = setInterval(fetchAllGatherings, updateTime * 1000);
const timerInterval = setInterval(() => {
setTimer((timer) => {
if (timer == 0) {
return updateTime - 1;
} else {
return timer - 1;
}
});
}, 1000);
return () => {
clearInterval(fetchInterval);
clearInterval(timerInterval)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const getPageJSX = () => {
if (!allGatheringsResponse) {
return <Spinner animation="border" role="status" />;
} else {
if (allGatheringsResponse.status != 200) {
return (
<Alert variant="danger" onClick={() => { document.location.reload() }}>
<Alert.Heading>Error fetching gatherings</Alert.Heading>
<p>
This error should never happen, try refreshing the page!
</p>
<hr />
<p>
If this happens again, contact the developers on the <Alert.Link href="https://discord.gg/pretendo">Pretendo Discord</Alert.Link>
</p>
</Alert>
);
} else {
if (allGatherings.length > 0) {
return (
<div className="d-flex flex-wrap justify-content-center">
{
allGatherings.map((gat, idx) => {
return (
<div className="ms-5 me-5 mb-3" key={idx}>
<GatheringEntry gathering={gat} />
</div>
);
})
}
</div>
);
} else {
return (
<Alert variant="primary" onClick={() => { document.location.reload() }}>
<Alert.Heading>No gatherings!</Alert.Heading>
<hr />
<p>
This means no players are currently in a matchmaking session, join the <Alert.Link href="https://discord.gg/pretendo">Pretendo Discord</Alert.Link> to communicate with other players.
</p>
</Alert>
)
}
}
}
}
return (
<>
<div className="container-fluid m-5 h-100 d-flex justify-content-center align-items-center">
<form className="form-floating text-center w-100 bg-light p-5 rounded-1 shadow-lg">
<h6><small className="text-muted">Refreshing in {timer} seconds ...</small></h6>
<h1 className="mb-3">Gatherings</h1>
<div>
{getPageJSX()}
</div>
</form>
</div>
</>
)
}

View file

@ -1,107 +0,0 @@
:root {
--max-width: 1100px;
--border-radius: 12px;
--font-mono: ui-monospace, Menlo, Monaco, 'Cascadia Mono', 'Segoe UI Mono',
'Roboto Mono', 'Oxygen Mono', 'Ubuntu Monospace', 'Source Code Pro',
'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace;
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
--primary-glow: conic-gradient(
from 180deg at 50% 50%,
#16abff33 0deg,
#0885ff33 55deg,
#54d6ff33 120deg,
#0071ff33 160deg,
transparent 360deg
);
--secondary-glow: radial-gradient(
rgba(255, 255, 255, 1),
rgba(255, 255, 255, 0)
);
--tile-start-rgb: 239, 245, 249;
--tile-end-rgb: 228, 232, 233;
--tile-border: conic-gradient(
#00000080,
#00000040,
#00000030,
#00000020,
#00000010,
#00000010,
#00000080
);
--callout-rgb: 238, 240, 241;
--callout-border-rgb: 172, 175, 176;
--card-rgb: 180, 185, 188;
--card-border-rgb: 131, 134, 135;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
--primary-glow: radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0));
--secondary-glow: linear-gradient(
to bottom right,
rgba(1, 65, 255, 0),
rgba(1, 65, 255, 0),
rgba(1, 65, 255, 0.3)
);
--tile-start-rgb: 2, 13, 46;
--tile-end-rgb: 2, 5, 19;
--tile-border: conic-gradient(
#ffffff80,
#ffffff40,
#ffffff30,
#ffffff20,
#ffffff10,
#ffffff10,
#ffffff80
);
--callout-rgb: 20, 20, 20;
--callout-border-rgb: 108, 108, 108;
--card-rgb: 100, 100, 100;
--card-border-rgb: 200, 200, 200;
}
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
}
a {
color: inherit;
text-decoration: none;
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
}

View file

@ -1,21 +1,24 @@
import './globals.css'
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'] })
import { NEXStatus } from '@/components/NEXStatus';
import NavigationBar from '@/components/NavigationBar';
import 'bootstrap/dist/css/bootstrap.min.css';
export const metadata = {
title: 'Create Next App',
description: 'Generated by create next app',
title: 'Pretendo MK8',
description: 'Pretendo Mario Kart 8 website',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
)
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<div>
<NavigationBar />
<div className="mt-0 container-fluid d-flex justify-content-center">
{children}
</div>
</div>
<NEXStatus />
</body>
</html>
)
}

View file

@ -1,229 +0,0 @@
.main {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
padding: 6rem;
min-height: 100vh;
}
.description {
display: inherit;
justify-content: inherit;
align-items: inherit;
font-size: 0.85rem;
max-width: var(--max-width);
width: 100%;
z-index: 2;
font-family: var(--font-mono);
}
.description a {
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
}
.description p {
position: relative;
margin: 0;
padding: 1rem;
background-color: rgba(var(--callout-rgb), 0.5);
border: 1px solid rgba(var(--callout-border-rgb), 0.3);
border-radius: var(--border-radius);
}
.code {
font-weight: 700;
font-family: var(--font-mono);
}
.grid {
display: grid;
grid-template-columns: repeat(4, minmax(25%, auto));
width: var(--max-width);
max-width: 100%;
}
.card {
padding: 1rem 1.2rem;
border-radius: var(--border-radius);
background: rgba(var(--card-rgb), 0);
border: 1px solid rgba(var(--card-border-rgb), 0);
transition: background 200ms, border 200ms;
}
.card span {
display: inline-block;
transition: transform 200ms;
}
.card h2 {
font-weight: 600;
margin-bottom: 0.7rem;
}
.card p {
margin: 0;
opacity: 0.6;
font-size: 0.9rem;
line-height: 1.5;
max-width: 30ch;
}
.center {
display: flex;
justify-content: center;
align-items: center;
position: relative;
padding: 4rem 0;
}
.center::before {
background: var(--secondary-glow);
border-radius: 50%;
width: 480px;
height: 360px;
margin-left: -400px;
}
.center::after {
background: var(--primary-glow);
width: 240px;
height: 180px;
z-index: -1;
}
.center::before,
.center::after {
content: '';
left: 50%;
position: absolute;
filter: blur(45px);
transform: translateZ(0);
}
.logo {
position: relative;
}
/* Enable hover only on non-touch devices */
@media (hover: hover) and (pointer: fine) {
.card:hover {
background: rgba(var(--card-rgb), 0.1);
border: 1px solid rgba(var(--card-border-rgb), 0.15);
}
.card:hover span {
transform: translateX(4px);
}
}
@media (prefers-reduced-motion) {
.card:hover span {
transform: none;
}
}
/* Mobile */
@media (max-width: 700px) {
.content {
padding: 4rem;
}
.grid {
grid-template-columns: 1fr;
margin-bottom: 120px;
max-width: 320px;
text-align: center;
}
.card {
padding: 1rem 2.5rem;
}
.card h2 {
margin-bottom: 0.5rem;
}
.center {
padding: 8rem 0 6rem;
}
.center::before {
transform: none;
height: 300px;
}
.description {
font-size: 0.8rem;
}
.description a {
padding: 1rem;
}
.description p,
.description div {
display: flex;
justify-content: center;
position: fixed;
width: 100%;
}
.description p {
align-items: center;
inset: 0 0 auto;
padding: 2rem 1rem 1.4rem;
border-radius: 0;
border: none;
border-bottom: 1px solid rgba(var(--callout-border-rgb), 0.25);
background: linear-gradient(
to bottom,
rgba(var(--background-start-rgb), 1),
rgba(var(--callout-rgb), 0.5)
);
background-clip: padding-box;
backdrop-filter: blur(24px);
}
.description div {
align-items: flex-end;
pointer-events: none;
inset: auto 0 0;
padding: 2rem;
height: 200px;
background: linear-gradient(
to bottom,
transparent 0%,
rgb(var(--background-end-rgb)) 40%
);
z-index: 1;
}
}
/* Tablet and Smaller Desktop */
@media (min-width: 701px) and (max-width: 1120px) {
.grid {
grid-template-columns: repeat(2, 50%);
}
}
@media (prefers-color-scheme: dark) {
.vercelLogo {
filter: invert(1);
}
.logo {
filter: invert(1) drop-shadow(0 0 0.3rem #ffffff70);
}
}
@keyframes rotate {
from {
transform: rotate(360deg);
}
to {
transform: rotate(0deg);
}
}

View file

@ -1,95 +1,5 @@
import Image from 'next/image'
import styles from './page.module.css'
import Link from "next/link";
export default function Home() {
return (
<main className={styles.main}>
<div className={styles.description}>
<p>
Get started by editing&nbsp;
<code className={styles.code}>app/page.tsx</code>
</p>
<div>
<a
href="https://vercel.com?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
By{' '}
<Image
src="/vercel.svg"
alt="Vercel Logo"
className={styles.vercelLogo}
width={100}
height={24}
priority
/>
</a>
</div>
</div>
<div className={styles.center}>
<Image
className={styles.logo}
src="/next.svg"
alt="Next.js Logo"
width={180}
height={37}
priority
/>
</div>
<div className={styles.grid}>
<a
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
className={styles.card}
target="_blank"
rel="noopener noreferrer"
>
<h2>
Docs <span>-&gt;</span>
</h2>
<p>Find in-depth information about Next.js features and API.</p>
</a>
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
className={styles.card}
target="_blank"
rel="noopener noreferrer"
>
<h2>
Learn <span>-&gt;</span>
</h2>
<p>Learn about Next.js in an interactive course with&nbsp;quizzes!</p>
</a>
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
className={styles.card}
target="_blank"
rel="noopener noreferrer"
>
<h2>
Templates <span>-&gt;</span>
</h2>
<p>Explore the Next.js 13 playground.</p>
</a>
<a
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
className={styles.card}
target="_blank"
rel="noopener noreferrer"
>
<h2>
Deploy <span>-&gt;</span>
</h2>
<p>
Instantly deploy your Next.js site to a shareable URL with Vercel.
</p>
</a>
</div>
</main>
)
}
export default function HomePage() {
return <h1>Hello, Dashboard Page!</h1>
}

3
app/rankings/page.tsx Normal file
View file

@ -0,0 +1,3 @@
export default async function RankingsPage() {
return <h1>Hello, Rankings Page!</h1>
}

3
app/tournaments/page.tsx Normal file
View file

@ -0,0 +1,3 @@
export default async function TournamentsPage() {
return <h1>Hello, Tournaments Page!</h1>
}

6
compile_proto.sh Normal file
View file

@ -0,0 +1,6 @@
./node_modules/.bin/grpc_tools_node_protoc \
--plugin=protoc-gen-ts_proto=./node_modules/.bin/protoc-gen-ts_proto \
--ts_proto_out=./helpers/proto \
--ts_proto_opt=outputServices=nice-grpc,outputServices=generic-definitions,useExactTypes=false,esModuleInterop=true \
--proto_path=./helpers/proto \
./helpers/proto/amkj_service.proto

View file

@ -0,0 +1,56 @@
import { Gathering } from '@/helpers/proto/amkj_service';
import { Card, OverlayTrigger, Popover } from 'react-bootstrap';
import Toast from 'react-bootstrap/Toast';
import ToastContainer from 'react-bootstrap/ToastContainer';
export interface GatheringEntryProps {
gathering: Gathering;
}
interface Player {
pid: number;
guests: number;
}
export const GatheringEntry: React.FC<GatheringEntryProps> = ({ gathering }) => {
const playerList: Player[] = [];
gathering.players.forEach((player) => {
const res = playerList.find(p => p.pid === Math.abs(player));
if (res) {
res.guests += 1;
} else {
playerList.push({ pid: player, guests: 0 });
}
});
console.log(playerList);
const popover = (
<Popover id="popover-basic" className="text-center">
<Popover.Body>
<ul>
{
playerList.map((player, idx) => {
return <li key={`${gathering.gid}_${idx}`}>PID {player.pid} {(player.guests > 0) ? ` (+${player.guests} guest)` : ""}</li>;
})
}
</ul>
</Popover.Body>
</Popover>
);
return (
<div>
<Card style={{ width: '18rem' }}>
<Card.Header className="text-center">Gathering {gathering.gid}</Card.Header>
<Card.Body>
</Card.Body>
<OverlayTrigger trigger={["hover", "focus"]} placement="bottom" overlay={popover}>
<Card.Header className="text-center">{gathering.players.length} player{gathering.players.length > 1 && 's'}</Card.Header>
</OverlayTrigger>
</Card>
</div>
)
}

136
components/NEXStatus.tsx Normal file
View file

@ -0,0 +1,136 @@
'use client'
import Link from 'next/link';
import { GetServerStatusResponse } from '@/helpers/proto/amkj_service';
import { useEffect, useState } from 'react';
import Spinner from 'react-bootstrap/Spinner';
import { StaticPopover } from './StaticPopover';
export const NEXStatus = () => {
const [statusResponse, setStatusResponse] = useState<Response | null>(null);
const [nexStatus, setNEXStatus] = useState<GetServerStatusResponse | null>(null);
const fetchNEXStatus = async () => {
try {
const response = await fetch('/api/status', { cache: "no-store" });
setStatusResponse(response);
const data = await response.json();
setNEXStatus(data as GetServerStatusResponse);
} catch (error) {
console.error('Failed to fetch NEX server status:', error);
}
}
useEffect(() => {
fetchNEXStatus();
const interval = setInterval(fetchNEXStatus, 10000);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!statusResponse) {
return (
<StaticPopover
title={
<strong className="me-auto">Fetching server status</strong>
}
body={
<Spinner animation="border" role="status" />
} />
);
} else {
if (statusResponse.status != 200 || !nexStatus) {
return (
<StaticPopover
title={
<strong className="me-auto">🔴 Error fetching server status</strong>
}
body={
<>
<span>The problem is on our side:</span>
<ul className="mt-3">
<li>The NEX server may be down</li>
<li>Server may be misconfigured</li>
</ul>
</>
} />
);
} else {
if (nexStatus.isMaintenance) {
const startMaintenanceDate = new Date(nexStatus.startMaintenanceTime as Date);
const endMaintenanceDate = new Date(nexStatus.endMaintenanceTime as Date);
const currentDate = new Date();
let numSeconds = Math.floor((endMaintenanceDate.getTime() - currentDate.getTime()) / 1000);
let numMinutes = Math.floor(numSeconds / 60);
if (numSeconds > 0) {
if (numMinutes > 60) {
let numHours = Math.floor(numMinutes / 60);
var text = <span>🟠 {`Maintenance is over in ${numHours}h${numMinutes % 60}min`}</span>;
} else {
var text = <span>🟠 {`Maintenance is over in ${numMinutes}min`}</span>;
}
} else {
numSeconds = Math.abs(numSeconds);
numMinutes = Math.floor(numSeconds / 60);
if (numMinutes > 60) {
let numHours = Math.floor(numMinutes / 60);
var text = <span>🟠 {`Maintenance has been extended by ${numHours}h${numMinutes % 60}min`}</span>;
} else {
var text = <span>🟠 {`Maintenance has been extended by ${numMinutes}min`}</span>;
}
}
return (
<StaticPopover
title={
<strong className="me-auto">{text}</strong>
}
body={
<>
<span>The server is aware of these maintenance times:</span>
<ul className="mt-3">
<li>Start: <strong>{startMaintenanceDate.toLocaleString()}</strong></li>
<li>End: <strong>{endMaintenanceDate.toLocaleString()}</strong></li>
</ul>
</>
} />
);
} else if (nexStatus.isWhitelist) {
return (
<StaticPopover
title={
<strong className="me-auto"> Whitelist mode</strong>
}
body={
<>
<span>Only allowed users can join the server</span>
<ul className="mt-3">
<li>This mode was activated by an admin</li>
<li>This is different from our functionality where only supporters can access the server</li>
<li>This is activated for private testing and development only</li>
</ul>
</>
} />
);
} else {
return (
<StaticPopover
title={
<strong className="me-auto">🟢 {`Online with ${nexStatus.numClients} player${(nexStatus.numClients == 0 || nexStatus.numClients > 1) ? 's' : ''}`}</strong>
}
body={
<>
The server is online, join the server on the
<Link href="https://pretendo.network" style={{ textDecoration: "none" }}> Pretendo Network</Link>
</>
}
align_center={true} />
);
}
}
}
}

View file

@ -0,0 +1,104 @@
'use client'
import Link from 'next/link';
import Image from 'next/image';
import { Navbar, Nav } from 'react-bootstrap';
import { usePathname, } from 'next/navigation';
import OverlayTrigger from 'react-bootstrap/OverlayTrigger';
import Popover from 'react-bootstrap/Popover';
import { MdLogin } from 'react-icons/md';
import { isBrowser } from 'react-device-detect';
interface NavigationBarEntryProps {
name: string;
path: string;
desc: string | JSX.Element;
}
const NavigationBarEntry: React.FC<NavigationBarEntryProps> = ({ name, path, desc }) => {
const pathname = usePathname();
const isActive = pathname === path;
const popover = (
<Popover id="popover-basic" className="text-center">
<Popover.Header as="h3">{name}</Popover.Header>
<Popover.Body>
{desc}
</Popover.Body>
</Popover>
);
return (
<OverlayTrigger trigger={["hover", "focus"]} placement="bottom" overlay={popover}>
<Link href={path} className="active nav-link me-5" style={{ fontWeight: (isActive ? 'bold' : 'inherit') }}>
{name}
</Link>
</OverlayTrigger>
)
}
interface NavigationBarProps { }
const NavigationBar: React.FC<NavigationBarProps> = ({ }) => {
const pathname = usePathname();
const getUserProfileJSX = () => {
/*
if (statusResponse) {
if (statusResponse.headers.has("x-mk8-pretendo-imageurl")) {
return (
<Link href="#" className="nav-link me-5" prefetch={false} onClick={() => {
fetch('/logout').then(() => window.location.reload());
}}>
<Image src={statusResponse.headers.get("x-mk8-pretendo-imageurl") as string} width={48} height={48} alt="Mii profile picture" className="mb-2" />
<strong>{statusResponse.headers.get("x-mk8-pretendo-username")}</strong>
</Link>
);
} else {
return (
<Link href={`https://pretendo.network/account/login?redirect=${window.location.origin + pathname}`} className="active nav-link me-5" prefetch={false}>
<strong className="me-2">Login</strong>
<MdLogin size={25} />
</Link>
);
}
}*/
return <></>;
}
return (
<div>
<Navbar expand="lg" style={{ borderBottom: '1px solid #e2edff', backgroundColor: '#e2edff' }}>
<Navbar.Brand>
<Link href='/' style={{ textDecoration: 'none' }}>
<div className="d-flex gap-2 align-items-center ms-3">
<div className="p-1">
<Image src="/assets/pretendo-logo.png" alt="Pretendo logo" width={35} height={35} />
</div>
<div className="d-flex flex-column">
<span className="text-muted fs-6">Mario Kart 8 Pretendo website</span>
</div>
</div>
</Link>
</Navbar.Brand>
{isBrowser && <div className="ms-2 vr" />}
<Navbar.Toggle aria-controls="basic-navbar-nav" />
<Navbar.Collapse id="basic-navbar-nav" className="justify-content-between me-3 ms-2">
<Nav className="ms-3">
<NavigationBarEntry path="/" name="Home" desc="Main page of the website" />
<NavigationBarEntry path="/gatherings" name="Gatherings" desc="List of matchmaking sessions (worldwide, regional, friend rooms, tournaments)" />
<NavigationBarEntry path="/tournaments" name="Tournaments" desc="List of tournaments" />
<NavigationBarEntry path="/rankings" name="Rankings" desc="Time trial rankings" />
</Nav>
</Navbar.Collapse>
<Navbar.Collapse className="justify-content-end">
{getUserProfileJSX()}
</Navbar.Collapse>
</Navbar>
</div>
)
}
export default NavigationBar;

View file

@ -0,0 +1,23 @@
import Toast from 'react-bootstrap/Toast';
import ToastContainer from 'react-bootstrap/ToastContainer';
export interface StaticPopoverProps {
title: string | JSX.Element;
body: string | JSX.Element;
align_center?: boolean;
}
export const StaticPopover: React.FC<StaticPopoverProps> = ({ title, body, align_center }) => {
return (
<ToastContainer className={"p-3" + (align_center ? " text-center" : "")} position="bottom-end" style={{ zIndex: 1, position: 'fixed' }}>
<Toast>
<Toast.Header closeButton={false}>
{title}
</Toast.Header>
<Toast.Body>
{body}
</Toast.Body>
</Toast>
</ToastContainer>
)
}

8
helpers/grpc.ts Normal file
View file

@ -0,0 +1,8 @@
import { createChannel, createClient, Metadata } from 'nice-grpc';
import { AmkjServiceClient, AmkjServiceDefinition, GetServerStatusResponse } from '@/helpers/proto/amkj_service';
import app_config from '@/app.config';
const channel = createChannel(`${app_config.grpc_host}:${app_config.grpc_port}`);
const amkj_grpc_client: AmkjServiceClient = createClient(AmkjServiceDefinition, channel);
export { amkj_grpc_client };

View file

@ -0,0 +1,151 @@
syntax = "proto3";
package amkj;
option go_package = "./;grpc_amkj";
import "google/protobuf/timestamp.proto";
service AmkjService {
rpc GetServerStatus(GetServerStatusRequest) returns (GetServerStatusResponse) {}
rpc StartMaintenance(StartMaintenanceRequest) returns (StartMaintenanceResponse) {}
rpc EndMaintenance(EndMaintenanceRequest) returns (EndMaintenanceResponse) {}
rpc ToggleWhitelist(ToggleWhitelistRequest) returns (ToggleWhitelistResponse) {}
rpc GetWhitelist(GetWhitelistRequest) returns (GetWhitelistResponse) {}
rpc AddWhitelistUser(AddWhitelistUserRequest) returns (AddWhitelistUserResponse) {}
rpc DelWhitelistUser(DelWhitelistUserRequest) returns (DelWhitelistUserResponse) {}
rpc GetAllUsers(GetAllUsersRequest) returns (GetAllUsersResponse) {}
rpc KickUser(KickUserRequest) returns (KickUserResponse) {}
rpc KickAllUsers(KickAllUsersRequest) returns (KickAllUsersResponse) {}
rpc GetAllGatherings(GetAllGatheringsRequest) returns (GetAllGatheringsResponse) {}
rpc GetAllTournaments(GetAllTournamentsRequest) returns (GetAllTournamentsResponse) {}
}
// ========================================================
message GetServerStatusRequest {}
message GetServerStatusResponse {
bool is_online = 1;
bool is_maintenance = 2;
bool is_whitelist = 3;
int32 num_clients = 4;
google.protobuf.Timestamp start_maintenance_time = 5;
google.protobuf.Timestamp end_maintenance_time = 6;
}
// ========================================================
message StartMaintenanceRequest {
google.protobuf.Timestamp projected_utc_end_maintenance_time = 1;
}
message StartMaintenanceResponse {}
// ========================================================
message EndMaintenanceRequest {}
message EndMaintenanceResponse {}
// ========================================================
message ToggleWhitelistRequest {}
message ToggleWhitelistResponse {
bool is_whitelist = 1;
}
// ========================================================
message GetWhitelistRequest {}
message GetWhitelistResponse {
repeated uint32 pids = 1;
}
// ========================================================
message AddWhitelistUserRequest {
uint32 pid = 1;
}
message AddWhitelistUserResponse {}
// ========================================================
message DelWhitelistUserRequest {
uint32 pid = 1;
}
message DelWhitelistUserResponse {}
// ========================================================
message GetAllUsersRequest {}
message GetAllUsersResponse {
repeated uint32 pids = 1;
}
// ========================================================
message KickUserRequest {
uint32 pid = 1;
}
message KickUserResponse {
bool was_connected = 1;
}
// ========================================================
message KickAllUsersRequest {}
message KickAllUsersResponse {
int32 num_kicked = 1;
}
// ========================================================
message Gathering {
uint32 gid = 1;
uint32 host = 2;
uint32 owner = 3;
repeated uint32 attributes = 4;
bytes app_data = 5;
repeated int64 players = 6;
uint32 min_participants = 7;
uint32 max_participants = 8;
}
message GetAllGatheringsRequest {
uint32 offset = 1;
int32 limit = 2;
}
message GetAllGatheringsResponse {
repeated Gathering gatherings = 1;
}
// ========================================================
message Tournament {
uint32 id = 1;
uint32 owner = 2;
repeated uint32 attributes = 3;
bytes app_data = 4;
int64 total_participants = 5;
int64 season_id = 6;
string name = 7;
string description = 8;
string red_team = 9;
string blue_team = 10;
uint32 repeat_type = 11;
uint32 gameset_num = 12;
uint32 icon_type = 13;
uint32 battle_time = 14;
uint32 update_date = 15;
}
message GetAllTournamentsRequest {
uint32 offset = 1;
int32 limit = 2;
}
message GetAllTournamentsResponse {
repeated Tournament tournaments = 1;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,225 @@
/* eslint-disable */
import Long from "long";
import _m0 from "protobufjs/minimal";
export const protobufPackage = "google.protobuf";
/**
* A Timestamp represents a point in time independent of any time zone or local
* calendar, encoded as a count of seconds and fractions of seconds at
* nanosecond resolution. The count is relative to an epoch at UTC midnight on
* January 1, 1970, in the proleptic Gregorian calendar which extends the
* Gregorian calendar backwards to year one.
*
* All minutes are 60 seconds long. Leap seconds are "smeared" so that no leap
* second table is needed for interpretation, using a [24-hour linear
* smear](https://developers.google.com/time/smear).
*
* The range is from 0001-01-01T00:00:00Z to 9999-12-31T23:59:59.999999999Z. By
* restricting to that range, we ensure that we can convert to and from [RFC
* 3339](https://www.ietf.org/rfc/rfc3339.txt) date strings.
*
* # Examples
*
* Example 1: Compute Timestamp from POSIX `time()`.
*
* Timestamp timestamp;
* timestamp.set_seconds(time(NULL));
* timestamp.set_nanos(0);
*
* Example 2: Compute Timestamp from POSIX `gettimeofday()`.
*
* struct timeval tv;
* gettimeofday(&tv, NULL);
*
* Timestamp timestamp;
* timestamp.set_seconds(tv.tv_sec);
* timestamp.set_nanos(tv.tv_usec * 1000);
*
* Example 3: Compute Timestamp from Win32 `GetSystemTimeAsFileTime()`.
*
* FILETIME ft;
* GetSystemTimeAsFileTime(&ft);
* UINT64 ticks = (((UINT64)ft.dwHighDateTime) << 32) | ft.dwLowDateTime;
*
* // A Windows tick is 100 nanoseconds. Windows epoch 1601-01-01T00:00:00Z
* // is 11644473600 seconds before Unix epoch 1970-01-01T00:00:00Z.
* Timestamp timestamp;
* timestamp.set_seconds((INT64) ((ticks / 10000000) - 11644473600LL));
* timestamp.set_nanos((INT32) ((ticks % 10000000) * 100));
*
* Example 4: Compute Timestamp from Java `System.currentTimeMillis()`.
*
* long millis = System.currentTimeMillis();
*
* Timestamp timestamp = Timestamp.newBuilder().setSeconds(millis / 1000)
* .setNanos((int) ((millis % 1000) * 1000000)).build();
*
* Example 5: Compute Timestamp from Java `Instant.now()`.
*
* Instant now = Instant.now();
*
* Timestamp timestamp =
* Timestamp.newBuilder().setSeconds(now.getEpochSecond())
* .setNanos(now.getNano()).build();
*
* Example 6: Compute Timestamp from current time in Python.
*
* timestamp = Timestamp()
* timestamp.GetCurrentTime()
*
* # JSON Mapping
*
* In JSON format, the Timestamp type is encoded as a string in the
* [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format. That is, the
* format is "{year}-{month}-{day}T{hour}:{min}:{sec}[.{frac_sec}]Z"
* where {year} is always expressed using four digits while {month}, {day},
* {hour}, {min}, and {sec} are zero-padded to two digits each. The fractional
* seconds, which can go up to 9 digits (i.e. up to 1 nanosecond resolution),
* are optional. The "Z" suffix indicates the timezone ("UTC"); the timezone
* is required. A proto3 JSON serializer should always use UTC (as indicated by
* "Z") when printing the Timestamp type and a proto3 JSON parser should be
* able to accept both UTC and other timezones (as indicated by an offset).
*
* For example, "2017-01-15T01:30:15.01Z" encodes 15.01 seconds past
* 01:30 UTC on January 15, 2017.
*
* In JavaScript, one can convert a Date object to this format using the
* standard
* [toISOString()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString)
* method. In Python, a standard `datetime.datetime` object can be converted
* to this format using
* [`strftime`](https://docs.python.org/2/library/time.html#time.strftime) with
* the time format spec '%Y-%m-%dT%H:%M:%S.%fZ'. Likewise, in Java, one can use
* the Joda Time's [`ISODateTimeFormat.dateTime()`](
* http://www.joda.org/joda-time/apidocs/org/joda/time/format/ISODateTimeFormat.html#dateTime%2D%2D
* ) to obtain a formatter capable of generating timestamps in this format.
*/
export interface Timestamp {
/**
* Represents seconds of UTC time since Unix epoch
* 1970-01-01T00:00:00Z. Must be from 0001-01-01T00:00:00Z to
* 9999-12-31T23:59:59Z inclusive.
*/
seconds: number;
/**
* Non-negative fractions of a second at nanosecond resolution. Negative
* second values with fractions must still have non-negative nanos values
* that count forward in time. Must be from 0 to 999,999,999
* inclusive.
*/
nanos: number;
}
function createBaseTimestamp(): Timestamp {
return { seconds: 0, nanos: 0 };
}
export const Timestamp = {
encode(message: Timestamp, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer {
if (message.seconds !== 0) {
writer.uint32(8).int64(message.seconds);
}
if (message.nanos !== 0) {
writer.uint32(16).int32(message.nanos);
}
return writer;
},
decode(input: _m0.Reader | Uint8Array, length?: number): Timestamp {
const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseTimestamp();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1:
if (tag !== 8) {
break;
}
message.seconds = longToNumber(reader.int64() as Long);
continue;
case 2:
if (tag !== 16) {
break;
}
message.nanos = reader.int32();
continue;
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skipType(tag & 7);
}
return message;
},
fromJSON(object: any): Timestamp {
return {
seconds: isSet(object.seconds) ? Number(object.seconds) : 0,
nanos: isSet(object.nanos) ? Number(object.nanos) : 0,
};
},
toJSON(message: Timestamp): unknown {
const obj: any = {};
message.seconds !== undefined && (obj.seconds = Math.round(message.seconds));
message.nanos !== undefined && (obj.nanos = Math.round(message.nanos));
return obj;
},
create(base?: DeepPartial<Timestamp>): Timestamp {
return Timestamp.fromPartial(base ?? {});
},
fromPartial(object: DeepPartial<Timestamp>): Timestamp {
const message = createBaseTimestamp();
message.seconds = object.seconds ?? 0;
message.nanos = object.nanos ?? 0;
return message;
},
};
declare var self: any | undefined;
declare var window: any | undefined;
declare var global: any | undefined;
var tsProtoGlobalThis: any = (() => {
if (typeof globalThis !== "undefined") {
return globalThis;
}
if (typeof self !== "undefined") {
return self;
}
if (typeof window !== "undefined") {
return window;
}
if (typeof global !== "undefined") {
return global;
}
throw "Unable to locate global object";
})();
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
export type DeepPartial<T> = T extends Builtin ? T
: T extends Array<infer U> ? Array<DeepPartial<U>> : T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>;
function longToNumber(long: Long): number {
if (long.gt(Number.MAX_SAFE_INTEGER)) {
throw new tsProtoGlobalThis.Error("Value is larger than Number.MAX_SAFE_INTEGER");
}
return long.toNumber();
}
if (_m0.util.Long !== Long) {
_m0.util.Long = Long as any;
_m0.configure();
}
function isSet(value: any): boolean {
return value !== null && value !== undefined;
}

View file

@ -0,0 +1,66 @@
import type { NextRequest } from 'next/server'
import app_config from '@/app.config';
import { SignJWT, jwtVerify } from 'jose';
import { nanoid } from 'nanoid';
export type JWTTokenPayload = {
access_level: number;
server_access_level: string;
pnid: string;
pid: number;
mii_image_url: string;
}
export async function getMK8Token(request: NextRequest): Promise<JWTTokenPayload | null> {
const mk8_token = request.cookies.get("mk8_token")?.value;
if (!mk8_token) {
return null;
}
try {
const verifyResult = await jwtVerify(mk8_token, new TextEncoder().encode(app_config.jwt_secret));
return verifyResult.payload as JWTTokenPayload;
} catch (error) { }
return null;
}
export async function getMK8TokenFromAccountAPI(request: NextRequest): Promise<{ token: JWTTokenPayload, jwt_token: string } | null> {
const token_type = request.cookies.get("token_type")?.value;
const access_token = request.cookies.get("access_token")?.value;
if (!token_type || !access_token) {
return null;
}
try {
var res = await fetch("https://api.pretendo.cc/v1/user", {
headers: {
"Authorization": `${token_type} ${access_token}`
}
})
if (res.status == 200) {
const userData = await res.json();
const token_data: JWTTokenPayload = {
access_level: userData["access_level"],
server_access_level: userData["server_access_level"],
pnid: userData["username"],
pid: userData["pid"],
mii_image_url: userData["mii"]["image_url"],
}
const token = await new SignJWT(token_data)
.setProtectedHeader({ alg: 'HS256' })
.setJti(nanoid())
.setIssuedAt()
.setExpirationTime('2h')
.sign(new TextEncoder().encode(app_config.jwt_secret));
return { token: token_data, jwt_token: token };
}
} catch (error) { }
return null;
}

87
middleware.ts Normal file
View file

@ -0,0 +1,87 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { type JWTTokenPayload, getMK8Token, getMK8TokenFromAccountAPI } from './helpers/types/JWTTokenPayload';
export async function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith('/logout')) {
const referer: string | null = request.headers.get("referer");
const url = new URL(referer ? referer : '/', request.url);
const response = NextResponse.redirect(url);
response.cookies.set("mk8_token", "", { maxAge: 0, domain: ".pretendo.network" });
response.cookies.set("access_token", "", { maxAge: 0, domain: ".pretendo.network" });
response.cookies.set("refresh_token", "", { maxAge: 0, domain: ".pretendo.network" });
response.cookies.set("token_type", "", { maxAge: 0, domain: ".pretendo.network" });
return response;
}
const hostname = request.nextUrl.hostname;
const redirect_login_url = `https://${hostname.substring(hostname.indexOf('.') + 1)}/account/login?redirect=http://${hostname}`;
var mk8_token: JWTTokenPayload | null = await getMK8Token(request);
let res;
if (!mk8_token) {
res = await getMK8TokenFromAccountAPI(request);
if (!res && request.nextUrl.pathname.startsWith('/admin')) {
const response = NextResponse.redirect(redirect_login_url);
if (request.cookies.has("mk8_token")) {
response.cookies.delete("mk8_token");
}
return response;
}
if (res) {
mk8_token = res.token;
}
}
if (request.nextUrl.pathname.startsWith('/admin')) {
if (mk8_token) {
if (mk8_token.access_level < 3) {
var response = NextResponse.redirect(new URL('/', request.url));
} else {
var response = NextResponse.next();
}
response.headers.set("X-MK8-Pretendo-SAL", mk8_token.server_access_level);
response.headers.set("X-MK8-Pretendo-Username", mk8_token.pnid);
response.headers.set("X-MK8-Pretendo-ImageURL", mk8_token.mii_image_url);
response.headers.set("X-MK8-Pretendo-PID", mk8_token.pid.toString());
if (res) {
response.cookies.set("mk8_token", res.jwt_token, { domain: ".pretendo.network" });
}
return response;
} else {
const response = NextResponse.redirect(redirect_login_url);
if (request.cookies.has("mk8_token")) {
response.cookies.delete("mk8_token");
}
return response;
}
} else {
const response = NextResponse.next();
if (mk8_token) {
response.headers.set("X-MK8-Pretendo-SAL", mk8_token.server_access_level);
response.headers.set("X-MK8-Pretendo-Username", mk8_token.pnid);
response.headers.set("X-MK8-Pretendo-ImageURL", mk8_token.mii_image_url);
response.headers.set("X-MK8-Pretendo-PID", mk8_token.pid.toString());
}
if (res) {
response.cookies.set("mk8_token", res.jwt_token, { domain: ".pretendo.network" });
}
return response;
}
}
export const config = {
matcher: [
'/',
'/logout',
'/api/:path*',
'/admin/:path*',
'/dashboard/:path*',
'/tournaments/:path*',
'/gatherings/:path*',
'/rankings/:path*'
]
}

View file

@ -1,4 +1,16 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
const nextConfig = {
poweredByHeader: false,
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'pretendo-cdn.b-cdn.net',
port: '',
pathname: '/**',
},
],
}
}
module.exports = nextConfig

1068
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,17 +6,30 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"dev_pretendo": "next dev --hostname mario-kart-8.pretendo.network --port 80 -H 0.0.0.0"
},
"dependencies": {
"@types/node": "20.3.1",
"@types/react": "18.2.14",
"@types/react-dom": "18.2.6",
"bootstrap": "^5.3.0",
"eslint": "8.43.0",
"eslint-config-next": "13.4.7",
"jose": "^4.14.4",
"long": "^5.2.3",
"next": "13.4.7",
"nice-grpc": "^2.1.4",
"protobufjs": "^7.2.4",
"react": "18.2.0",
"react-bootstrap": "^2.8.0",
"react-device-detect": "^2.2.3",
"react-dom": "18.2.0",
"react-icons": "^4.10.1",
"typescript": "5.1.3"
},
"devDependencies": {
"grpc-tools": "^1.12.4",
"ts-proto": "^1.150.1"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>

Before

Width:  |  Height:  |  Size: 629 B