Merge pull request #446 from PretendoNetwork/nuxt-refactor

Refactor: move to nuxt 4
This commit is contained in:
mrjvs
2026-08-19 16:45:31 +02:00
committed by GitHub
493 changed files with 47768 additions and 32642 deletions

View File

@@ -0,0 +1,32 @@
# Taken from: https://spwoodcock.dev/blog/2026-03-03-automate-garage-standalone/
# Modified to add environment variable support
# chroot into the s3 container's filesystem so the garage binary runs
# with its own libs/linker, while the shared network namespace keeps
# 127.0.0.1:3901 pointing at the live Garage server
G="chroot /proc/1/root /garage -c /etc/garage.toml"
# Wait for server
for i in $(seq 1 20); do
if $G node id -q 2>/dev/null; then break; fi
echo "Waiting for Garage RPC... ($i/20)"
sleep 3
done
$G node id -q || { echo "Garage RPC not ready"; exit 1; }
# Init garage nodes
if $G status 2>&1 | grep -q 'NO ROLE ASSIGNED'; then
NODE_ID=$($G node id -q | cut -c1-16)
$G layout assign "$NODE_ID" -z local -c 1G
$G layout apply --version 1
fi
# Create S3 bucket
$G key import --yes -n "$S3_ACCESS_KEY_NAME" \
"$S3_ACCESS_KEY_ID" \
"$S3_ACCESS_KEY_SECRET" || true
$G bucket create "$S3_BUCKET" || true
$G bucket allow "$S3_BUCKET" --key "$S3_ACCESS_KEY_NAME" --read --write --owner || true
$G bucket website --allow "$S3_BUCKET"
echo "Garage initialized."

View File

@@ -0,0 +1,17 @@
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
replication_factor = 1
db_engine = "sqlite"
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "127.0.0.1:3901"
rpc_secret = "fbb4c8c25cdb7766ef9c2913cee0a6b52a927c03fafea2aa21e8658c68cc14c7"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
[s3_web]
bind_addr = "[::]:3902"
root_domain = ".localhost"

View File

@@ -0,0 +1,97 @@
name: "pn-website-dev"
services:
redis:
image: redis:8.2-alpine
restart: unless-stopped
ports:
- "6379:6379" # Redis port
mailpit:
image: axllent/mailpit
restart: unless-stopped
networks:
- net
ports:
- 8025:8025 # Web port
- 1025:1025 # SMTP port
mongo:
image: mongo:8.0
restart: unless-stopped
networks:
- net
command: ["--replSet", "rs0", "--bind_ip_all", "--port", "27017"]
ports:
- 27017:27017 # MongoDB port
# This funky stuff is needed for making a replicaset
healthcheck:
test: echo "try { rs.status() } catch (err) { rs.initiate({_id:'rs0',members:[{_id:0,host:'mongo:27017'}]}) }" | mongosh --port 27017 --quiet
interval: 5s
timeout: 30s
start_period: 0s
start_interval: 1s
retries: 30
volumes:
- "mongo_data:/data/db"
garage:
image: dxflrs/garage:v2.3.0
restart: unless-stopped
networks:
- net
ports:
- "3900:3900" # S3 API port
- "3902:3902" # Web port
healthcheck:
test: ["CMD", "/garage", "status"]
start_period: 5s
interval: 5s
timeout: 5s
retries: 10
volumes:
- ./assets/garage.toml:/etc/garage.toml:ro
- garage_data:/var/lib/garage
garage-init:
image: alpine:3.23
depends_on:
garage:
condition: service_healthy
network_mode: service:garage
pid: service:garage
environment:
GARAGE_ADMIN_TOKEN: garage-admin-token
S3_ACCESS_KEY_NAME: pretendo-key
S3_ACCESS_KEY_ID: GK3515373e4c851ebaad366558
S3_ACCESS_KEY_SECRET: 7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34
S3_BUCKET: pretendo
restart: "on-failure:2"
entrypoint: "/bin/sh -eu /etc/init.sh"
volumes:
- "./assets/garage-init.sh:/etc/init.sh"
account:
image: ghcr.io/pretendonetwork/account:sha-edb4b02
restart: unless-stopped
networks:
- net
ports:
- "8123:8123" # grpc
- "8056:8056" # http
environment:
PN_ACT_CONFIG_HTTP_PORT: 8056
PN_ACT_CONFIG_GRPC_PORT: 8123
PN_ACT_CONFIG_MONGO_CONNECTION_STRING: "mongodb://mongo:27017/account?directConnection=true"
PN_ACT_CONFIG_S3_ENDPOINT: "http://garage:3900"
PN_ACT_CONFIG_S3_REGION: "garage"
PN_ACT_CONFIG_S3_BUCKET: "pretendo"
PN_ACT_CONFIG_S3_ACCESS_KEY: "GK3515373e4c851ebaad366558"
PN_ACT_CONFIG_S3_ACCESS_SECRET: "7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34"
PN_ACT_CONFIG_S3_FORCE_PATH_STYLE: "true"
PN_ACT_CONFIG_CDN_BASE_URL: "http://pretendo.localhost:3902/"
PN_ACT_CONFIG_AES_KEY: "1234567812345678123456781234567812345678123456781234567812345678"
PN_ACT_CONFIG_GRPC_MASTER_API_KEY_ACCOUNT: "12345678123456781234567812345678"
PN_ACT_CONFIG_GRPC_MASTER_API_KEY_API: "12345678123456781234567812345678"
networks:
net:
volumes:
mongo_data:
garage_data:

View File

@@ -1,5 +1,5 @@
.git
.git/
node_modules/
.env
node_modules
dist
logs
.nuxt
.output

View File

@@ -1,15 +1,9 @@
# http://editorconfig.org
root = true
[*]
[*.{ts,js,vue,json}]
indent_style = tab
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.js]
indent_size = 4
[*.{css,handlebars,json}]
indent_size = 2

3
.gitattributes vendored
View File

@@ -1,2 +1 @@
# Auto detect text files and perform LF normalization
* text=auto
* text=auto eol=lf

View File

@@ -8,18 +8,21 @@ on:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
SHOULD_PUSH_IMAGE: ${{ (github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/dev')) || github.event_name == 'workflow_dispatch' }}
permissions:
contents: read
packages: write
jobs:
build-publish:
env:
SHOULD_PUSH_IMAGE: ${{ (github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/dev')) || github.event_name == 'workflow_dispatch' }}
build-publish-amd64:
name: Build and Publish (amd64)
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU for Docker
uses: docker/setup-qemu-action@v3
@@ -48,9 +51,52 @@ jobs:
id: build-and-push
uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm64
platforms: linux/amd64
push: ${{ env.SHOULD_PUSH_IMAGE }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-publish-arm64:
name: Build and Publish (arm64)
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU for Docker
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log into the container registry
if: ${{ env.SHOULD_PUSH_IMAGE == 'true' }}
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest-arm,enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=edge-arm,enable=${{ github.ref == 'refs/heads/dev' }}
type=sha,suffix=-arm
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v6
with:
platforms: linux/arm64
push: ${{ env.SHOULD_PUSH_IMAGE }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -14,10 +14,16 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "24"
- name: Install dependencies
run: npm ci
- name: Prepare linting
run: npm run prepare
- name: Typecheck
run: npm run typecheck
- name: Lint
run: npm run lint
run: npm run lint -- --max-warnings=0

81
.gitignore vendored
View File

@@ -1,76 +1,11 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Typescript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# vscode settings
.vscode
# JetBrains settings
.idea
# dotenv environment variables file
.output
.data
.nuxt
.nitro
.cache
dist
node_modules
.env
# keep config and blog posts out of this
# Old configuration ignored for safety
config.json
static-text.json
.DS_Store
# keep browserified files out
*.bundled.js
# remove nuxt files from passing from nuxt-refactor to master
.nuxt
.data

View File

@@ -1,10 +0,0 @@
language: node_js
node_js:
- "7"
- "8"
- "9"
sudo: false
script:
- "npm run lint"

7
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"editorconfig.editorconfig",
"vue.volar"
]
}

6
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
},
"eslint.format.enable": true
}

View File

@@ -4,7 +4,7 @@ ARG app_dir="/home/node/app"
# * Base Node.js image
FROM node:20-alpine AS base
FROM node:24-alpine AS base
ARG app_dir
WORKDIR ${app_dir}
@@ -35,14 +35,15 @@ RUN npm run build
FROM base AS final
ARG app_dir
RUN mkdir -p ${app_dir}/logs && chown node:node ${app_dir}/logs
ENV NODE_ENV=production
USER node
ENV NODE_ENV=production
ENV NITRO_HOST=0.0.0.0
ENV NITRO_PORT=8080
EXPOSE 8080
COPY package.json .
COPY --from=dependencies ${app_dir}/node_modules ${app_dir}/node_modules
COPY --from=build ${app_dir} ${app_dir}
COPY --from=build ${app_dir}/.output ${app_dir}/.output
CMD ["node", "."]
CMD ["node", "--enable-source-maps", ".output/server/index.mjs"]

View File

@@ -1,18 +1,64 @@
# Website
# Pretendo website
This repository contains the source code for [our website](https://pretendo.network). All contributions should go in the [dev branch](https://github.com/PretendoNetwork/website/tree/dev).
This repository contains the source code for [our website](https://pretendo.network).
### Localization
If you'd like to help localize Pretendo Network, you can check out our project on [Weblate](https://hosted.weblate.org/engage/pretendonetwork/).
# Running locally for development
<a href="https://hosted.weblate.org/engage/pretendonetwork/">
<img src="https://hosted.weblate.org/widgets/pretendonetwork/-/website/multi-auto.svg" alt="Translation status" />
</a>
Prerequisites:
- Clone the repository
- Have Docker Desktop installed (or Docker engine)
- Have NodeJS 24 or higher installed
* * *
Then follow these steps:
- Run `docker compose up -d` inside `/.docker`
- Create a file called `.env` in the root, fill it with the contents of `example.env`
- Install dependencies with `npm i`
- Run the app with `npm run dev`
Join our Discord:
# Translation
<a href="https://discord.gg/DThgbba" target="_blank">
<img src="https://discordapp.com/api/guilds/408718485913468928/widget.png?style=banner3">
</a>
If you'd like to help localize Pretendo Network, you can contribute to the translations on our project on [Weblate](https://hosted.weblate.org/engage/pretendonetwork/).
# Configuration
The application can be configured with environment variables. `.env` files are available for development.
There are no fully required configuration variables, the app can runs minimally without any configuration:
| Feature | Variable | Description | Default |
| ---------------------------- | -------------------------------------- | --------------------------------------------- | ---------------------------- |
| Core | `PN_WEBSITE_PUBLIC_BASE_URL` | Base URL of the app | `https://pretendo.network` |
| | `PN_WEBSITE_PUBLIC_CDN_BASE_URL` | Base URL for the CDN | `https://r2-cdn.pretendo.cc` |
| | `PN_WEBSITE_PUBLIC_COOKIE_SECURE` | Should Secure be enabled for auth cookies | `true` |
| | `PN_WEBSITE_TRUST_PROXY` | Should X-Forwarded-* headers be trusted? | `false` |
| | `PN_WEBSITE_REDIS_URL` | Redis URL to use for caching & ratelimits | (No distributed KV) |
| | | | |
| Authentication | `PN_WEBSITE_GRPC_HOST` | Account server GRPC host + port | - |
| | `PN_WEBSITE_GRPC_API_KEY` | Account server GRPC API key | - |
| | `PN_WEBSITE_API_BASE` | Base URL of the account server | `https://api.pretendo.cc` |
| | `PN_WEBSITE_API_BASE_HOST` | Hostname of the account server | `api.pretendo.cc` |
| | | | |
| Progress tracking | `PN_WEBSITE_GITHUB_API_TOKEN` | Github API token | - |
| | | | |
| Discord | `PN_WEBSITE_DISCORD_BOT_TOKEN` | Discord bot token | - |
| | `PN_WEBSITE_DISCORD_CLIENT_ID` | Discord OAuth client ID | - |
| | `PN_WEBSITE_DISCORD_CLIENT_SECRET` | Discord OAuth client secret | - |
| | `PN_WEBSITE_DISCORD_GUILD_ID` | Discord server ID for role linking | - |
| | `PN_WEBSITE_DISCORD_TESTER_ROLE_ID` | Role to give for tester access | (No role) |
| | `PN_WEBSITE_DISCORD_SUPPORTER_ROLE_ID` | Role to give for supporter access | (No role) |
| | | | |
| Payments | `PN_WEBSITE_STRIPE_SECRET_KEY` | Stripe secret key | - |
| (Requires `discord` feature) | `PN_WEBSITE_STRIPE_WEBHOOK_SECRET` | Stripe webhook signing key | - |
| | `PN_WEBSITE_STRIPE_NOTIFICATION_EMAIL` | Email address to send stripe notifications to | (No notifications) |
| | `PN_WEBSITE_MONGO_CONNECTION_STRING` | MongoDB connection string for account server | - |
| | `PN_WEBSITE_SMTP_HOST` | Host for the SMTP server | - |
| | `PN_WEBSITE_SMTP_PORT` | Port for the SMTP server | `587` |
| | `PN_WEBSITE_SMTP_SECURE` | Use a secure SMTP connection | `true` |
| | `PN_WEBSITE_SMTP_USER` | Username for the SMTP server | (No SMTP auth) |
| | `PN_WEBSITE_SMTP_PASSWORD` | Password for the SMTP server | (No SMTP auth) |
| | `PN_WEBSITE_SMTP_FROM_EMAIL` | Email to sent emails from | - |
| | `PN_WEBSITE_SMTP_FROM_NAME` | Display of the FROM email adress | - |
| | | | |
| Captcha | `PN_WEBSITE_HCAPTCHA_SECRET_KEY` | HCaptcha secret key | - |
| | `PN_WEBSITE_PUBLIC_HCAPTCHA_SITE_KEY` | HCaptcha site key | - |
| | | | |
| Discourse SSO | `PN_WEBSITE_DISCOURSE_SSO_SECRET` | Discourse SSO secret | - |

95
content.config.ts Normal file
View File

@@ -0,0 +1,95 @@
import {
defineContentConfig,
defineCollection,
defineCollectionSource,
z
} from '@nuxt/content';
import { getAllErrors, getErrorInfo } from '@pretendonetwork/error-codes';
const errorCodeSource = defineCollectionSource({
getKeys: () => {
return getAllErrors().map((key: string) => `${key}.json`);
},
getItem: (key: string) => {
const errorString = key.split('.')[0];
const sysmodule = errorString.split('-')[0];
const code = errorString.split('-')[1];
const errorInfo = getErrorInfo(sysmodule, code, 'en-US');
if (errorInfo) {
errorInfo.code = errorString;
}
return errorInfo;
}
});
export default defineContentConfig({
collections: {
blog: defineCollection({
type: 'page',
source: 'blog/*.md',
schema: z.object({
author: z.string(),
author_image: z.string(),
date: z.string(),
caption: z.string(),
cover_image: z.string()
})
}),
docs: defineCollection({
type: 'page',
source: 'docs/**/*.md',
schema: z.object({
description: z.string()
})
}),
terms: defineCollection({
type: 'page',
source: 'terms/*.md'
}),
errorcodes: defineCollection({
type: 'data',
source: errorCodeSource,
schema: z.object({
name: z.string(),
message: z.string(),
short_description: z.string(),
long_description: z.string(),
short_solution: z.string(),
long_solution: z.string(),
support_link: z.string(),
module: z.object({
name: z.string(),
description: z.string(),
system: z.string()
}),
code: z.string()
})
}),
team: defineCollection({
type: 'data',
source: 'team.json',
schema: z.object({
people: z.array(z.object({
name: z.string(),
captionKey: z.string(),
picture: z.string().url(),
github: z.string().url()
}))
})
}),
specialThanks: defineCollection({
type: 'data',
source: 'specialthanks.json',
schema: z.object({
people: z.array(z.object({
name: z.string(),
captionKey: z.string(),
picture: z.string().url(),
github: z.string().url(),
isSpecial: z.boolean().default(false)
}))
})
})
}
});

View File

@@ -83,7 +83,8 @@ In the latest version of the Cemu 2.0 experimental builds, support for Pretendo
If you haven't yet seen this video by Good Vibes Gaming, go check it out! I won't go anywhere...
[yt-iframe](Xtc9DJ6LYas)
::md-iframe{video-id="Xtc9DJ6LYas"}
::
All done? Welcome back! Yes, you aren't dreaming - Wii U Chat is finally here! The journey leading up to this point has been an adventurous one, so grab your popcorn as we go back to the beginning.

View File

@@ -33,19 +33,22 @@ Both Mario Kart 7 and Mario Kart 8 have started going online and can play matche
![Screenshot of Billy showing off CTGP-7](/assets/images/blogposts/screenshot-of-billy-showing-off-ctgp-7.webp)
[yt-iframe](W974FEDIoAA)
::md-iframe{video-id="W974FEDIoAA"}
::
## Hello YouTube!
An unintended side effect of working on Miiverse support in Mario Kart 8 was YouTube uploading being re-enabled! You can now upload race clips from Mario Kart 8 to YouTube again. Patrons who run the Miiverse patch can try this feature out right now
[yt-iframe](d3Bq7auupV0)
::md-iframe{video-id="d3Bq7auupV0"}
::
## Squid Game
Splatoon multiplayer battles now works! As of now only private friend battles have been tested, but they seem to be working without issue
[yt-iframe](d_qFnXrP7a4)
::md-iframe{video-id="d_qFnXrP7a4"}
::
## Friendship is magic

View File

@@ -32,7 +32,8 @@ Juxt started out before I actually even joined the Pretendo Network development
From there we quickly realized that the scope of this project was going to be much larger than we though, and moved on to experimenting with the Miiverse Applet itself
[yt-iframe](d9VAr9sEvCo)
::md-iframe{video-id="d9VAr9sEvCo"}
::
> Check out this ancient video of the first demo website that was running in the Miiverse Applet.
@@ -40,11 +41,13 @@ Shortly after this the project was absorbed into the Pretendo Network, and our f
2020 was a big year for Juxt, going from the simple api server before, to rapidly building out its web interface and features across both the 3DS and Wii u
[yt-iframe](NrfaOx5xcJY)
::md-iframe{video-id="NrfaOx5xcJY"}
::
> First attempt at an interface for the 3DS
[yt-iframe](IXnJOacx_gE)
::md-iframe{video-id="IXnJOacx_gE"}
::
> Community page Demo on the Wii U

View File

@@ -3,11 +3,11 @@ title: "Test"
author: "limes.pink"
author_image: "https://github.com/limes.pink.png"
date: "January 20, 2038"
caption: "A post to test the styling of the various elements we might use (rename to _test.md before deploying the blog section)"
caption: "A post to test the styling of the various elements we might use"
cover_image: "https://media.discordapp.net/attachments/413884110667251722/886474243662037062/image1.jpg"
---
A post to test the styling of the various elements we might use (rename to _test.md before deploying the blog section)
A post to test the styling of the various elements we might use
**bold**
@@ -35,23 +35,23 @@ _italic_
---
| Element | Description |
| :----------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| table | The table HTML element represents tabular data — that is, information presented in a two-dimensional table comprised of rows and columns of cells containing data. |
| tuble | The tuble HTML element represents tubular data — that is, information presented in a totally gnarly and radical way. |
| table | A table is an item of furniture with a flat top and one or more legs, used as a surface for working at, eating from or on which to place things. |
| Element | Description |
| :------ | :----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| table | The table HTML element represents tabular data — that is, information presented in a two-dimensional table comprised of rows and columns of cells containing data. |
| tuble | The tuble HTML element represents tubular data — that is, information presented in a totally gnarly and radical way. |
| table | A table is an item of furniture with a flat top and one or more legs, used as a surface for working at, eating from or on which to place things. |
| Element | Description |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| table | The table HTML element represents tabular data — that is, information presented in a two-dimensional table comprised of rows and columns of cells containing data. |
| tuble | The tuble HTML element represents tubular data — that is, information presented in a totally gnarly and radical way. |
| table | A table is an item of furniture with a flat top and one or more legs, used as a surface for working at, eating from or on which to place things. |
| Element | Description |
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| table | The table HTML element represents tabular data — that is, information presented in a two-dimensional table comprised of rows and columns of cells containing data. |
| tuble | The tuble HTML element represents tubular data — that is, information presented in a totally gnarly and radical way. |
| table | A table is an item of furniture with a flat top and one or more legs, used as a surface for working at, eating from or on which to place things. |
| Element | Description |
| -----------: | -----------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| table | The table HTML element represents tabular data — that is, information presented in a two-dimensional table comprised of rows and columns of cells containing data. |
| tuble | The tuble HTML element represents tubular data — that is, information presented in a totally gnarly and radical way. |
| table | A table is an item of furniture with a flat top and one or more legs, used as a surface for working at, eating from or on which to place things. |
| Element | Description |
| ------: | -----------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| table | The table HTML element represents tabular data — that is, information presented in a two-dimensional table comprised of rows and columns of cells containing data. |
| tuble | The tuble HTML element represents tubular data — that is, information presented in a totally gnarly and radical way. |
| table | A table is an item of furniture with a flat top and one or more legs, used as a surface for working at, eating from or on which to place things. |
Yee haw 🤠
@@ -126,14 +126,22 @@ console.log(trueOrFalseJSON);
<cite>Adapted from [blockquote: The Block Quotation element, from MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/blockquote)</cite>
[yt-iframe](djV11Xbc914)
::md-iframe{video-id="djV11Xbc914"}
::
```[yt-iframe](djV11Xbc914)```
```
::md-iframe{video-id="djV11Xbc914"}
::
```
![test](https://media.discordapp.net/attachments/413884110667251722/886474243662037062/image1.jpg)
![test](https://upload.wikimedia.org/wikipedia/commons/5/57/View_of_the_Turin_Metro_tracks_from_Fermi_terminal.jpg)
<a href="https://commons.wikimedia.org/wiki/File:View_of_the_Turin_Metro_tracks_from_Fermi_terminal.jpg">Wikilimes</a> (that's me!), <a href="https://creativecommons.org/licenses/by-sa/4.0">CC BY-SA 4.0</a>, via Wikimedia Commons
***
<video controls>
<source src="https://cdn.discordapp.com/attachments/413884110667251722/878216238940160040/video0.mov">
<source src="https://upload.wikimedia.org/wikipedia/commons/transcoded/5/5f/Steamboat_Willie_%281928%29_by_Walt_Disney.webm/Steamboat_Willie_%281928%29_by_Walt_Disney.webm.720p.vp9.webm">
</video>
Blogposts whose filename starts with a \_ will not show up on the /blogs page, but will still be accessible from the url (keep in mind that the file is still going to be publicly accessible on GitHub).
Blogposts with filename starting with \_ will not show up on the /blogs page, but will still be accessible via the url.

View File

@@ -1,3 +1,7 @@
---
description: "Instructions on how to set up Pretendo on the 3DS/2DS family of consoles."
---
# 3DS/2DS Family
<div class="tip red">

View File

@@ -1,3 +1,7 @@
---
description: "Instructions on how to set up Pretendo on Azahar, the 3DS/2DS emulator."
---
# Azahar
<div class="tip red">

View File

@@ -1,3 +1,7 @@
---
description: "Instructions on how to set up Pretendo on Cemu, the Wii U emulator."
---
<div class="tip green">This Guide may be missing some info or incomplete.</div>
# Cemu

View File

@@ -1,3 +1,7 @@
---
description: "Citra is not supported. Please use Azahar instead."
---
# Citra
Citra is not supported. Please use <a href="/docs/install/azahar" target="_blank">Azahar</a> instead.

View File

@@ -1,3 +1,7 @@
---
description: "Instructions on how to install Juxtaposition."
---
# Installing Juxtaposition
<div class="tip">

View File

@@ -1,3 +1,7 @@
---
description: "Instructions on how to set up Pretendo on the Wii U console."
---
# Wii U
<div class="tip red">

View File

@@ -1,3 +1,7 @@
---
description: "Instructions on how to gather network dumps of games."
---
# Network Dumps
One of the best ways to support the project is to help in gathering network dumps of games. These network dumps can be used by developers to help understand how the servers operate in a live environment. This kind of data greatly simplifies development of the servers, as it removes a large amount of guesswork and reverse engineering.

View File

@@ -0,0 +1,83 @@
{
"people": [
{
"name": "superwhiskers",
"captionKey": "forCrunch",
"picture": "https://github.com/superwhiskers.png",
"github": "https://github.com/superwhiskers"
},
{
"name": "Stary",
"captionKey": "forCtrDev",
"picture": "https://github.com/Stary2001.png",
"github": "https://github.com/Stary2001"
},
{
"name": "rverse",
"captionKey": "forMiiverseHelp",
"picture": "https://github.com/rverseTeam.png",
"github": "https://twitter.com/rverseClub"
},
{
"name": "Kinnay",
"isSpecial": true,
"captionKey": "forResearch",
"picture": "https://cdn.discordapp.com/avatars/186572995848830987/b55c0d4e7bfd792edf0689f83a25d8ea.png?size=128",
"github": "https://github.com/Kinnay"
},
{
"name": "NinStar",
"captionKey": "forIcons",
"picture": "https://github.com/ninstar.png",
"github": "https://github.com/ninstar"
},
{
"name": "Rambo6Glaz",
"captionKey": "forServerDev",
"picture": "https://github.com/EpicUsername12.png",
"github": "https://github.com/EpicUsername12"
},
{
"name": "GaryOderNichts",
"captionKey": "forPatches",
"picture": "https://github.com/GaryOderNichts.png",
"github": "https://github.com/GaryOderNichts"
},
{
"name": "zaksabeast",
"captionKey": "forPatches",
"picture": "https://cdn.discordapp.com/avatars/219324395707957248/c62573fbd4d26c8b4724f54413df6960.png?size=128",
"github": "https://github.com/zaksabeast"
},
{
"name": "mrjvs",
"captionKey": "forServers",
"picture": "https://github.com/mrjvs.png",
"github": "https://github.com/mrjvs"
},
{
"name": "binaryoverload",
"captionKey": "forServers",
"picture": "https://github.com/binaryoverload.png",
"github": "https://github.com/binaryoverload"
},
{
"name": "Simonx22",
"captionKey": "forSplatoon",
"picture": "https://github.com/Simonx22.png",
"github": "https://github.com/Simonx22"
},
{
"name": "OatmealDome",
"captionKey": "forSplatoon",
"picture": "https://github.com/OatmealDome.png",
"github": "https://github.com/OatmealDome"
},
{
"name": "GitHub contributors",
"captionKey": "general",
"picture": "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png",
"github": "https://github.com/PretendoNetwork"
}
]
}

76
content/team.json Normal file
View File

@@ -0,0 +1,76 @@
{
"people": [
{
"name": "Jonathan Barrow (jonbarrow)",
"captionKey": "owner",
"picture": "https://github.com/jonbarrow.png",
"github": "https://github.com/jonbarrow"
},
{
"name": "Jemma (CaramelKat)",
"captionKey": "miiverseDev",
"picture": "https://github.com/caramelkat.png",
"github": "https://github.com/CaramelKat"
},
{
"name": "quarky",
"captionKey": "wiiuResearchAndPatchDev",
"picture": "https://github.com/ashquarky.png",
"github": "https://github.com/ashquarky"
},
{
"name": "SuperMarioDaBom",
"captionKey": "researchAndServerArch",
"picture": "https://github.com/supermariodabom.png",
"github": "https://github.com/SuperMarioDaBom"
},
{
"name": "limes.pink",
"captionKey": "webDev",
"picture": "https://github.com/limesdotpink.png",
"github": "https://github.com/limesdotpink"
},
{
"name": "Shutterbug2000",
"captionKey": "researchAndGameDev",
"picture": "https://cdn.discordapp.com/avatars/191370953807233024/0311b61e2009c1576828dd2e9a59d72e.png?size=128",
"github": "https://github.com/shutterbug2000"
},
{
"name": "Billy",
"captionKey": "preserveAndServerArch",
"picture": "https://github.com/InternalLoss.png",
"github": "https://github.com/InternalLoss"
},
{
"name": "DaniElectra",
"captionKey": "researchAndGameDev",
"picture": "https://github.com/danielectra.png",
"github": "https://github.com/DaniElectra"
},
{
"name": "niko",
"captionKey": "webDevAndGameDev",
"picture": "https://github.com/hauntii.png",
"github": "https://github.com/hauntii"
},
{
"name": "MatthewL246",
"captionKey": "devopsAndCommunityWork",
"picture": "https://github.com/MatthewL246.png",
"github": "https://github.com/MatthewL246"
},
{
"name": "wolfendale",
"captionKey": "gameDevAndOptimise",
"picture": "https://github.com/wolfendale.png",
"github": "https://github.com/wolfendale"
},
{
"name": "TraceEntertains",
"captionKey": "ctrResearchAndPatchDev",
"picture": "https://github.com/TraceEntertains.png",
"github": "https://github.com/TraceEntertains"
}
]
}

View File

@@ -1,104 +0,0 @@
# Familia 3DS/2DS
<div class="tip red">
<strong>¡PRECAUCIÓN!</strong>
LAS TRANSFERENCIAS DE SISTEMA NO ESTÁN ACTUALMENTE SOPORTADAS POR NUESTROS SERVIDORES. INTENTAR REALIZAR UNA TRANSFERENCIA DE SISTEMA PUEDE EVITAR QUE PUEDAS CONECTARTE EN LÍNEA EN EL FUTURO. EL SOPORTE PARA LAS TRANSFERENCIAS DE SISTEMA ESTÁ EN DESARROLLO.
</div>
<div class="tip">
Esta guía asume que tienes un <b>Sistema con Homebrew ejecutando la última versión de Luma3DS (13+)</b>. Si no es así, por favor sigue esta <a href="https://3ds.hacks.guide/" target="_blank">guía</a> para hacer Homebrew en tu sistema primero.
</div>
Los siguientes pasos son necesarios para que te puedas conectar a la Red Pretendo:
1. [Descargar Nimbus](#downloading-nimbus)
2. [Habilitar parches de Luma](#luma-patches)
3. [Nimbus](#using-nimbus)
## Descargar Nimbus
<div class="tip">
Nimbus también está disponible en <a href="https://db.universal-team.net/3ds/nimbus" target="_blank">Universal-Updater</a>. Si no tienes Universal-Updater, puedes seguir esta <a href="https://universal-team.net/projects/universal-updater.html" target="_blank">guía</a>. Puedes descargar los archivos requeridos desde allí en lugar de GitHub, o instalar/actualizar la aplicación directamente desde tu consola.
<br>
<br>
Si lo instalas directamente desde tu consola por primera vez, aún necesitarás instalar los parches IPS asociados desde GitHub. Una vez instalado, las actualizaciones pueden gestionarse exclusivamente desde Universal-Updater.
</div>
Antes de comenzar, apaga tu consola e inserta la tarjeta SD en tu computadora.
Una vez insertada, descarga la última versión de [Nimbus](https://github.com/PretendoNetwork/Nimbus/releases/latest).
Nimbus está disponible tanto como una aplicación 3DSX como un CIA instalable. La página de versiones ofrece descargas de ambas opciones. Selecciona la versión que prefieras utilizar, o selecciona el archivo `combined.[versión].zip` para utilizar ambos.
<img src="/assets/images/docs/install/3ds/zip-highlight.png" width=100% height=auto/>
Extrae el contenido del archivo zip a la raíz de tu tarjeta SD. Si se te pregunta si deseas fusionar o sobrescribir archivos, acepta los cambios.
Asegúrate de que tu tarjeta SD tenga todos los siguientes archivos:
- `SD:/luma/titles/000400300000BC02/code.ips` (Miiverse, JPN)
- `SD:/luma/titles/000400300000BD02/code.ips` (Miiverse, USA)
- `SD:/luma/titles/000400300000BE02/code.ips` (Miiverse, EUR)
- `SD:/luma/sysmodules/0004013000002F02.ips` (SSL)
- `SD:/luma/sysmodules/0004013000003202.ips` (FRD/Friends)
- `SD:/luma/sysmodules/0004013000003802.ips` (ACT/NNID)
- `SD:/3ds/juxt-prod.pem` (certificado de Juxtaposition)
Si no lo instalaste a través de Universal-Updater, asegúrate de que al menos uno de los siguientes también esté presente:
- `SD:/cias/nimbus.cia`
- `SD:/3ds/nimbus.3dsx`
Vuelve a insertar tu tarjeta SD en la consola.
## Parches de Luma
<div class="tip">
<b>Salta este paso si ya has habilitado los parches necesarios en tu consola para la Red Pretendo.</b>
</div>
Para usar el servicio de Red Pretendo, necesitarás habilitar los parches de Luma en tu consola. Mantén presionado el botón `SELECT` en tu 3DS y enciéndela.
En la pantalla que se muestra, asegúrate de que las siguientes opciones estén habilitadas:
- `Habilitar carga de firmwares y módulos externos`
- `Habilitar parcheo de juegos`
Presiona `START` para guardar y continuar con estos cambios.
## Instalación de Nimbus en el Menú HOME
<div class="tip">
<b>Salta este paso si solo descargaste el archivo zip de 3DSX.</b>
</div>
Si descargaste los archivos combinados o CIA, puedes instalar Nimbus en el Menú HOME para un acceso rápido y sencillo.
Abre FBI. Si no tienes FBI, descarga la última versión desde [GitHub](https://github.com/lifehackerhansol/FBI/releases/latest). Selecciona `SD`, luego `cias`. Encuentra y selecciona `nimbus.cia`. Selecciona `Instalar CIA` o `Instalar y eliminar CIA`.
Una vez que haya terminado la instalación, presiona el botón HOME y sal de FBI. Deberías ver un mensaje indicando que se ha añadido una nueva aplicación al Menú HOME. Haz clic en OK y ahora tendrás Nimbus en tu Menú HOME.
## Usando Nimbus
Dependiendo de cómo hayas instalado Nimbus, ábrelo a través del Homebrew Launcher o el Menú HOME de 3DS. Selecciona `Pretendo` o `Nintendo` para cambiar entre los servicios.
Tu selección persistirá entre reinicios.
## Iniciar sesión en tu PNID
El 3DS no depende de los NNID para la gran mayoría de los servidores de juegos. Debido a esto, el uso de un PNID tampoco es necesario para la mayoría de los juegos<sup><a>[[1]](#footnote-1)</a></sup>.
Configurar un PNID en el 3DS es similar a configurar un NNID. Puedes crear el PNID en tu consola o registrarlo desde una cuenta en [nuestro sitio web](/account/register) y vincularlo a tu consola en una fecha posterior.
Se recomienda registrar el PNID en tu dispositivo en este momento, ya que registrar en el sitio web actualmente no te permite cambiar tus datos de usuario.
## Otra información
### ¿Cómo funciona Nimbus?
Nimbus creará una segunda cuenta local establecida en el entorno `test` NASC. Los parches IPS establecerán que las URLs del entorno `test` NASC apunten a Pretendo. Puedes alternar libremente entre Pretendo y Nintendo. Tu modo seleccionado persistirá entre reinicios.
### ¿Segunda cuenta local?
Quizás te hayas preguntado: _"¿Segunda cuenta local? ¿Qué es eso? Pensé que la 3DS solo tenía una cuenta."_ Y estarías medio en lo correcto. La 3DS normalmente solo admite una cuenta, y solo puedes tener una cuenta activa a la vez. Sin embargo, Nintendo implementó soporte para múltiples cuentas locales en la 3DS/2DS que permanece sin usar en todas las unidades minoristas. En una unidad minorista normal, solo se crea una cuenta local, que se establece en el entorno `prod` NASC. Nimbus utiliza esta función no utilizada para crear cuentas locales en un entorno sandbox con diferentes entornos.
<ul id="footnotes">
<li id="footnote-1"><sup>[1]</sup> Algunos juegos pueden requerir un PNID para ciertas acciones, como compras en eShop. El único juego conocido que requiere un PNID para uso general es Nintendo Badge Arcade, que aún no es compatible.</li>
</ul>

View File

@@ -1,26 +1,45 @@
import pluginVue from 'eslint-plugin-vue';
import eslintConfig from '@pretendonetwork/eslint-config';
import globals from 'globals';
import withNuxt from './.nuxt/eslint.config.mjs';
export default [
export default withNuxt([
...eslintConfig,
...pluginVue.configs['flat/recommended'],
{
files: ['public/**'],
rules: {
'@typescript-eslint/explicit-function-return-type': 'off',
'@eslint-community/eslint-comments/disable-enable-pair': 'off',
'no-restricted-imports': 'off'
}
},
{
files: ['*.vue', '**/*.vue'],
languageOptions: {
parserOptions: {
parser: '@typescript-eslint/parser'
},
globals: {
...globals.browser,
...globals.node
...globals.browser
}
},
rules: {
'vue/multi-word-component-names': 'off'
}
},
{
settings: {
'import/resolver': {
typescript: {
alwaysTryTypes: true,
project: [
'./.nuxt/tsconfig.json'
]
}
}
}
},
{
files: ['src/**'],
languageOptions: {
globals: {
...globals.node
}
}
},
{
ignores: ['**/*.bundled.js']
ignores: ['.output', '.nuxt', '.old']
}
];
]);

View File

@@ -1,50 +0,0 @@
{
"http": {
"port": 3000,
"base_url": "http://localhost:3000"
},
"github": {
"graphql_token": "ghp_..."
},
"discord": {
"roles": {
"supporter": "role_id",
"tester": "role_id"
},
"guild_id": "guild_id",
"client_id": "client_id",
"client_secret": "client_secret",
"bot_token": "bot_token"
},
"stripe": {
"goal_cents": 300000,
"secret_key": "secret_key",
"webhook_secret": "webhook_secret",
"notification_emails": []
},
"database": {
"account": {
"uri": "mongodb://127.0.0.1:27017",
"database": "database_name",
"connection_string": "mongodb://127.0.0.1:27017",
"options": {
"useNewUrlParser": true,
"useUnifiedTopology": true
}
}
},
"email": {
"ses": {
"region": "AWS SES us-east-1",
"key": "AWS SES access key",
"secret": "AWS SES secret key"
},
"from": "Firstname Lastname <user@domain.com>"
},
"trello": {
"api_key": "api_key",
"api_token": "api_token",
"board_name": "board_name"
},
"api_base": "https://api.domain.com"
}

49
example.env Normal file
View File

@@ -0,0 +1,49 @@
# The defaults in this file are meant for the docker-compose based setup.
# Core
PN_WEBSITE_PUBLIC_BASE_URL=http://localhost:3000
PN_WEBSITE_PUBLIC_CDN_BASE_URL=http://pretendo.localhost:3902
PN_WEBSITE_PUBLIC_COOKIE_SECURE=false
PN_WEBSITE_PUBLIC_REDIRECT_HOSTS=localhost:3000
PN_WEBSITE_TRUST_PROXY=false
PN_WEBSITE_REDIS_URL=redis://localhost:6379
# Optional - Authentication
PN_WEBSITE_GRPC_HOST=localhost:8123
PN_WEBSITE_GRPC_API_KEY=12345678123456781234567812345678
PN_WEBSITE_API_BASE=http://localhost:8056
PN_WEBSITE_API_BASE_HOST=api.pretendo.cc
# --- Partially configured features ---
# You will need to fill in some parts in this section before these features start working
# Optional - Github progress tracking:
PN_WEBSITE_GITHUB_API_TOKEN=
# Optional - Discord:
PN_WEBSITE_DISCORD_BOT_TOKEN=
PN_WEBSITE_DISCORD_CLIENT_ID=
PN_WEBSITE_DISCORD_CLIENT_SECRET=
PN_WEBSITE_DISCORD_GUILD_ID=
PN_WEBSITE_DISCORD_TESTER_ROLE_ID=
PN_WEBSITE_DISCORD_SUPPORTER_ROLE_ID=
# Optional - Donation features + rewards (Requires discord):
PN_WEBSITE_STRIPE_SECRET_KEY=
PN_WEBSITE_STRIPE_WEBHOOK_SECRET=
PN_WEBSITE_STRIPE_NOTIFICATION_EMAIL=notifs@example.com
PN_WEBSITE_MONGO_CONNECTION_STRING=mongodb://localhost:27017/account?directConnection=true
PN_WEBSITE_SMTP_HOST=localhost
PN_WEBSITE_SMTP_USER=localhost
PN_WEBSITE_SMTP_PORT=1025
PN_WEBSITE_SMTP_SECURE=false
PN_WEBSITE_SMTP_FROM_EMAIL=pretendo@example.com
PN_WEBSITE_SMTP_FROM_NAME=Pretendo Network
# Optional - Captchas:
PN_WEBSITE_HCAPTCHA_SECRET_KEY=
PN_WEBSITE_PUBLIC_HCAPTCHA_SITE_KEY=
# Optional - Discourse SSO:
PN_WEBSITE_DISCOURSE_SSO_SECRET=

4
i18n.config.ts Normal file
View File

@@ -0,0 +1,4 @@
export default {
fallbackLocale: 'en-US',
warnHtmlMessage: false
};

252
nuxt.config.ts Normal file
View File

@@ -0,0 +1,252 @@
export default defineNuxtConfig({
compatibilityDate: '2026-08-07',
srcDir: './src',
dir: {
public: './src/public'
},
nitro: {
prerender: {
routes: ['/blog/feed.xml']
}
},
vite: {
server: {
allowedHosts: ['pretendo.network']
}
},
modules: [
'@pinia/nuxt',
'@nuxt/eslint',
'@nuxt/fonts',
'@nuxt/icon',
'@nuxt/content',
'@nuxtjs/i18n'
],
eslint: {
config: {
standalone: false
}
},
routeRules: {
'/docs': { redirect: '/docs/welcome' }
},
runtimeConfig: {
nitro: {
envPrefix: 'PN_WEBSITE_'
},
trustProxy: false,
githubApiToken: '',
stripeSecretKey: '',
stripeWebhookSecret: '',
stripeNotificationEmail: '',
hcaptchaSecretKey: '',
grpcHost: '',
grpcApiKey: '',
mongoConnectionString: '',
smtpHost: '',
smtpPort: 587,
smtpUser: '',
smtpPassword: '',
smtpSecure: true,
smtpFromEmail: '',
smtpFromName: '',
discordBotToken: '',
discordClientId: '',
discordClientSecret: '',
discordGuildId: '',
discordTesterRoleId: '',
discordSupporterRoleId: '',
discourseSsoSecret: '',
apiBase: 'https://api.pretendo.cc',
apiBaseHost: 'api.pretendo.cc',
redisUrl: '',
public: {
baseUrl: 'https://pretendo.network',
cdnBaseUrl: 'https://r2-cdn.pretendo.cc',
redirectHosts: 'pretendo.network',
hcaptchaSiteKey: '',
cookieSecure: true
}
},
css: ['~/assets/css/main.css'],
fonts: {
defaults: {
weights: [400, 700],
styles: ['normal', 'italic']
}
},
icon: {
clientBundle: {
scan: true
},
provider: 'none',
serverBundle: 'local'
},
components: [
{
path: '~/components',
pathPrefix: false
}
],
content: {
build: {
markdown: {
highlight: {
theme: 'github-dark'
}
}
}
},
app: {
head: {
link: [
{
rel: 'icon',
type: 'image/x-icon',
href: '/assets/images/icons/favicon.ico'
},
{
rel: 'apple-touch-icon',
sizes: '180x180',
href: '/assets/images/icons/apple-touch-icon.png'
},
{
rel: 'icon',
sizes: '32x32',
type: 'image/png',
href: '/assets/images/icons/favicon-32x32.png'
},
{
rel: 'icon',
sizes: '16x16',
type: 'image/png',
href: '/assets/images/icons/favicon-16x16.png'
},
{
rel: 'mask-icon',
href: '/assets/images/icons/safari-pinned-tab.svg',
color: '#1b1f3b'
},
{ rel: 'manifest', href: '/assets/site.webmanifest' },
{
rel: 'alternate',
type: 'application/rss+xml',
title: 'Pretendo Network Blog',
href: '/blog/feed.xml'
}
],
meta: [
{ name: 'msapplication-config', content: '/assets/browserconfig.xml' },
{ 'http-equiv': 'X-UA-Compatible', 'content': 'ie=edge' },
{ name: 'apple-mobile-web-app-title', content: 'Pretendo Network' },
{ name: 'application-name', content: 'Pretendo Network' },
{ name: 'msapplication-TileColor', content: '#1b1f3b' },
{ name: 'theme-color', content: '#1b1f3b' },
{
property: 'og:description',
content:
'An open source Nintendo Network replacement that aims to build custom servers for the WiiU and 3DS family of consoles'
},
{ property: 'og:type', content: 'website' },
{ property: 'og:url', content: 'https://pretendo.network' },
{
property: 'og:image',
content:
'https://pretendo.network/assets/images/opengraph/opengraph-image.png'
},
{ property: 'og:image:alt', content: '' },
{ property: 'og:site_name', content: 'Pretendo Network' },
{ property: 'twitter:url', content: 'https://pretendo.network/' },
{ property: 'twitter:card', content: 'summary_large_image' },
{ property: 'twitter:site', content: '@PretendoNetwork' },
{
property: 'twitter:description',
content:
'An open source Nintendo Network replacement that aims to build custom servers for the WiiU and 3DS family of consoles'
},
{
property: 'twitter:image',
content:
'https://pretendo.network/assets/images/opengraph/opengraph-image.png'
},
{
name: 'description',
content:
'An open source Nintendo Network replacement that aims to build custom servers for the WiiU and 3DS family of consoles'
},
{ property: 'robots', content: 'index, follow' }
]
}
},
i18n: {
compilation: {
strictMessage: false
},
restructureDir: 'src',
strategy: 'no_prefix',
defaultLocale: 'en-US',
vueI18n: '../i18n.config.ts',
locales: [
{ code: 'ar-AR', name: 'العربية', file: 'ar_AR.json' },
{ code: 'ast', name: 'Asturianu', file: 'ast.json' },
{ code: 'be-BY', name: 'Беларуская', file: 'be_BY.json' },
{ code: 'ca-ES', name: 'Català', file: 'ca_ES.json' },
{ code: 'cs-CZ', name: 'Čeština', file: 'cs_CZ.json' },
{ code: 'cy-GB', name: 'Cymraeg', file: 'cy_GB.json' },
{ code: 'da-DK', name: 'Dansk', file: 'da_DK.json' },
{ code: 'de-DE', name: 'Deutsch', file: 'de_DE.json' },
{ code: 'el-GR', name: 'Ελληνικά', file: 'el_GR.json' },
{ code: 'en-GB', name: 'English (United Kingdom)', file: 'en_GB.json' },
{ code: 'en-US', name: 'English (United States)', file: 'en_US.json' },
{ code: 'en@uwu', name: 'English (lolcat)', file: 'en@uwu.json' },
{ code: 'eo-XX', name: 'Esperanto', file: 'eo_XX.json' },
{ code: 'es-ES', name: 'Español', file: 'es_ES.json' },
{ code: 'fi-FI', name: 'Suomi', file: 'fi_FI.json' },
{ code: 'fr-CA', name: 'Français (Canada)', file: 'fr_CA.json' },
{ code: 'fr-FR', name: 'Français', file: 'fr_FR.json' },
{ code: 'ga-IE', name: 'Gaeilge', file: 'ga_IE.json' },
{ code: 'gd-GB', name: 'Gàidhlig', file: 'gd_GB.json' },
{ code: 'gl-ES', name: 'Galego', file: 'gl_ES.json' },
{ code: 'hr-HR', name: 'Hrvatski', file: 'hr_HR.json' },
{ code: 'hu-HU', name: 'Magyar', file: 'hu_HU.json' },
{ code: 'id-ID', name: 'Bahasa Indonesia', file: 'id_ID.json' },
{ code: 'it-IT', name: 'Italiano', file: 'it_IT.json' },
{ code: 'ja-JP', name: '日本語', file: 'ja_JP.json' },
{ code: 'kk-KZ', name: 'Қазақша', file: 'kk_KZ.json' },
{ code: 'ko-KR', name: '한국어', file: 'ko_KR.json' },
{ code: 'lt-LT', name: 'Lietuvių', file: 'lt_LT.json' },
{ code: 'lv-LV', name: 'Latviešu', file: 'lv_LV.json' },
{ code: 'nb-NO', name: 'Norsk bokmål', file: 'nb_NO.json' },
{ code: 'nl-NL', name: 'Nederlands', file: 'nl_NL.json' },
{ code: 'pl-PL', name: 'Polski', file: 'pl_PL.json' },
{ code: 'pt-BR', name: 'Português (Brasil)', file: 'pt_BR.json' },
{ code: 'pt-PT', name: 'Português (Portugal)', file: 'pt_PT.json' },
{ code: 'ro-RO', name: 'Română', file: 'ro_RO.json' },
{ code: 'ru-RU', name: 'Русский', file: 'ru_RU.json' },
{ code: 'sk-SK', name: 'Slovenčina', file: 'sk_SK.json' },
{ code: 'sr-RS', name: 'Српски', file: 'sr_RS.json' },
{ code: 'sv-SE', name: 'Svenska', file: 'sv_SE.json' },
{ code: 'tr-TR', name: 'Türkçe', file: 'tr_TR.json' },
{ code: 'uk-UA', name: 'Українська', file: 'uk_UA.json' },
{ code: 'tr-TR', name: 'Türkçe', file: 'tr_TR.json' },
{ code: 'uk-UA', name: 'Українська', file: 'uk_UA.json' },
{ code: 'zh-CN', name: '中文 (简体)', file: 'zh_CN.json' },
{ code: 'zh-Hant', name: '中文 (繁體)', file: 'zh_Hant.json' }
]
}
});

29908
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,57 +1,70 @@
{
"name": "website",
"version": "1.0.0",
"description": "",
"main": "src/server.js",
"description": "Website for Pretendo Network",
"license": "AGPL-3.0-only",
"type": "module",
"scripts": {
"start": "node src/server.js",
"build": "npm run browserify",
"start": "node --env-file=.env --enable-source-maps .output/server/index.mjs",
"build": "nuxt build",
"prepare": "nuxt prepare",
"dev": "nuxt dev",
"dev:host": "nuxt dev --host",
"typecheck": "nuxt typecheck",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"browserify": "npm run browserify-miieditor && npm run browserify-reset-password",
"browserify-miieditor": "browserify ./public/assets/js/miieditor.js -o ./public/assets/js/miieditor.bundled.js",
"browserify-reset-password": "browserify ./public/assets/js/reset-password.js -o ./public/assets/js/reset-password.bundled.js"
"lint:fix": "eslint . --fix"
},
"repository": {
"type": "git",
"url": "git+https://github.com/PretendoNetwork/website.git"
},
"author": "",
"license": "ISC",
"bugs": {
"url": "https://github.com/PretendoNetwork/website/issues"
},
"homepage": "https://github.com/PretendoNetwork/website#readme",
"dependencies": {
"@aws-sdk/client-ses": "^3.515.0",
"@discordjs/rest": "^2.6.3",
"@node-saml/node-saml": "^5.0.0",
"@pretendonetwork/error-codes": "^1.0.3",
"@pretendonetwork/grpc": "^2.2.3",
"browserify": "^17.0.0",
"colors": "^1.4.0",
"cookie-parser": "^1.4.5",
"discord-api-types": "^0.38.52",
"discord-oauth2": "github:ryanblenis/discord-oauth2",
"express": "^5.2.1",
"express-handlebars": "^5.3.1",
"express-locale": "^2.0.0",
"fs-extra": "^9.1.0",
"got": "^11.8.5",
"graphql-request": "^4.3.0",
"gray-matter": "^4.0.3",
"lodash.merge": "^4.6.2",
"marked": "^4.0.10",
"mii-js": "github:PretendoNetwork/mii-js#v1.0.4",
"mongoose": "^6.4.0",
"morgan": "^1.10.0",
"nice-grpc": "^2.1.16",
"nodemailer": "^6.7.5",
"stripe": "^9.9.0"
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"@nuxt/content": "^3.4.0",
"@nuxt/eslint": "^1.3.0",
"@nuxt/fonts": "^0.14.0",
"@nuxt/icon": "^2.5.0",
"@nuxtjs/i18n": "^10.6.0",
"@pinia/nuxt": "^1.0.1",
"@pretendonetwork/error-codes": "^1.2.2",
"@pretendonetwork/grpc": "^2.5.4",
"@pretendonetwork/mii-js": "^1.0.11",
"@vueuse/core": "^14.4.0",
"better-sqlite3": "^12.11.1",
"discord-api-types": "^0.38.53",
"eslint": "^9.39.5",
"feed": "^6.0.0",
"hcaptcha": "^0.2.0",
"ioredis": "^5.11.1",
"mlly": "^1.8.2",
"mongodb": "^7.5.0",
"nice-grpc": "^2.1.17",
"nodemailer": "^9.0.5",
"nuxt": "^4.5.2",
"octokit": "^5.0.5",
"papr": "^17.1.1",
"rate-limiter-flexible": "^11.2.0",
"reka-ui": "^2.10.3",
"stripe": "^22.4.0",
"text-mask-core": "^5.1.2",
"vue": "^3.5.13",
"vue-router": "^5.2.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@pretendonetwork/eslint-config": "^0.0.6",
"eslint": "^9.18.0",
"globals": "^15.14.0"
"@iconify-json/fa7-brands": "^1.2.4",
"@iconify-json/ph": "^1.2.2",
"@pretendonetwork/eslint-config": "^0.1.4",
"@types/nodemailer": "^8.0.1",
"eslint-plugin-vue": "^10.0.0",
"sass-embedded": "^1.86.3",
"vue-tsc": "^3.3.10"
},
"allowScripts": {
"better-sqlite3@12.11.1": true,
"esbuild@0.28.1": true,
"esbuild@0.25.12": true,
"protobufjs@7.6.5": true,
"unrs-resolver@1.12.2": true,
"@parcel/watcher@2.6.0": true,
"vue-demi@0.14.10": true,
"esbuild@0.27.2": true
}
}

View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="https://pretendo.network/assets/images/icons/mstile-150x150.png"/>
<TileColor>#1b1f3b</TileColor>
</tile>
</msapplication>
</browserconfig>

View File

@@ -1,24 +0,0 @@
.status {
text-align: center;
font-size: 8rem;
padding-top: 60px;
color: var(--text-shade-1);
}
.description {
text-align: center;
font-size: 1.7rem;
margin-top: -10px;
color: var(--text-shade-1);
}
.shocked-bandwidth {
display: block;
margin-left: auto;
margin-right: auto;
margin-top: 70px;
margin-bottom: -120px;
}
@media screen and (max-width: 900px) {
.shocked-bandwidth {
margin-bottom: -100px;
}
}

View File

@@ -1,350 +0,0 @@
/* Removing until it's done */
.sign-in-history a {
display: none;
}
.account-wrapper {
display: grid;
column-gap: 48px;
margin-top: 80px;
color: var(--text-shade-1);
}
/* Account settings sidebar */
.account-sidebar .user {
margin: 55px auto;
width: fit-content;
display: flex;
flex-flow: column;
align-items: center;
}
.account-sidebar .user .miiname {
font-size: 1.2rem;
color: var(--text-shade-3);
margin: 8px 0 4px;
}
.account-sidebar .user .username {
margin: 0;
}
.account-sidebar .user .tier-name {
margin: 12px 0;
line-height: 1.2em;
border-radius: 1.2em;
border-width: 2px;
border-style: solid;
padding: 4px 16px;
}
.account-sidebar .user .tier-level-0,
.account-sidebar .user .access-level-0 {
background: #2a2f50;
color: var(--text-shade-1);
border-color: #383f6b;
}
.account-sidebar .user .tier-level-1 {
background: rgba(255, 132, 132, 0.2);
color: #FF8484;
border-color: rgba(255, 132, 132, 0.8);
}
.account-sidebar .user .tier-level-2 {
background: rgba(89, 201, 165, 0.3);
color:#59c9a5;
border-color: #59c9a5;
}
.account-sidebar .user .tier-level-3 {
background: rgba(202, 177, 251, 0.3);
color:var(--accent-shade-3);
border-color: var(--accent-shade-3);
}
.account-sidebar .user .access-level-banned {
background: rgba(255, 63, 0, 0.1);
color:#FF3F00;
border-color: rgba(255, 63, 0, 0.8);
}
.account-sidebar .user .access-level-1 {
background: rgba(100, 247, 239, 0.3);
color: #64F7EF;
border-color: #64F7EF;
}
.account-sidebar .user .access-level-2 {
background: rgba(255, 199, 89, 0.3);
color: #FFC759;
border-color: #FFC759;
}
.account-sidebar .user .access-level-3 {
background: rgba(90, 255, 21, 0.3);
color:#5AFF15;
border-color: #5AFF15;
}
.account-sidebar .user a.mii {
position: relative;
display: block;
width: 128px;
height: 128px;
overflow: hidden;
border-radius: 100%;
background: var(--bg-shade-3);
}
.account-sidebar .user a.mii::after {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: no-repeat center/40% url("/assets/images/edit.svg"), rgba(55, 60, 101, 0.7);
opacity: 0;
transition: opacity 150ms;
}
.account-sidebar .user a.mii:hover::after {
opacity: 1;
}
.account-sidebar .user .mii {
width: 100%;
height: 100%;
}
.account-sidebar .buttons a {
display: flex;
flex-flow: column;
align-items: center;
padding: 20px 24px;
margin: 20px 0 0;
text-decoration: none;
text-align: center;
}
.account-sidebar .buttons a svg {
margin-bottom: 16px;
}
.account-sidebar .buttons a p.caption {
margin: 0;
}
.account-sidebar .buttons p.cemu-warning {
margin: 4px 0 0;
font-size: 0.7rem;
color: var(--text-shade-1);
}
.account-sidebar .buttons #account-delete {
background-color: #F44336;
}
/* Settings */
.settings-wrapper {
display: grid;
grid-column-start: 2;
grid-template-columns: 1fr 1fr;
column-gap: 20px;
}
.settings-wrapper a {
color: var(--accent-shade-1);
text-decoration: none;
font-weight: bold;
}
.settings-wrapper a:hover {
text-decoration: underline;
}
.settings-wrapper h2.section-header {
margin-top: 40px;
grid-column: 1 / 3;
color: var(--text-shade-3);
}
.setting-card {
display: grid;
grid-template-rows: 35px repeat(2, auto);
row-gap: 24px;
position: relative;
border-radius: 10px;
background: var(--bg-shade-2);
padding: 48px 60px;
}
.setting-card * {
margin: 0;
}
.setting-card .edit {
color: var(--text-shade-1);
background: var(--bg-shade-3);
border-radius: 100%;
position: absolute;
top: 42px;
right: 48px;
width: 24px;
height: 24px;
padding: 12px;
}
.setting-card .edit:hover {
background: var(--bg-shade-3);
color: var(--text-shade-3);
}
.setting-card .edit svg {
pointer-events: none;
}
.setting-card .header {
color: var(--text-shade-3);
}
.setting-card .setting-list {
display: grid;
grid-template-columns: repeat(2, auto);
gap: 24px;
list-style: none;
padding: 0;
}
.setting-card .setting-list p.label {
color: var(--text-shade-3);
margin-bottom: 4px;
}
fieldset {
position: relative;
height: min-content;
padding: 0;
border: none;
}
.setting-card .server-selection {
display: flex;
border-radius: 5px;
overflow: hidden;
background: var(--bg-shade-3);
}
.setting-card .server-selection input {
display: none;
}
.server-selection input + label {
display: flex;
flex-flow: column;
align-items: center;
flex: 50%;
color: var(--text-shade-1);
padding: 40px;
justify-content: space-between;
cursor: pointer;
}
.server-selection input + label h2 {
margin-top: 12px;
color: var(--text-shade-1);
}
.server-selection input:checked + label,
.server-selection input:checked + label h2 {
background: var(--accent-shade-0);
color: var(--text-shade-3);
}
.setting-card #link-discord-account {
width: 100%;
padding: 12px 48px;
cursor: pointer;
background: var(--bg-shade-3);
}
.setting-card button {
width: 100%;
height: fit-content;
padding: 12px 48px;
align-self: flex-end;
cursor: pointer;
background: var(--bg-shade-3);
}
.setting-card.span-both-columns {
grid-column: 1 / span 2;
}
@keyframes banner-notice {
0% {
top: -150px;
}
20% {
top: 35px;
}
80% {
top: 35px;
}
100% {
top: -150px;
}
}
.banner-notice {
display: flex;
justify-content: center;
position: fixed;
top: -150px;
width: 100%;
animation: banner-notice 5s;
}
.banner-notice div {
padding: 4px 36px;
border-radius: 5px;
z-index: 3;
}
.banner-notice.success div {
background: var(--green-shade-0);
}
.banner-notice.error div {
background: var(--red-shade-1);
}
footer {
margin-top: 80px;
}
@media screen and (max-width: 1300px) {
.account-wrapper {
margin: 20px 0;
}
.settings-wrapper {
grid-column-start: 1;
}
.account-sidebar {
margin: 0;
}
.account-sidebar .user .mii {
width: 128px;
height: 128px;
}
}
@media screen and (max-width: 1000px) {
.settings-wrapper {
display: block;
width: 100%;
}
.setting-card {
margin-bottom: 24px;
}
}
@media screen and (max-width: 550px) {
.setting-card {
padding: 24px;
width: calc(100vw - 48px);
margin-left: -5vw;
margin-right: -2.5vw;
border-radius: 0;
margin-bottom: 12px;
}
.setting-card .edit {
top: 20px;
right: 20px;
transform: scale(0.85);
}
.setting-card .server-selection {
flex-flow: column;
}
}
@media screen and (max-width: 350px) {
.setting-card .setting-list {
grid-template-columns: auto;
}
}

View File

@@ -1,139 +0,0 @@
.new-font {
font-family: museo-sans, sans-serif;
}
.pretendo {
font-family: Poppins, Arial, Helvetica, sans-serif;
font-weight: 700;
}
.announcement-hero {
position: relative;
text-align: center;
padding: 96px 0;
margin: 36px 0 24px;
}
.announcement-hero p {
font-size: 24px;
margin: 0;
margin-bottom: 24px;
}
.announcement-hero h1 {
font-size: 450%;
margin: 0;
}
.announcement-hero::before {
content: "";
position: absolute;
width: 500vw;
height: 100%;
top: 0;
left: -50vw;
background: var(--accent-shade-0);
z-index: -1;
}
.bro-what.subscribe {
padding-top: 0;
display: flex;
}
.bro-what.subscribe h1 {
margin: 0;
margin-right: 12px;
width: fit-content;
}
.buy-now {
margin-left:auto;
}
.buy-now button {
cursor: pointer;
width: max-content;
height: 100%;
}
.bro-what {
padding: 48px;
}
.bro-what a {
color: inherit;
text-decoration: none;
font-weight: 700;
}
.dotted-bg {
position: relative;
}
.dotted-bg::before {
content: "";
position: absolute;
width: 500vw;
height: 100%;
top: 0;
left: -50vw;
background:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' height='100%25' width='100%25'%3E%3Cdefs%3E%3Cpattern id='doodad' width='6' height='6' viewBox='0 0 40 40' patternUnits='userSpaceOnUse' patternTransform=''%3E%3Crect width='100%25' height='100%25' fill='rgba(27, 31, 59,1)'/%3E%3Ccircle cx='20' cy='20' r='11' fill='rgba(103, 61, 182,0.4)'/%3E%3Cpath d='M9 20aInfinityInfinity 0 0 0InfinityNaNaInfinityInfinity 0 0 0-InfinityNaN' fill='%23ecc94b'/%3E%3C/pattern%3E%3C/defs%3E%3Crect fill='url(%23doodad)' height='200%25' width='200%25'/%3E%3C/svg%3E ");
z-index: -1;
}
.footnotes {
color: var(--text-shade-1);
}
@media screen and (max-width: 946px) {
header nav a:not(.keep-on-mobile) {
display: none;
}
.announcement-hero h1 {
font-size: 350%;
}
}
@media screen and (max-width: 724px) {
header .logo-link svg text {
display: none;
}
header .logo-link svg {
width: 39.876px;
}
header .logo-link {
margin-right: 10px;
}
header nav a {
margin: 0 10px;
}
.announcement-hero h1 {
font-size: 250%;
}
.announcement-hero p {
font-size: 18px;
}
}
@media screen and (max-width: 600px) {
.bro-what.subscribe {
flex-flow: column;
}
.bro-what a,
.buy-now button {
width: 100%;
}
.bro-what a {
margin-top: 24px;
}
.announcement-hero {
padding: 72px 0;
}
}
@media screen and (max-width: 480px) {
.bro-what {
padding: 36px 0;
}
}

View File

@@ -1,119 +0,0 @@
.blog-card {
display: flex;
flex-flow: row nowrap;
padding: 0;
margin: 0 auto;
max-width: 1000px;
margin-bottom: 30px;
text-decoration: none;
position: relative;
border-radius: 10px;
overflow: hidden;
}
.blog-card .post-info {
flex: 50%;
padding: 40px;
display: flex;
flex-flow: column;
color: var(--text-shade-1);
}
.blog-card .post-info .title {
color: var(--text-shade-3);
margin: 0;
}
.blog-card .post-info .caption {
margin: 4px 0 32px 0;
}
.blog-card .pub-info {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: left;
margin-top: auto;
}
.blog-card .pub-info .date {
font-weight: bold;
color: var(--text-shade-3);
}
.blog-card .pub-info > * {
margin-right: 0.5em;
margin-top: 0.2em;
}
.blog-card .profile {
display: inline-grid;
grid-template-columns: 30px auto;
grid-gap: 10px;
font-weight: bold;
color: var(--text-shade-3);
align-items: center;
height: 32px;
margin-right: 0.3em;
}
.blog-card .profile img {
border-radius: 4px;
border: 1px solid var(--border);
max-width: 100%;
}
.blog-card .cover {
flex: 50%;
border: 3px solid var(--bg-shade-0);
border-radius: 0 10px 10px 0;
}
.progress-hero a,
.progress-hero a * {
color: var(--accent-shade-1);
text-decoration: none;
font-weight: bold;
}
.progress-hero a:hover,
.progress-hero a:hover {
text-decoration: underline;
}
.buttons {
margin: 10vh auto;
width: min-content;
}
.buttons button.secondary.icon-btn {
cursor: pointer;
width: 35px;
height: 35px;
padding: 0;
}
footer {
margin-top: 160px;
}
@media screen and (max-width: 900px) {
.blog-card {
flex-flow: column;
}
.blog-card .post-info {
padding: 30px;
}
.blog-card .cover {
order: -1;
min-height: 250px;
border-radius: 10px 10px 0 0;
}
footer {
margin-top: 100px;
}
}
@media screen and (max-width: 450px) {
.blog-card .cover {
min-height: 200px;
}
}

View File

@@ -1,236 +0,0 @@
.wrapper {
display: flex;
flex-direction: column;
width: 95%;
min-height: 100vh;
}
header {
width: 100%;
}
.card-wrap {
width: 100%;
}
.blog-card {
padding: 60px;
max-width: 1100px;
margin: 50px auto;
color: var(--text-shade-1);
}
.blog-card h1,
.blog-card h2,
.blog-card h3,
.blog-card h4,
.blog-card h5,
.blog-card h6 {
margin: 40px 0 10px;
color: var(--text-shade-3);
}
.blog-card strong {
color: var(--text-shade-3);
}
.blog-card a,
.blog-card a * {
color: var(--accent-shade-1);
text-decoration: none;
font-weight: bold;
}
.blog-card a:hover,
.blog-card a:hover {
text-decoration: underline;
}
.blog-card del {
text-decoration: line-through;
}
.blog-card .title {
margin: 0;
margin-bottom: 8px;
}
.blog-card .pub-info {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: left;
margin-top: auto;
margin-bottom: 30px;
}
.blog-card .pub-info .date {
font-weight: bold;
color: var(--text-shade-3);
}
.blog-card .pub-info > * {
margin-right: 0.5em;
margin-top: 0.2em;
}
.blog-card .profile {
display: inline-grid;
grid-template-columns: 30px auto;
grid-gap: 10px;
font-weight: bold;
color: var(--text-shade-3);
align-items: center;
height: 32px;
margin-right: 0.3em;
}
.blog-card .profile img {
margin: 0;
border-radius: 4px;
border: 1px solid var(--border);
max-width: 100%;
}
.blog-card p,
.post-info {
color: var(--text-shade-1);
}
.blog-card img {
max-width: 100%;
max-height: 800px;
margin: 10px auto;
display: block;
border-radius: 4px;
border: 1px solid var(--border);
}
.blog-card img.emoji {
display: inline;
margin: 0;
border: none;
}
.blog-card video {
width: 100%;
border-radius: 4px;
border: 1px solid var(--border);
}
.blog-card iframe {
width: 100%;
aspect-ratio: 16/9;
border-radius: 4px;
border: 1px solid var(--border);
}
/* Fallback for aspect-ratio since it's unsupported by some browsers (looking at you Safari) */
@supports not (aspect-ratio: 16/9) {
.blog-card .aspectratio-fallback {
position: relative;
height: 0;
padding-top: 56.25%;
}
.blog-card iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 0;
}
}
/* Some twitter iframe specific stuff */
.blog-card .twitter-tweet {
margin: auto;
}
.blog-card .twitter-tweet iframe {
border: none; /* Fixes the double border */
}
.blog-card table {
border-radius: 4px;
border-collapse: collapse;
background: var(--bg-shade-3);
margin-bottom: 30px;
overflow: hidden;
}
.blog-card table th {
padding: 8px 12px;
background: var(--bg-shade-4);
color: var(--text-shade-3);
}
.blog-card table td {
padding: 8px 12px;
vertical-align: top;
border-radius: inherit;
}
.blog-card table tr:nth-child(even) {
background: var(--bg-shade-2);
}
.blog-card pre code {
border-radius: 4px;
margin-bottom: 30px;
}
.blog-card input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
display: inline-block;
background: var(--bg-shade-3);
padding: 12px;
margin: 4px;
border-radius: 4px;
vertical-align: -60%;
}
.blog-card input[type="checkbox"]:checked {
content: "checkboxtest";
background: no-repeat center/contain url(../images/check.svg),
var(--bg-shade-3);
}
.blog-card hr {
border: 1px solid var(--text-shade-1);
margin: 30px 0;
}
.blog-card blockquote {
border-left: 2px solid var(--text-shade-1);
padding: 8px 24px;
margin: 0;
margin-bottom: 30px;
}
@media screen and (min-width: 901px) {
.blog-card h1,
.blog-card h2,
.blog-card h3,
.blog-card h4,
.blog-card h5,
.blog-card h6 {
scroll-margin-top: 110px;
}
}
@media screen and (max-width: 800px) {
.blog-card {
padding: 40px;
}
}
@media screen and (max-width: 600px) {
.wrapper {
width: 100%;
}
header {
width: 90%;
margin: 35px auto;
}
.blog-card {
padding: 40px 5vw;
border-radius: 0;
margin-top: 0;
}
footer {
width: 95%;
margin: auto auto 40px;
}
}

View File

@@ -1,190 +0,0 @@
/* BUTTONS */
button,
.button {
appearance: none;
-webkit-appearance: none;
border: 0;
border-radius: 6px;
font-family: Poppins, Arial, Helvetica, sans-serif;
font-size: 1rem;
color: var(--text-shade-3);
padding: 12px 48px;
background: var(--bg-shade-3);
cursor: pointer;
display: block;
text-align: center;
}
button:hover,
.button:hover {
background: var(--bg-shade-4);
}
button.inactive {
pointer-events: none;
}
button.primary,
.button.primary {
background: var(--accent-shade-0);
}
button.primary:hover,
.button.primary:hover {
background: var(--accent-shade-1);
}
button.secondary.icon-btn,
.button.secondary.icon-btn {
width: 50px;
height: 50px;
display: flex;
justify-content: center;
align-items: center;
}
button svg,
.button svg {
width: 30px;
height: 30px;
display: block;
}
/* MODALS */
body.modal-open {
overflow: hidden;
}
div.modal-wrapper {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.6);
z-index: 10;
}
div.modal-wrapper.hidden {
display: none;
}
div.modal {
background: var(--bg-shade-3);
padding: 48px;
border-radius: 8px;
text-align: left;
width: min(660px, 90%);
box-sizing: border-box;
}
div.modal h1 {
margin-top: 0;
}
p.modal-caption {
color: var(--text-shade-1);
}
p.modal-caption span,
p.switch-tier-modal-caption span {
color: var(--text-shade-3);
}
.modal-button-wrapper {
margin-top: 24px;
display: flex;
justify-content: flex-end;
}
.modal-button-wrapper button {
margin-left: 12px;
width: fit-content;
}
.modal-button-wrapper button.cancel {
background: none;
}
.modal-button-wrapper button {
padding: 12px 24px;
}
@media screen and (max-width: 600px) {
div.modal {
padding: 24px;
}
}
/* MISC FORM COMPONENTS */
input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
background: var(--bg-shade-3);
padding: 12px;
margin: 4px;
margin-left: 0;
border-radius: 4px;
vertical-align: -65%;
width: fit-content;
cursor: pointer;
}
input[type="checkbox"]:checked {
background: no-repeat center/contain url(../images/check.svg), var(--accent-shade-0);
}
input {
appearance: none;
-webkit-appearance: none;
display: block;
font-family: Poppins, Arial, Helvetica, sans-serif;
font-size: 1rem;
background-color: var(--bg-shade-3);
border: none;
border-radius: 4px;
padding: 12px;
color: var(--text-shade-3);
width: calc(100% - 24px);
}
input:focus {
background-color: var(--bg-shade-4);
outline: none;
transition: 150ms;
}
input[type="range"] {
background: transparent;
cursor: pointer;
width: 100%;
box-sizing: border-box;
}
input[type="range"]::-webkit-slider-runnable-track {
background: var(--bg-shade-3);
height: 1rem;
border-radius: 1rem;
}
input[type="range"]::-moz-range-track {
background: var(--bg-shade-3);
border-radius: 1rem;
height: 1rem;
}
input[type="range"]::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 1.5rem;
height: 3rem;
margin-top: -1rem;
background-color: var(--accent-shade-1);
border-radius: 0.5rem;
}
input[type="range"]::-moz-range-thumb {
width: 1.5rem;
height: 3rem;
border: none;
border-radius: 0.5rem;
background-color: var(--accent-shade-1);
}
input[type="range"]:focus {
outline: none;
}
input[type="range"]:focus::-webkit-slider-thumb {
background-color: var(--accent-shade-3);
}
input[type="range"]:focus::-moz-range-thumb {
background-color: var(--accent-shade-3);
}

View File

@@ -1,428 +0,0 @@
html,
body,
div.main-body {
height: 100%;
background: var(--bg-shade-0);
}
a.logo-link {
margin: auto;
margin-left: 36px;
height: 40px;
text-decoration: none;
}
button#openSidebar {
display: none;
}
.docs-wrapper .content:not(.search) a {
text-decoration: none;
font-weight: bold;
color: var(--accent-shade-1);
}
.docs-wrapper header {
position: relative;
box-sizing: border-box;
margin: 20px;
margin-left: 0;
}
.docs-wrapper header::before {
content: none;
background: none;
pointer-events: none;
}
.docs-wrapper header a.logo-link {
display: none;
}
.docs-wrapper header nav a:first-child {
margin-left: 0;
}
.docs-wrapper {
display: grid;
grid-template-columns: fit-content(100%) auto;
grid-template-rows: fit-content(100%) auto;
height: 100%;
}
.docs-wrapper .sidebar {
display: flex;
flex-flow: column;
align-items: center;
width: clamp(270px, 25vw, 500px);
overflow-y: scroll;
overflow-x: hidden;
min-height: 100%;
}
.docs-wrapper .sidebar .section {
display: flex;
flex-flow: column;
width: 200px;
margin-left: clamp(60px, 10vw, 138px);
margin-bottom: 72px;
}
.docs-wrapper .sidebar .section:first-child {
margin-top: 72px;
}
.docs-wrapper .sidebar .section h5 {
margin: 0;
font-weight: normal;
text-transform: uppercase;
color: var(--text-shade-0);
margin-bottom: 12px;
}
.docs-wrapper .sidebar .section a {
position: relative;
text-decoration: none;
color: var(--text-shade-1);
width: fit-content;
margin-bottom: 12px;
}
.docs-wrapper .sidebar .section a.active,
.docs-wrapper .sidebar .section a:hover {
color: var(--text-shade-3);
}
.docs-wrapper .sidebar .section a.active::before {
/* This filter thing is jank, if anyone knows a better way to do this please fix */
filter: invert(51%) sepia(12%) saturate(2930%) hue-rotate(218deg)
brightness(99%) contrast(92%);
position: absolute;
left: -30px;
content: url(../images/docs/arrow-right.svg);
}
.docs-wrapper .content {
background: var(--bg-shade-1);
padding: 72px;
max-height: 100%;
overflow-y: scroll;
border-top-left-radius: 8px;
}
.docs-wrapper .content-inner {
max-width: 900px;
}
.docs-wrapper .content p {
color: var(--text-shade-1);
}
.docs-wrapper .content h1:first-child {
margin-top: 0;
}
.docs-wrapper .content .quick-links-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-gap: 24px;
margin-bottom: 60px;
}
.docs-wrapper .quick-links-grid a {
text-decoration: none;
background: var(--bg-shade-2);
border-radius: 6px;
color: var(--text-shade-1);
display: flex;
align-items: center;
padding: 20px;
}
.docs-wrapper .quick-links-grid svg:first-child {
height: 36px;
margin-right: 24px;
margin-left: 4px;
color: var(--accent-shade-2);
}
.docs-wrapper .quick-links-grid p.header {
font-size: 22px;
font-weight: 600;
color: var(--text-shade-3);
margin: 0;
}
.docs-wrapper .quick-links-grid p {
margin: 0;
}
.docs-wrapper .quick-links-grid svg:last-child {
height: 36px;
margin-left: auto;
}
.docs-wrapper .content-inner div.tip {
position: relative;
width: 100%;
padding: 36px;
background: var(--bg-shade-2);
border-radius: 8px;
overflow: hidden;
border: var(--accent-shade-2);
margin: 24px 0;
box-sizing: border-box;
}
.docs-wrapper .content-inner div.tip::after {
content: "";
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 12px;
background: var(--accent-shade-2);
opacity: 1;
}
.docs-wrapper .content-inner div.tip.yellow::after {
background: var(--yellow-shade-1);
}
.docs-wrapper .content-inner div.tip.red::after {
background: var(--red-shade-1);
}
.docs-wrapper .content-inner div.tip.green::after {
background: var(--green-shade-1);
}
.docs-wrapper .content .missing-in-locale-notice {
background: var(--bg-shade-2);
padding: 24px;
border-radius: 6px;
}
.search .purple-card {
padding: 36px;
}
.search .purple-card h1 {
margin-top: 0;
}
.search .purple-card p {
margin-bottom: 2em;
}
.search .purple-card input::placeholder {
color: var(--text-shade-0);
}
.search .purple-card input:focus {
background-color: var(--bg-shade-4);
color: #fff;
transition: 200ms;
outline: none;
}
.search .input-wrapper {
position: relative;
margin-top: 8px;
}
.search .input-wrapper .matches {
display: flex;
flex-flow: column;
font-size: 1rem;
background-color: var(--bg-shade-2);
border: none;
border-radius: 0 0 4px 4px;
max-height: 204px;
overflow-y: auto;
overflow-x: hidden;
}
.search .input-wrapper .matches * {
padding: 12px;
margin: 0;
text-decoration: none;
color: var(--text-shade-3);
}
.search .input-wrapper .matches a:hover {
background-color: var(--bg-shade-1);
}
.search input.has-matches {
border-radius: 4px 4px 0 0;
}
.platform-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24px;
margin-top: 36px;
}
.docs-wrapper .platform-grid a {
text-decoration: none;
background: var(--bg-shade-3);
border-radius: 12px;
color: var(--text-shade-4) !important;
display: grid;
grid-template-rows: auto fit-content(100%);
align-items: center;
justify-content: center;
text-align: center;
padding: 36px;
padding-bottom: 24px;
gap: 24px;
}
.platform-grid a img {
width: 180px;
max-width: 100%;
height: auto;
}
.platform-grid a span {
margin-top: auto;
font-size: 1.2rem;
}
@media screen and (max-width: 1296px) {
.docs-wrapper .content {
padding: 48px;
}
}
@media screen and (max-width: 1080px) {
.docs-wrapper .header-wrapper {
position: absolute;
top: 0;
left: 0;
display: flex;
width: 100vw;
}
button#openSidebar {
display: block;
padding: 0;
margin: 0;
margin-left: 20px;
background: none;
}
.docs-wrapper header {
margin-left: 20px;
width: 100%;
left: 0;
}
.docs-wrapper {
margin-top: 80px;
height: calc(100% - 80px);
}
a.logo-link {
display: none;
}
.docs-wrapper header a.logo-link {
display: block;
height: 40px;
margin: 0;
margin-right: 34px;
}
.docs-wrapper .sidebar {
grid-column: 1 / span 1;
grid-row: 2 / span 1;
width: 0;
transition: width 250ms;
}
.docs-wrapper .sidebar.open {
width: min(300px, 100vw);
}
.docs-wrapper .content {
width: 100vw;
box-sizing: border-box;
border-top-left-radius: 0;
grid-column: 2 / span 1;
grid-row: 2 / span 1;
}
.docs-wrapper .content.open-sidebar {
border-top-left-radius: 8px;
}
.docs-wrapper .content-inner {
max-width: none;
}
}
@media screen and (max-width: 900px) {
.docs-wrapper header button.dropdown-button#mobile-button {
display: none;
}
.docs-wrapper header .logo-link svg text {
display: block;
}
.docs-wrapper header .logo-link svg {
width: 120px;
}
}
@media screen and (max-width: 820px) {
.docs-wrapper .content .quick-links-grid,
.platform-grid {
grid-template-columns: 1fr;
grid-auto-rows: 1fr;
}
.docs-wrapper header a.logo-link {
margin-right: 6px;
}
}
@media screen and (max-width: 576px) {
.docs-wrapper header div.dropdown {
left: calc(-39.876px - 6px - 30px - 40px);
}
}
@media screen and (max-width: 492px) {
.docs-wrapper .content {
padding: 36px;
}
header .logo-link svg text {
display: none;
}
header .logo-link svg {
width: 39.876px;
}
.docs-wrapper header a.logo-link {
margin-right: 0;
}
}
@media screen and (max-width: 360px) {
.docs-wrapper .content {
padding: 24px;
}
}
/* Scrollbar styling 'cause it's fancy */
.docs-wrapper .sidebar::-webkit-scrollbar,
.docs-wrapper .content::-webkit-scrollbar,
.docs-wrapper .content pre code::-webkit-scrollbar,
.search .input-wrapper .matches::-webkit-scrollbar {
width: 12px;
height: 12px;
}
.docs-wrapper .sidebar::-webkit-scrollbar-track,
.docs-wrapper .content::-webkit-scrollbar-track,
.docs-wrapper .content pre code::-webkit-scrollbar-track,
.search .input-wrapper .matches::-webkit-scrollbar-track {
background: none;
}
.docs-wrapper .sidebar::-webkit-scrollbar-thumb,
.docs-wrapper .content::-webkit-scrollbar-thumb,
.docs-wrapper .content pre code::-webkit-scrollbar-thumb,
.search .input-wrapper .matches::-webkit-scrollbar-thumb {
background-color: var(--text-shade-0);
border-radius: 24px;
border: 3px solid var(--bg-shade-0);
}
.docs-wrapper .content::-webkit-scrollbar-thumb {
border: 3px solid var(--bg-shade-1);
}
.docs-wrapper .content pre code::-webkit-scrollbar-thumb,
.search .input-wrapper .matches::-webkit-scrollbar-thumb {
border: 3px solid var(--bg-shade-2);
}
.docs-wrapper .sidebar,
.search .input-wrapper .matches {
scrollbar-width: thin;
scrollbar-color: var(--text-shade-0) var(--bg-shade-1);
}
.docs-wrapper .content {
scrollbar-width: thin;
scrollbar-color: var(--text-shade-0) var(--bg-shade-1);
}
.docs-wrapper .content pre code {
scrollbar-width: thin;
scrollbar-color: var(--text-shade-0) var(--bg-shade-0);
}

View File

@@ -1,76 +0,0 @@
.select-box {
display: flex;
flex-direction: column;
position: relative;
user-select: none;
}
.select-box > * {
box-sizing: border-box;
}
.select-box .options-container {
max-height: 0;
width: min(90vw, 240px);
opacity: 0;
transition: all 0.4s;
overflow: hidden;
border-radius: 5px;
background-color: var(--bg-shade-3);
order: 1;
position: absolute;
top: 48px;
right: 0;
}
.select-box .option .item {
color: var(--text-shade-2);
}
.select-box .lang {
width: 1.3rem;
height: 1rem;
margin-right: .2rem;
display: inline-block;
}
.select-box .options-container.active {
max-height: 320px;
opacity: 1;
overflow-y: auto;
}
.select-box .options-container.active + .locale-dropdown-toggle::after {
transform: translateY(-50%) rotateX(180deg);
}
.select-box .options-container::-webkit-scrollbar {
width: 8px;
background: var(--bg-shade-3);
border-radius: 0 5px 5px 0;
}
.select-box .options-container::-webkit-scrollbar-thumb {
background: var(--text-shade-1);
border-radius: 0 5px 5px 0;
}
.select-box .option {
padding: 12px 15px;
cursor: pointer;
border-radius: 5px;
}
.select-box .option:hover {
background: var(--bg-shade-4);
}
.select-box .option:hover .item {
color: white;
}
.select-box label {
cursor: pointer;
}
.select-box .option .radio {
display: none;
}

View File

@@ -1,137 +0,0 @@
pre code.hljs {
display: block;
overflow-x: auto;
padding: 36px;
border-radius: 10px;
font-family: Poppins, Arial, Helvetica, sans-serif;
}
code.hljs {
padding: 3px 5px;
}
.hljs {
background: var(--bg-shade-0);
color: #d6deeb;
}
.hljs-keyword {
color: var(--accent-shade-2);
font-style: italic;
}
.hljs-built_in {
color: #addb67;
font-style: italic;
}
.hljs-type {
color: #82aaff;
}
.hljs-literal {
color: #ff5874;
}
.hljs-number {
color: #f78c6c;
}
.hljs-regexp {
color: #5ca7e4;
}
.hljs-string {
color: #ecc48d;
}
.hljs-subst {
color: #d3423e;
}
.hljs-symbol {
color: #82aaff;
}
.hljs-class {
color: #ffcb8b;
}
.hljs-function {
color: #82aaff;
}
.hljs-title {
color: #dcdcaa;
font-style: italic;
}
.hljs-params {
color: #7fdbca;
}
.hljs-comment {
color: #637777;
font-style: italic;
}
.hljs-doctag {
color: #7fdbca;
}
.hljs-meta,
.hljs-meta .hljs-keyword {
color: #82aaff;
}
.hljs-meta .hljs-string {
color: #ecc48d;
}
.hljs-section {
color: #82b1ff;
}
.hljs-attr,
.hljs-name,
.hljs-tag {
color: #7fdbca;
}
.hljs-attribute {
color: #80cbc4;
}
.hljs-variable {
color: #addb67;
}
.hljs-bullet {
color: #d9f5dd;
}
.hljs-code {
color: #80cbc4;
}
.hljs-emphasis {
color: #c792ea;
font-style: italic;
}
.hljs-strong {
color: #addb67;
font-weight: 700;
}
.hljs-formula {
color: #c792ea;
}
.hljs-link {
color: #ff869a;
}
.hljs-quote {
color: #697098;
font-style: italic;
}
.hljs-selector-tag {
color: #ff6363;
}
.hljs-selector-id {
color: #fad430;
}
.hljs-selector-class {
color: #addb67;
font-style: italic;
}
.hljs-selector-attr,
.hljs-selector-pseudo {
color: #c792ea;
font-style: italic;
}
.hljs-template-tag {
color: #c792ea;
}
.hljs-template-variable {
color: #addb67;
}
.hljs-addition {
color: #addb67ff;
font-style: italic;
}
.hljs-deletion {
color: #ef535090;
font-style: italic;
}

View File

@@ -1,95 +0,0 @@
.localization-wrapper {
width: 100%;
min-height: calc(100vh - 155px);
margin: 0;
text-align: left;
display: flex;
justify-content: center;
align-items: center;
}
.localization-widget {
max-width: 600px;
width: 100%;
}
.caption {
color: var(--text-shade-1);
max-width: 400px;
margin: 20px 0;
}
.title.dot {
margin: 0;
}
.localization-instr,
.localization-instr:visited {
display: flex;
align-items: center;
color: var(--accent-shade-2);
text-decoration: none;
position: relative;
left: -4px;
width: fit-content;
}
.localization-instr svg {
height: 1.3em;
margin-right: 4px;
}
.localization-form {
padding: 36px;
background-color: var(--bg-shade-0);
border-radius: 12px;
margin-top: 36px;
}
.input-wrapper {
display: flex;
margin-top: 8px;
}
.localization-form input {
appearance: none;
-webkit-appearance: none;
border: 0;
font-family: Poppins, Arial, Helvetica, sans-serif;
font-size: 1rem;
background-color: var(--bg-shade-3);
border: none;
border-radius: 4px 0 0 4px;
padding: 12px 24px;
color: var(--text-shade-1);
width: 20px;
flex: 2 10%;
}
.localization-form input::placeholder {
color: var(--text-shade-0);
}
.localization-form input:focus {
background-color: var(--bg-shade-4);
color: var(--bg-shade-3);
transition: 200ms;
outline: none;
}
.localization-form button {
appearance: none;
-webkit-appearance: none;
border: 0;
border-radius: 0 4px 4px 0;
font-family: Poppins, Arial, Helvetica, sans-serif;
font-size: 1rem;
color: var(--text-shade-3);
padding: 12px 36px;
background: var(--accent-shade-0);
cursor: pointer;
}
footer {
margin-top: auto;
}

View File

@@ -1,132 +0,0 @@
.wrapper {
display: flex;
flex-flow: column;
min-height: 100vh;
}
header {
margin: 35px 0;
}
.account-form-wrapper {
margin: auto;
width: fit-content;
overflow: hidden;
}
form.account {
display: block;
padding: 40px 48px;
background-color: var(--bg-shade-2);
color: var(--text-shade-1);
border-radius: 12px;
width: min(480px, 90vw);
box-sizing: border-box;
}
form.account h2 {
margin: 0;
color: var(--text-shade-3);
}
form.account p {
margin: 12px 0;
}
form.account div {
margin-top: 24px;
}
form.account label {
display: block;
margin-bottom: 6px;
text-transform: uppercase;
font-size: 12px;
}
form.account button {
width: 100%;
background: var(--accent-shade-0);
}
form.account a {
text-decoration: none;
display: block;
color: var(--text-shade-1);
text-align: right;
margin: 6px 0;
width: fit-content;
}
form.account a:hover {
color: var(--text-shade-3);
}
form.account a.pwdreset {
margin-left: auto;
font-size: 14px;
}
form.account a.register {
margin: auto;
margin-top: 18px;
}
@keyframes banner-notice {
0% {
top: -150px;
}
20% {
top: 35px;
}
80% {
top: 35px;
}
100% {
top: -150px;
}
}
.banner-notice {
display: flex;
justify-content: center;
position: fixed;
top: -150px;
width: 100%;
animation: banner-notice 5s;
}
.banner-notice div {
padding: 4px 36px;
border-radius: 5px;
z-index: 3;
}
.banner-notice.error div {
background: var(--red-shade-1);
}
form.account.register {
display: grid;
grid-template-columns: repeat(2, 1fr);
width: min(780px, 90vw);
column-gap: 24px;
margin-bottom: 48px;
}
form.account.register div.h-captcha {
grid-column: 1 / span 2;
display: flex;
justify-content: center;
}
form.account.register p,
form.account.register div.email,
form.account.register div.buttons {
grid-column: 1 / span 2;
}
@media screen and (max-width: 720px) {
form.account.register {
grid-template-columns: 1fr;
}
form.account.register div.h-captcha,
form.account.register p,
form.account.register div.email,
form.account.register div.buttons {
grid-column: unset;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,709 +0,0 @@
html,
body,
div.main-body {
height: 100%;
}
body,
div.main-body,
.miieditor-wrapper {
z-index: -1;
user-select: none;
background: var(--bg-shade-0);
}
svg.logotype {
position: absolute;
width: 120px;
top: 42px;
left: 60px;
}
.miieditor-wrapper {
position: relative;
display: grid;
grid-template-columns: auto auto;
width: 95vw;
max-width: 1920px;
height: 100%;
margin: auto;
gap: 0 120px;
}
.params-wrapper::before {
content: "";
display: block;
position: absolute;
background: var(--bg-shade-1);
border-radius: 100% 0 0 100%;
width: 1300px;
height: 1700px;
top: 50%;
transform: translateY(-50%);
left: -200px;
z-index: -1;
}
.miieditor-wrapper::after {
content: "";
display: block;
position: absolute;
background: radial-gradient(
closest-side,
var(--bg-shade-1) 0%,
rgba(27, 31, 59, 0) 100%
);
width: 200vh;
height: 200vh;
top: -100vh;
left: -100vh;
z-index: -1;
}
.canvas-wrapper {
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
overflow: hidden;
}
canvas#miiCanvas {
width: auto;
height: auto;
transform-origin: center;
transition: transform 200ms, filter 200ms;
}
div.mii-img-wrapper::before {
content: "";
position: absolute;
bottom: -22px;
height: 72px;
width: 100%;
background: radial-gradient(
farthest-side,
var(--bg-shade-2) 0%,
rgba(35, 39, 74, 0) 100%
);
}
div.params-wrapper {
position: relative;
margin: auto;
display: grid;
z-index: 3;
}
div.tabs {
display: grid;
grid-template-columns: repeat(11, 1fr);
width: 100%;
box-sizing: border-box;
background: #0a0c19;
padding: 6px;
gap: 6px;
border-radius: 6px;
margin-bottom: 2rem;
}
div.tabs .tabbtn {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1;
border-radius: 6px;
background: none;
padding: 0;
}
div.tabs .tabbtn::after {
content: "";
display: block;
width: 12px;
height: 12px;
background: url("/assets/images/miieditor.svg");
background-position: calc(var(--assetcol) * -12px) -312px;
transform: scale(2.9);
}
div.tabs .tabbtn:hover,
div.tabs .tabbtn.active {
background: var(--bg-shade-2);
}
div.subtabs {
position: relative;
display: flex;
width: fit-content;
}
div.subtabs .subtabbtn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 60px;
border-radius: 6px;
background: none;
padding: 0;
color: var(--text-shade-3);
aspect-ratio: 1;
}
div.subtabs .subtabbtn::after {
content: "";
display: block;
width: 12px;
height: 12px;
background: url("/assets/images/miieditor.svg");
background-position: calc(var(--assetcol) * -12px) -324px;
transform: scale(2.9);
}
div.subtabs .subtabbtn.active::before,
div.subtabs .subtabbtn.active:hover::before {
content: "";
position: absolute;
bottom: -2px;
left: 5%;
width: 90%;
height: 5px;
background: var(--accent-shade-1);
border-radius: 6px;
}
.has-sliders {
grid-template-columns: 60px auto;
gap: 12px;
}
.has-sliders label {
position: relative;
display: flex;
width: 60px;
height: 60px;
align-items: center;
justify-content: center;
}
.has-sliders label::after {
content: "";
display: block;
width: 16px;
height: 16px;
background: url("/assets/images/miieditor.svg");
background-position: calc(var(--assetcol) * -16px) -336px;
transform: scale(3);
}
.has-textinput {
grid-template-columns: 1fr 1fr;
grid-template-rows: auto;
grid-auto-rows: auto;
gap: 24px;
}
.has-textinput label {
display: block;
margin-bottom: 6px;
text-transform: uppercase;
font-size: 12px;
}
.has-textinput .icons {
display: flex;
flex-flow: row nowrap;
justify-content: space-between;
}
.has-textinput .icons input[type="checkbox"] {
box-sizing: border-box;
margin: 0;
height: 49px;
width: 49px;
}
input[type="checkbox"]#allowCopying:checked {
background: no-repeat center/80% url(../images/copy.svg), var(--accent-shade-0);
}
input[type="checkbox"]#disableSharing:checked {
background: no-repeat center/80% url(../images/share.svg), var(--accent-shade-0);
}
input[type="checkbox"]#favorite:checked {
background: no-repeat center/80% url(../images/star.svg), var(--accent-shade-0);
}
form.params {
grid-template-columns: repeat(2, auto);
height: 618px;
width: 582px;
}
form.params .tab {
display: none;
gap: 4rem 0;
}
form.params .tab.active {
display: grid;
}
fieldset,
fieldset.has-subpages .subpage {
appearance: none;
border: none;
padding: 0;
margin: 0;
display: none;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(4, 1fr);
gap: 18px;
width: 100%;
height: fit-content;
}
fieldset.active {
display: grid;
}
fieldset input[type="radio"] {
display: none;
}
fieldset input[type="radio"] + label {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 18px;
background: var(--bg-shade-3);
width: 100%;
aspect-ratio: 1;
}
fieldset.has-subpages.active {
display: block;
}
fieldset.has-subpages .subpage.active {
display: grid;
}
fieldset:not(.color, #favoriteColor) input[type="radio"] + label::after {
content: "";
display: block;
width: 24px;
height: 24px;
background: url("/assets/images/miieditor.svg");
background-position: calc(
((var(--assetcol) + (var(--subpage, 0) * 12)) * -24px)
)
calc(var(--assetrow) * -24px);
transform: scale(4.5);
}
fieldset input[type="radio"]:checked + label {
background: var(--bg-shade-4);
box-shadow: inset 0 0 0 4px var(--accent-shade-1);
}
fieldset.color input[type="radio"]:checked + label,
fieldset#favoriteColor input[type="radio"]:checked + label {
box-shadow: inset 0 0 0 4px var(--accent-shade-1),
inset 0 0 0 6px var(--bg-shade-1);
}
input[type="range"].invert {
direction: rtl;
}
.pagination {
display: flex;
width: max-content;
height: fit-content;
grid-column: 1 / span 4;
grid-row: 4;
margin-left: auto;
align-items: center;
font-size: 18px;
color: var(--text-shade-1);
}
.pagination .current-page-index {
display: inline-block;
font-weight: bold;
color: var(--text-shade-3);
width: 18px;
margin-right: 0.5ch;
text-align: right;
}
.page-btn {
appearance: none;
border: none;
background: none;
cursor: pointer;
padding: 0;
margin-left: 8px;
}
.page-btn:hover {
background: none;
}
.page-btn:hover svg path {
fill: var(--accent-shade-3);
}
.page-btn svg {
height: 36px;
margin: 6px;
}
.page-btn.disabled {
pointer-events: none;
}
.page-btn.disabled svg path {
fill: var(--bg-shade-3);
}
.tab#saveTab {
gap: 2rem 0;
}
.tab#saveTab p.save-prompt {
margin-bottom: 0;
text-align: center;
}
.mii-comparison-animation-wrapper {
position: relative;
height: fit-content;
}
.mii-comparison {
position: relative;
display: grid;
grid-template-columns: repeat(3, auto);
align-items: center;
width: 100%;
}
.mii-comparison.confirmed {
position: absolute;
height: 100%;
top: 0;
left: 0;
opacity: 0;
overflow: hidden;
}
.mii-comparison img {
display: block;
width: 100%;
aspect-ratio: 1;
background: var(--bg-shade-3);
border-radius: 24px;
}
.mii-comparison .new-mii-wrapper {
position: relative;
transition: right 500ms, transform 500ms;
right: 0;
}
.mii-comparison .new-mii-wrapper::after {
position: absolute;
content: "";
display: block;
box-shadow: inset 0 0 0 8px var(--accent-shade-1);
border-radius: 24px;
margin: 0;
right: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 2;
}
.mii-comparison svg {
width: 72px;
height: 72px;
}
.mii-comparison svg path {
fill: var(--accent-shade-1);
}
.fade-in {
animation: fadeIn 0.25s forwards;
}
.fade-out {
animation: fadeOut 0.5s forwards;
}
.mii-comparison div.new-mii-wrapper.centered-mii-img {
position: absolute;
right: 50%;
transform: translateX(50%);
height: 100%;
aspect-ratio: 1;
width: auto;
}
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes fadeOut {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
button * {
pointer-events: none;
}
@media screen and (max-width: 1400px) {
form.params {
height: 562px;
width: 512px;
}
fieldset,
fieldset.has-subpages .subpage {
gap: 12px;
}
div.params-wrapper {
margin-right: 24px;
}
.params-wrapper::before {
left: -150px;
}
div.tabs {
padding: 4px;
gap: 2px;
}
}
@media screen and (max-width: 1280px) {
form.params {
height: 492px;
width: 480px;
}
fieldset,
fieldset.has-subpages .subpage {
gap: 8px;
}
div.params-wrapper {
margin-right: 24px;
}
.params-wrapper::before {
left: -150px;
}
fieldset:not(.color, #favoriteColor) input[type="radio"] + label::after {
transform: scale(4);
}
div.subtabs .subtabbtn::after,
div.tabs .tabbtn::after {
transform: scale(2.4);
}
div.subtabs .subtabbtn {
width: 48px;
}
.params-wrapper::before {
left: -100px;
}
div.tabs {
margin-bottom: 1rem;
}
form.params .tab {
gap: 2rem 0;
}
}
@media screen and (max-width: 1120px) {
form.params {
height: 444px;
width: 420px;
}
fieldset,
fieldset.has-subpages .subpage {
gap: 6px;
}
}
@media screen and (max-width: 1080px) {
.canvas-wrapper {
height: calc(100% - 12px);
}
svg.logotype {
left: 0;
}
svg.logotype text#Pretendo {
display: none;
}
.miieditor-wrapper {
grid-template-columns: auto;
grid-template-rows: auto fit-content(100%);
margin: auto;
max-width: 360px;
width: 90vw;
}
div.params-wrapper {
width: 100%;
margin: 0 auto;
display: flex;
flex-flow: column;
}
div.tabs,
div.subtabs {
order: 2;
margin-top: 1rem;
}
fieldset {
order: 1;
}
form.params {
width: 100%;
height: 100%;
order: 1;
}
fieldset input[type="radio"] + label {
width: 100%;
height: auto;
border-radius: 8px;
aspect-ratio: 1;
}
fieldset:not(.color, #favoriteColor) input[type="radio"] + label::after {
transform: scale(3.5);
}
.has-sliders {
overflow-y: auto;
overflow-x: hidden;
height: 100%;
gap: 2px 6px;
scrollbar-width: thin;
scrollbar-color: var(--text-shade-1) var(--bg-shade-3);
}
.has-sliders::-webkit-scrollbar {
width: 12px;
background: var(--bg-shade-3);
border-radius: 9px;
}
.has-sliders::-webkit-scrollbar-thumb {
background: var(--text-shade-1);
border-radius: 9px;
}
.params-wrapper::before {
top: -12px;
left: -100vw;
width: 300vw;
border-radius: 0;
background: var(--bg-shade-2);
transform: none;
}
fieldset,
fieldset.has-subpages .subpage {
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(3, 1fr) 48px;
width: 100%;
gap: 8px;
}
.pagination {
grid-column: 1 / span 4;
grid-row: 4;
}
fieldset:not(.has-sliders, .has-textinput) {
position: relative;
margin-bottom: -60px;
}
div.tabs {
margin-bottom: 1rem;
}
form.params .tab {
gap: 0;
height: 100%;
}
div.subtabs {
z-index: 4;
}
form.params {
grid-template-columns: repeat(2, auto);
}
div.tabs .tabbtn::after,
div.subtabs .subtabbtn::after,
.has-sliders label::after {
transform: scale(2);
}
div.subtabs .subtabbtn {
width: 48px;
height: 48px;
}
}
@media screen and (max-width: 424px) {
fieldset:not(.color, #favoriteColor) input[type="radio"] + label::after {
transform: scale(2.8);
}
}
@media screen and (max-width: 396px) {
div.tabs .tabbtn::after,
div.subtabs .subtabbtn::after,
.has-sliders label::after {
transform: scale(1.5);
}
div.tabs .tabbtn {
width: 24px;
height: 24px;
}
div.subtabs .subtabbtn {
width: 36px;
height: 36px;
}
div.pagination {
transform: scale(0.7);
transform-origin: right;
}
.has-textinput {
grid-template-columns: 1fr;
}
}
@media screen and (max-width: 360px) {
fieldset,
fieldset.has-subpages .subpage {
gap: 4px;
}
fieldset input[type="radio"]:checked + label {
box-shadow: inset 0 0 0 3px var(--accent-shade-1);
}
}
@media screen and (max-width: 344px) {
fieldset:not(.color, #favoriteColor) input[type="radio"] + label::after {
transform: scale(2);
}
}
@media screen and (max-width: 320px) {
div.tabs .tabbtn {
width: 18px;
height: 18px;
}
div.tabs .tabbtn::after {
transform: scale(1.25);
}
}

View File

@@ -1,197 +0,0 @@
footer {
width: 100%;
display: grid;
grid-template-columns: repeat(3, fit-content(100%)) 1fr;
gap: min(48px, 7.7vw);
color: var(--text-shade-1);
margin-top: 120px;
position: relative;
padding: 60px 0;
}
footer::after {
content: "";
width: 400vw;
height: 100%;
position: absolute;
top: 0;
left: -50vw;
background: var(--bg-shade-0);
z-index: -1;
}
footer div {
display: flex;
flex-flow: column;
width: fit-content;
}
footer svg.logotype {
height: 56px;
margin: -10px 0 24px -10px;
}
footer p {
margin: 0;
}
footer h1 {
font-size: 20px;
margin-top: 0;
color: var(--text-shade-3);
}
footer a {
color: var(--text-shade-1);
text-decoration: none;
width: fit-content;
}
footer a:hover {
color: var(--text-shade-3);
text-decoration: underline;
}
footer div.discord-server-card-wrapper {
z-index: 2;
justify-self: end;
position: relative;
}
footer div.discord-server-card {
background: var(--bg-shade-2);
border-radius: 12px;
padding: 30px 90px 30px 36px;
}
footer div.discord-server-card h1 {
font-size: 25px;
margin: 0;
}
footer div.discord-server-card h2 {
color: var(--text-shade-3);
font-size: 22px;
margin: 0;
}
footer div.discord-server-card a {
display: flex;
align-items: center;
color: var(--accent-shade-3);
font-size: 22px;
text-decoration: none;
width: fit-content;
margin-left: -2px;
margin-top: 12px;
}
footer div.discord-server-card a:hover {
text-decoration: underline;
}
footer div.discord-server-card svg {
height: 24px;
stroke-width: 3px;
margin-right: 4px;
}
footer div.discord-server-card-wrapper .bandwidth-raccoon-wrapper {
position: absolute;
top: -120px;
right: 0px;
z-index: -1;
}
footer div.discord-server-card-wrapper img.bandwidth-raccoon {
width: 192px;
height: 192px;
cursor: pointer;
transform: none;
transition: transform 150ms;
}
footer div.bandwidth-raccoon-wrapper.speak img.bandwidth-raccoon {
transform: rotate(12deg) translateY(-12px);
}
footer .bandwidth-raccoon-wrapper .text-bubble {
position: absolute;
right: 0;
margin: 0 auto;
top: -24px;
max-width: min(200%, 90vw);
width: max-content;
background: var(--bg-shade-3);
padding: 18px;
align-self: center;
margin-bottom: 12px;
border-radius: 12px;
box-sizing: border-box;
transform: translateY(-100%);
opacity: 0;
pointer-events: none;
transition: opacity 250ms;
}
footer .bandwidth-raccoon-wrapper.speak .text-bubble {
opacity: 1;
}
footer .bandwidth-raccoon-wrapper .text-bubble:after {
content: "";
position: absolute;
display: block;
width: 0;
z-index: 1;
border-style: solid;
border-color: var(--bg-shade-3) transparent;
border-width: 12px 12px 0;
bottom: -9px;
right: 60px;
margin-left: -10px;
}
@media screen and (max-width: 900px) {
footer {
margin-top: 100px;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(2, fit-content(100%));
}
footer div {
justify-self: center;
}
footer div.discord-server-card-wrapper {
grid-column: 1 / span 4;
width: 100%;
justify-self: normal;
}
footer div.discord-server-card-wrapper::before {
content: "";
width: 100%;
height: 60px;
position: absolute;
bottom: -60px;
left: 0;
background: var(--bg-shade-0);
z-index: 2;
}
footer div.discord-server-card-wrapper .bandwidth-raccoon-wrapper {
bottom: -72px;
top: unset;
z-index: 0;
}
footer div.discord-server-card {
box-sizing: border-box;
width: 100%;
overflow: hidden;
}
}
@media screen and (max-width: 580px) {
footer {
grid-template-columns: 1fr;
grid-template-rows: repeat(4, fit-content(100%));
}
footer div {
justify-self: start;
}
footer div.discord-server-card-wrapper {
grid-column: 1 / span 1;
}
footer div.discord-server-card {
padding: 30px;
overflow: visible;
}
footer div.discord-server-card-wrapper .bandwidth-raccoon-wrapper {
bottom: unset;
top: -120px;
z-index: -1;
}
}
@media screen and (max-width: 320px) {
footer div.discord-server-card-wrapper .bandwidth-raccoon-wrapper {
display: none;
}
}

View File

@@ -1,470 +0,0 @@
header {
position: fixed;
top: 0;
left: 2.5%;
display: flex;
align-items: center;
width: 95%;
margin-top: 35px;
z-index: 60;
transition: box-shadow 180ms, background 180ms;
}
header * {
z-index: 1;
}
header::before {
content: "";
position: absolute;
top: -35px;
left: -10vw;
width: 120vw;
height: calc(100% + 35px + 35px);
background: rgba(27, 31, 59, 0.98);
transition: background 180ms;
}
header.transparent,
header.transparent::before {
background: rgba(27, 31, 59, 0);
}
header.dropdown-active {
background: rgba(27, 31, 59, 0.98);
box-shadow: 0 0 0 600vw rgba(27, 31, 59, 0.8);
}
header .dropdown-arrow {
opacity: 0;
position: absolute;
width: 0;
border-style: solid;
border-color: var(--bg-shade-3) transparent;
border-width: 0 14px 14px;
bottom: -26px;
margin-left: -10px;
margin-bottom: -10px;
transition: left 180ms, margin-bottom 180ms, opacity 180ms;
pointer-events: none;
}
header.dropdown-active .dropdown-arrow {
opacity: 1;
display: block;
margin-bottom: 0;
}
header a {
text-decoration: none;
}
header .logo-link,
header .logo-link svg {
display: block;
}
header div.left-section {
display: flex;
flex-flow: row nowrap;
}
header nav {
position: relative;
display: flex;
flex-flow: row nowrap;
align-items: center;
margin-left: 40px;
}
header button.dropdown-button#mobile-button {
display: none;
background: none;
padding: 0;
margin-right: 12px;
transition: transform 200ms;
}
header button.dropdown-button#mobile-button.active {
transform: rotate(90deg);
}
header div.dropdown-button-wrapper {
position: relative;
display: flex;
flex-flow: row nowrap;
}
/* these are safezones where the dropdown will not close */
header div.dropdown-button-wrapper::before {
content: "";
position: absolute;
top: -16px;
bottom: 100%;
left: 0;
right: -12px;
}
header div.dropdown-button-wrapper::after {
content: "";
position: absolute;
top: 100%;
bottom: -32px;
left: 0;
right: -12px;
}
header nav button {
background: none;
color: var(--text-shade-1);
margin: 0 17px;
padding: 0;
}
header nav button:hover,
header nav button.active {
background: none;
color: var(--text-shade-3);
}
header nav a.donate button {
display: grid;
grid-auto-flow: column;
align-items: center;
gap: 4px;
font-weight: bold;
background: #332b61;
color: var(--accent-shade-3);
padding: 2px 12px;
border-radius: 24px;
}
header nav a.donate button svg {
height: 1rem;
width: 1rem;
}
header nav a.donate button:hover {
background: var(--accent-shade-0);
color: #fff;
}
header nav a.donate button.dropdown-button::after {
content: "";
display: inline-block;
align-items: center;
width: 16px;
height: 8px;
bottom: 0;
margin-left: 0.3rem;
background: no-repeat center url("/assets/images/down-arrow.svg");
filter: brightness(0) invert(78%) sepia(2%) saturate(5488%) hue-rotate(197deg)
brightness(88%) contrast(93%);
transition: transform 100ms;
}
header nav button.dropdown-button.active::after {
transform: scaleY(-1);
filter: none;
}
header div.dropdown {
position: absolute;
top: 100%;
margin-top: 24px;
left: 0;
display: block;
background: var(--bg-shade-3);
border-radius: 8px;
width: 420px;
height: 0;
overflow-y: hidden;
transition: height 180ms;
}
header div.dropdown * {
box-sizing: border-box;
}
header div.dropdown-content {
position: absolute;
top: 0;
width: 100%;
opacity: 0;
pointer-events: none;
transition: opacity 180ms;
}
header div.dropdown-content.show {
opacity: 1;
pointer-events: auto;
}
header div.dropdown .top {
padding: 32px 18px;
}
header div.dropdown .top a {
position: relative;
display: grid;
grid-auto-flow: column;
gap: 16px;
justify-content: start;
align-items: center;
color: var(--text-shade-1);
padding: 16px;
border-radius: 8px;
}
header div.dropdown .top a .icon {
background: var(--bg-shade-2);
color: var(--accent-shade-3);
height: 56px;
width: 56px;
border-radius: 8px;
}
header div.dropdown .top a:hover .icon {
background: #151b44;
color: var(--accent-shade-1);
}
header div.dropdown .top a .icon svg {
width: 32px;
height: 32px;
margin: 12px;
}
header div.dropdown .top a .title {
margin: 0;
font-weight: bold;
color: var(--text-shade-3);
}
header div.dropdown .top a .caption {
margin: 0;
}
header div.dropdown .top a:hover {
background: var(--bg-shade-2-5);
}
header div.dropdown .top a:hover::after {
content: "";
position: absolute;
top: 0;
right: 24px;
width: 24px;
height: 100%;
background: no-repeat center url("/assets/images/arrow-right.svg");
/* garbage to make it look the same color */
filter: brightness(0) invert(60%) sepia(70%) saturate(453%) hue-rotate(208deg)
brightness(113%) contrast(97%);
}
header div.dropdown .top a:hover .title {
color: var(--accent-shade-3);
}
header div.dropdown .bottom {
display: grid;
grid-auto-flow: column;
justify-content: center;
gap: 24px;
background: var(--bg-shade-3-5);
padding: 22px;
width: 100%;
box-sizing: border-box;
}
header div.dropdown .bottom a {
width: 48px;
height: 48px;
background: var(--bg-shade-3);
color: var(--text-shade-3);
border-radius: 100%;
display: flex;
justify-content: center;
align-items: center;
}
header div.dropdown .bottom a:hover {
background: var(--bg-shade-2);
}
header div.dropdown .bottom a svg {
width: 24px;
height: auto;
}
header div.dropdown .top a.show-on-mobile {
display: none;
}
header .right-section {
display: grid;
grid-auto-flow: column;
gap: 24px;
margin-left: auto;
z-index: 2;
color: var(--text-shade-1);
}
header .locale-dropdown-toggle {
width: fit-content;
height: 24px;
padding: 0;
margin: auto;
transition: color 150ms;
cursor: pointer;
}
header .locale-dropdown-toggle:hover,
header .locale-dropdown-toggle.active {
color: var(--text-shade-3);
}
header .user-widget-wrapper {
height: auto;
}
header .user-widget-wrapper a.login-link {
color: var(--text-shade-1);
text-decoration: none;
display: block;
height: 32px;
transition: color 150ms;
}
header .user-widget-wrapper a.login-link:hover {
color: var(--text-shade-3);
}
header .user-widget-wrapper.logged-in {
position: relative;
width: 32px;
height: 32px;
}
header .user-widget-wrapper.logged-in .user-widget-toggle {
width: 32px;
height: 32px;
background: var(--text-shade-0);
border-radius: 50%;
overflow: hidden;
cursor: pointer;
}
header .user-widget-wrapper .user-widget-toggle img,
header .user-widget .user-avatar img {
width: 100%;
height: 100%;
}
header .user-widget {
max-height: 0;
overflow: hidden;
box-sizing: border-box;
transition: max-height 300ms, padding 200ms, opacity 150ms;
position: absolute;
right: 0;
top: 48px;
padding: 0;
background: var(--bg-shade-2);
border-radius: 8px;
text-align: center;
opacity: 0;
box-shadow: 0 0 10px -2px var(--bg-shade-0);
}
header .user-widget.active {
max-height: 100vh;
padding: 36px;
opacity: 1;
}
header .user-widget .user-avatar {
width: 128px;
height: 128px;
margin: auto;
background: var(--text-shade-0);
border-radius: 50%;
overflow: hidden;
}
header .user-widget .user-info {
margin-top: 12px;
}
header .user-widget .user-info .mii-name {
color: var(--text-shade-3);
}
header .user-widget .buttons {
margin-top: 12px;
}
header .user-widget .button {
margin-top: 12px;
width: 100%;
padding: 8px 60px;
cursor: pointer;
word-break: keep-all;
}
header .user-widget .button.logout {
background: var(--bg-shade-3);
color: var(--text-shade-3);
}
@media screen and (max-width: 900px) {
header {
position: relative;
justify-content: flex-end;
width: 90%;
left: 5%;
}
header .hide-on-mobile {
display: none !important;
}
header button.dropdown-button#mobile-button {
display: block;
}
header .left-section {
margin-right: auto;
}
header .right-section {
margin-left: 24px;
}
header nav a.donate button {
margin: 0;
}
header div.dropdown {
left: unset;
right: -104px;
width: 90vw;
}
header div.dropdown .top {
display: grid;
grid-template-columns: 1fr 1fr;
}
}
@media screen and (max-width: 600px) {
header div.dropdown .top {
grid-template-columns: 1fr;
}
header div.dropdown .top {
padding: 16px 9px;
}
}
@media screen and (max-width: 500px) {
header nav a.donate button span {
display: none;
}
header nav a.donate button {
padding: 6px;
}
}
@media screen and (max-width: 420px) {
header nav a.donate button {
display: none;
}
header .right-section {
margin-left: 0;
}
header div.dropdown {
right: -80px;
}
header div.dropdown .top a {
padding: 8px;
}
header div.dropdown .top a .icon {
height: 40px;
width: 40px;
}
header div.dropdown .top a .icon svg {
width: 24px;
height: 24px;
margin: 8px;
}
}
@media screen and (max-width: 330px) {
header .logo-link svg text {
display: none;
}
header .logo-link svg {
width: 39.876px;
}
header .logo-link {
margin-right: 6px;
}
}

View File

@@ -1,13 +0,0 @@
/*
MOVE PROGRESS CSS HERE
*/
#quick-nav a {
color: var(--text-shade-1);
text-decoration: none;
width: fit-content;
}
#quick-nav a:hover {
color: var(--text-shade-3);
text-decoration: underline;
}

View File

@@ -1,132 +0,0 @@
.wrapper {
display: flex;
flex-flow: column;
min-height: 100vh;
}
header {
margin: 35px 0;
}
.account-form-wrapper {
margin: auto;
width: fit-content;
overflow: hidden;
}
form.account {
display: block;
padding: 40px 48px;
background-color: var(--bg-shade-2);
color: var(--text-shade-1);
border-radius: 12px;
width: min(480px, 90vw);
box-sizing: border-box;
}
form.account h2 {
margin: 0;
color: var(--text-shade-3);
}
form.account p {
margin: 12px 0;
}
form.account div {
margin-top: 24px;
}
form.account label {
display: block;
margin-bottom: 6px;
text-transform: uppercase;
font-size: 12px;
}
form.account button {
width: 100%;
background: var(--accent-shade-0);
}
form.account a {
text-decoration: none;
display: block;
color: var(--text-shade-1);
text-align: right;
margin: 6px 0;
width: fit-content;
}
form.account a:hover {
color: var(--text-shade-3);
}
form.account a.pwdreset {
margin-left: auto;
font-size: 14px;
}
form.account a.register {
margin: auto;
margin-top: 18px;
}
@keyframes banner-notice {
0% {
top: -150px;
}
20% {
top: 35px;
}
80% {
top: 35px;
}
100% {
top: -150px;
}
}
.banner-notice {
display: flex;
justify-content: center;
position: fixed;
top: -150px;
width: 100%;
animation: banner-notice 5s;
}
.banner-notice div {
padding: 4px 36px;
border-radius: 5px;
z-index: 3;
}
.banner-notice.success div {
background: var(--green-shade-0);
}
form.account.register {
display: grid;
grid-template-columns: repeat(2, 1fr);
width: min(780px, 90vw);
column-gap: 24px;
margin-bottom: 48px;
}
form.account.register div.h-captcha {
grid-column: 1 / span 2;
display: flex;
justify-content: center;
}
form.account.register p,
form.account.register div.email,
form.account.register div.buttons {
grid-column: 1 / span 2;
}
@media screen and (max-width: 720px) {
form.account.register {
grid-template-columns: 1fr;
}
form.account.register div.h-captcha,
form.account.register p,
form.account.register div.email,
form.account.register div.buttons {
grid-column: unset;
}
}

View File

@@ -1,310 +0,0 @@
.wrapper {
display: flex;
justify-content: center;
text-align: center;
min-height: 100vh;
}
.wrapper::before {
position: absolute;
top: -800px;
content: "";
background: var(--bg-shade-0);
border-radius: 100%;
width: 1600px;
height: 1400px;
}
.back-arrow {
position: absolute;
display: flex;
justify-content: center;
top: 36px;
left: max(calc((100vw - 1590px) / 2), 2.5vw);
padding: 6px 10px;
background: var(--bg-shade-3);
border-radius: 24px;
transition: filter 150ms;
text-decoration: none;
color: var(--text-shade-3);
z-index: 5;
}
.back-arrow:hover {
filter: brightness(1.5)
}
.back-arrow svg {
width: 24px;
height: 24px;
}
.back-arrow span {
margin: 0 4px;
}
.account-form-wrapper {
display: flex;
flex-flow: column;
width: min(1200px, 100%);
color: var(--text-shade-1);
margin: 0 auto 48px;
z-index: 1;
}
.account-form-wrapper .logotype {
margin: 36px auto 0;
width: fit-content;
}
h1.title {
color: var(--text-shade-3);
}
p.caption {
width: min(100%, 500px);
margin: 0 auto 36px;
}
.account-form-wrapper .progress-bar-wrapper {
justify-content: center;
width: min(100%, 500px);
margin: 0 auto 72px;
padding: 24px;
border-radius: 6px;
background: var(--bg-shade-2);
box-sizing: border-box;
}
.account-form-wrapper .progress-bar-wrapper p {
text-align: left;
margin-bottom: 0;
}
.account-form-wrapper .progress-bar-wrapper p span {
color: var(--text-shade-3);
font-weight: 600;
}
.account-form-wrapper .progress-bar {
height: 8px;
border-radius: 4px;
margin-top: 0;
}
form {
box-sizing: border-box;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.3rem;
}
form .tier-radio {
display: none;
}
form .tier-radio:checked + label::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
box-shadow: inset 0 0 0 4px var(--accent-shade-1);
border-radius: 10px;
}
form .tier-radio:checked + label::after {
content: url(/assets/images/check.svg);
display: flex;
justify-content: center;
background: var(--accent-shade-1);
width: 24px;
height: 24px;
border-radius: 100%;
position: absolute;
top: -16px;
right: -16px;
padding: 6px;
}
label.tier {
display: flex;
flex-flow: column;
position: relative;
border-radius: 10px;
align-items: center;
padding-top: calc(50px + 1rem);
background: var(--bg-shade-3);
cursor: pointer;
transition: all 150ms;
margin-top: 50px;
text-align: center;
}
label.tier p {
margin: 0;
margin-bottom: 0.5rem;
}
label.tier .tier-thumbnail {
height: 100px;
width: 100px;
display: flex;
align-items: center;
overflow: hidden;
border-radius: 8px;
position: absolute;
top: -50px;
z-index: 2;
background: var(--bg-shade-4);
padding: 8px;
box-sizing: border-box;
}
form .tier-radio:checked + label .tier-thumbnail::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
box-shadow: inset 0 0 0 4px var(--accent-shade-1);
border-radius: 8px;
}
label.tier .tier-text {
display: flex;
flex-flow: column;
margin-bottom: auto;
}
label.tier .tier-name {
color: var(--text-shade-3);
font-weight: bold;
font-size: 1.2rem;
}
label.tier .tier-perks {
text-align: left;
width: 70%;
margin: 24px auto 48px;
}
label.tier .tier-perks div {
display: grid;
grid-template-columns: 16px auto;
gap: 8px;
}
label.tier .tier-perks svg {
stroke-width: 5px;
stroke: var(--green-shade-1);
stroke-linecap: square;
width: 16px;
height: 16px;
vertical-align: top;
margin-top: 0.5ex;
}
label.tier p.price {
display: flex;
width: 100%;
justify-content: center;
align-items: center;
background: var(--bg-shade-4);
margin: 0;
padding: 1.5rem 1rem;
box-sizing: border-box;
border-radius: 0 0 10px 10px;
}
label.tier p.price span {
font-size: 2rem;
color: var(--text-shade-3);
font-weight: bold;
margin-right: 0.5ch;
}
form .button-wrapper {
grid-column: 2 / span 1;
position: relative;
margin-top: 24px;
}
button {
appearance: none;
-webkit-appearance: none;
display: block;
font-family: Poppins, Arial, Helvetica, sans-serif;
font-size: 1rem;
height: fit-content;
background: var(--accent-shade-0);
border: none;
border-radius: 4px;
padding: 12px;
color: var(--text-shade-3);
width: 100%;
transition: filter 300ms;
pointer-events: all;
cursor: pointer;
filter: none;
}
form button.disabled {
pointer-events: none;
filter: brightness(0.75) saturate(0.75); /* not using opacity here 'cause in the mobile layout you would see the cards under it */
cursor: default;
}
form button.unsubscribe {
position: relative;
background: none;
color: var(--text-shade-1);
margin-top: 12px;
padding: 0;
}
form button.unsubscribe.hidden {
position: absolute;
top: 0;
pointer-events: none;
z-index: -1;
}
form button.unsubscribe:hover {
color: var(--text-shade-3);
}
@media screen and (max-width: 900px) {
.account-form-wrapper {
width: min(500px, 100%);
margin-bottom: 172px;
}
form {
grid-template-columns: 1fr;
gap: 2.4rem;
}
form button {
position: relative;
width: 100%;
}
form .button-wrapper {
grid-column: 1 / span 1;
position: fixed;
bottom: 24px;
width: min(500px, 90%);
z-index: 5;
}
form .button-wrapper::before {
content: "";
position: absolute;
top: -24px;
left: -100vw;
width: 200vw;
height: 300%;
background: var(--bg-shade-0);
}
}
@media screen and (max-width: 380px) {
label.tier .tier-perks {
width: 80%;
}
.back-arrow {
padding: 6px;
}
.back-arrow span {
display: none;
}
}

View File

@@ -1,24 +0,0 @@
# How to optimise images
WebP is the best format to use for most images, as it provides a good compression over PNG and JPEG. It is supported by all modern browsers (https://caniuse.com/webp) and is the recommended format for images on the web.
## Using imagemagick (Recommended)
This is the recommended method to convert images to WebP as it is faster and can be done in bulk.
Install imagemagick from: https://imagemagick.org/script/download.php
In the folder you would like to convert images, use the appropriate command:
```bash
mogrify -format webp *.png
mogrify -format webp *.jpg
```
## Using a web service
If you don't want to install imagemagick, you can use towebp.io to convert images to WebP.
Go to https://towebp.io/ and upload your images. You can convert multiple images at once.
To match the default quality of imagemagick, use a quality of 90 (imagemagick uses 92 by default).

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-arrow-right"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg>

Before

Width:  |  Height:  |  Size: 306 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="#fff" d="M5 19h1.4l8.625-8.625l-1.4-1.4L5 17.6ZM19.3 8.925l-4.25-4.2l1.4-1.4q.575-.575 1.413-.575q.837 0 1.412.575l1.4 1.4q.575.575.6 1.388q.025.812-.55 1.387ZM4 21q-.425 0-.712-.288Q3 20.425 3 20v-2.825q0-.2.075-.387q.075-.188.225-.338l10.3-10.3l4.25 4.25l-10.3 10.3q-.15.15-.337.225q-.188.075-.388.075ZM14.325 9.675l-.7-.7l1.4 1.4Z"/></svg>

Before

Width:  |  Height:  |  Size: 438 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 571 KiB

View File

@@ -1,76 +0,0 @@
const updateServerEnvironmentForm = document.querySelector('form.server-selection');
const serverSelectionSaveButton = document.querySelector('#save-server-selection');
const editSettingsModal = document.querySelector('.modal-wrapper#edit-settings');
const editSettingsModalButtonClose = document.getElementById('editSettingsCloseButton');
const deleteAccountButton = document.getElementById('account-delete');
const deletePNIDConfirmModal = document.querySelector('.modal-wrapper#confirm-delete');
const deletePNIDConfirmModalButtonConfirm = document.getElementById('confirmDeleteConfirmButton');
const deletePNIDConfirmModalButtonClose = document.getElementById('confirmDeleteCloseButton');
editSettingsModalButtonClose?.addEventListener('click', () => {
editSettingsModal.classList.add('hidden');
});
document.addEventListener('click', (event) => {
if (event.target.classList.contains('edit')) {
event.preventDefault();
editSettingsModal.classList.remove('hidden');
}
});
serverSelectionSaveButton?.addEventListener('click', (event) => {
event.preventDefault();
const checkedInput = updateServerEnvironmentForm.querySelector('input:checked');
try {
const tokenType = document.cookie.split('; ').find(row => row.startsWith('token_type=')).split('=')[1];
const accessToken = document.cookie.split('; ').find(row => row.startsWith('access_token=')).split('=')[1];
fetch('https://api.pretendo.cc/v1/user', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': `${tokenType} ${decodeURIComponent(accessToken)}`
},
body: JSON.stringify({
environment: checkedInput.value
})
})
.then(response => response.json())
.then((json) => {
if (!json.error) {
// TODO - Make this prettier
alert('Saved server environment');
} else {
console.log(json.error);
alert('Failed to server environment');
}
})
.catch((error) => {
console.log(error);
// TODO - Make this prettier
alert('Failed to server environment');
});
} catch (error) {
alert(error);
}
});
deleteAccountButton?.addEventListener('click', (event) => {
event.preventDefault();
deletePNIDConfirmModal.classList.remove('hidden');
});
deletePNIDConfirmModalButtonConfirm?.addEventListener('click', async () => {
await fetch('/account/delete', {
method: 'POST'
});
deletePNIDConfirmModal.classList.add('hidden');
});
deletePNIDConfirmModalButtonClose?.addEventListener('click', () => {
deletePNIDConfirmModal.classList.add('hidden');
});

View File

@@ -1,7 +0,0 @@
const openSidebarBtn = document.querySelector('#openSidebar');
const content = document.querySelector('div.content');
openSidebarBtn.addEventListener('click', function () {
const sidebar = document.querySelector('.sidebar');
sidebar.classList.toggle('open');
content.classList.toggle('open-sidebar');
});

View File

@@ -1,197 +0,0 @@
const header = document.querySelector('header');
const dropdownButtonWrapper = document.querySelector('.dropdown-button-wrapper');
const dropdown = document.querySelector('header div.dropdown');
const allDropdownButtons = document.querySelectorAll('button.dropdown-button');
const desktopDropdownBtns = document.querySelectorAll('header .dropdown-button-wrapper button.dropdown-button');
const mobileDropdownBtn = document.querySelector('.dropdown-button#mobile-button');
let dropdownContent;
function isDropdownOpen() {
return header.classList.contains('dropdown-active');
}
function closeDropdown() {
dropdown.style.height = '0';
header.classList.remove('dropdown-active');
// deselect all buttons
allDropdownButtons.forEach((button) => {
button.classList.remove('active');
});
}
window.addEventListener('resize', () => {
if (isDropdownOpen() && dropdownContent) {
// set the dropdown height to the height of the content
dropdown.style.height = `${dropdownContent.offsetHeight}px`;
}
});
function navbarDropdownHandler(buttonID) {
const allDropdownContents = dropdown.querySelectorAll('div.dropdown-content');
dropdownContent = document.querySelector(`.dropdown-content#${buttonID}-dropdown-content`);
const dropdownButton = document.querySelector(`button.dropdown-button#${buttonID}-button`);
const dropdownArrow = document.querySelector('.dropdown-arrow#navbar-dropdown-arrow');
// if on mobile, reclicking the button should close the dropdown
if (buttonID === 'mobile' && dropdownButton.classList.contains('active')) {
closeDropdown();
return;
}
// hide all contents
allDropdownContents.forEach((content) => {
content.classList.remove('show');
});
// deselect all buttons
allDropdownButtons.forEach((button) => {
button.classList.remove('active');
});
// show the content of the clicked button
dropdownContent.classList.add('show');
// select the clicked button
dropdownButton.classList.add('active');
// set the dropdown height to the height of the content
dropdown.style.height = `${dropdownContent.offsetHeight}px`;
// move the arrow to the selected button
dropdownArrow.style.left = `${dropdownButton.offsetLeft + dropdownButton.offsetWidth / 2 - 5}px`;
// dim the rest of the page
header.classList.add('dropdown-active');
}
const dropdownAnchors = document.querySelectorAll('.dropdown-content a');
dropdownAnchors.forEach((a) => {
a.addEventListener('click', () => {
closeDropdown();
});
});
// make the header background transparent if near the top of the page
function makeHeaderBackgroundTransparent() {
if (window.pageYOffset < 100) {
header.classList.add('transparent');
} else {
header.classList.remove('transparent');
}
}
makeHeaderBackgroundTransparent();
window.addEventListener('scroll', () => {
makeHeaderBackgroundTransparent();
});
desktopDropdownBtns.forEach((btn) => {
['click', 'mouseover'].forEach((event) => {
btn.addEventListener(event, () => {
const id = btn.id.replace('-button', '');
navbarDropdownHandler(id);
});
});
});
mobileDropdownBtn.addEventListener('click', () => {
const id = 'mobile';
navbarDropdownHandler(id, true);
});
/* if on desktop: we check if the element the mouse moves to is part of the ignored element (keep the dropdown open) or not (close the dropdown)
* if on mobile: do nothing
*/
function dropdownOnMouseLeave(e, ignoredElement) {
if (window.innerWidth > 900) {
const targetElement = e.relatedTarget || e.toElement;
if (targetElement !== ignoredElement && !ignoredElement.contains(targetElement)) {
closeDropdown();
}
}
}
dropdownButtonWrapper.addEventListener('mouseleave', (e) => {
dropdownOnMouseLeave(e, dropdown);
});
dropdown.addEventListener('mouseleave', (e) => {
dropdownOnMouseLeave(e, dropdownButtonWrapper);
});
// Account widget handler
const userWidgetToggle = document.querySelector('.user-widget-toggle');
const userWidget = document.querySelector('.user-widget');
// Open widget on click, close locale dropdown
userWidgetToggle?.addEventListener('click', () => {
userWidget.classList.toggle('active');
localeOptionsContainer.classList.toggle('active');
localeDropdownToggle.classList.toggle('active');
});
// Locale dropdown handler
function localeDropdownHandler(selectedLocale) {
document.cookie = `preferredLocale=${selectedLocale};max-age=31536000`;
window.location.reload();
}
const localeDropdown = document.querySelector(
'.locale-dropdown[data-dropdown]'
);
const localeDropdownOptions = document.querySelectorAll(
'.locale-dropdown[data-dropdown] .options-container'
);
const localeDropdownToggle = document.querySelector('.locale-dropdown-toggle');
const localeOptionsContainer = localeDropdown.querySelector('.options-container');
const localeOptionsList = localeDropdown.querySelectorAll('.option');
// click dropdown element will open dropdown
localeDropdownToggle.addEventListener('click', () => {
localeOptionsContainer.classList.toggle('active');
localeDropdownToggle.classList.toggle('active');
});
// clicking on any option will close dropdown and change value
localeOptionsList.forEach((option) => {
option.addEventListener('click', () => {
localeDropdownToggle.classList.remove('active');
localeOptionsContainer.classList.remove('active');
const selectedLocale = option.querySelector('label').getAttribute('for');
localeDropdownHandler(selectedLocale);
});
});
// close all dropdowns on scroll
document.addEventListener('scroll', () => {
localeDropdownOptions.forEach(el => el.classList.remove('active'));
localeDropdownToggle.classList.remove('active');
userWidget?.classList.remove('active');
});
// click outside of dropdown will close all dropdowns
document.addEventListener('click', (e) => {
const targetElement = e.target;
let found = false;
if (
localeDropdown == targetElement ||
localeDropdown?.contains(targetElement)
) {
found = true;
userWidget?.classList.remove('active');
}
if (
userWidget == targetElement ||
userWidget?.contains(targetElement) ||
userWidgetToggle == targetElement ||
userWidgetToggle?.contains(targetElement)
) {
found = true;
localeDropdownToggle.classList.remove('active');
localeOptionsContainer.classList.remove('active');
}
if (found) {
return;
}
// click outside of dropdowns
userWidget?.classList.remove('active');
localeDropdownToggle.classList.remove('active');
localeOptionsContainer.classList.remove('active');
});

View File

@@ -1,523 +0,0 @@
/**
* Compilation note:
* This file gets automatically bundled with browserify when running the start script.
* This also means that after any update you're gonna need to restart the server to see any changes.
*
* browserify is needed for the use of require() in the browser
*/
const Mii = require('mii-js');
const newMiiData = 'AwAAQOlVognnx0GC2qjhdwOzuI0n2QAAAGBzAHQAZQB2AGUAAAAAAAAAAAAAAEBAAAAhAQJoRBgmNEYUgRIXaA0AACkAUkhQAAAAAAAAAAAAAAAAAAAAAAAAAAAAANeC';
// Prevent the user from reloading or leaving the page
window.onbeforeunload = function (e) {
e?.preventDefault();
e.returnValue = '';
};
// this makes it so the canvas fits in the target element
function setCanvasScale() {
let targetX;
let targetY;
if (window.innerWidth <= 1080) {
const canvasWrapper = document.querySelector('.canvas-wrapper');
targetX = canvasWrapper.offsetWidth;
targetY = canvasWrapper.offsetHeight;
} else {
targetX = window.innerWidth * 0.9;
targetY = window.innerHeight * 0.9;
}
const canvas = document.querySelector('canvas#miiCanvas');
const XScale = targetX / canvas.width;
const YScale = targetY / canvas.height;
canvas.style.transform = `scale(${Math.min(XScale, YScale)})`;
}
setCanvasScale();
window.addEventListener('resize', () => {
setCanvasScale();
});
let mii; // global mii object
// this initalizes a mii for editing
// returns if mii data was parsed successfully
function initializeMiiData(encodedUserMiiData) {
console.group('Initalizing Mii data');
console.log('encoded mii data:', encodedUserMiiData);
// We initialize the Mii object
try {
console.log('Attempting to parse mii data');
mii = new Mii(Buffer.from(encodedUserMiiData, 'base64'));
} catch (err) {
console.error('failed to decode mii data', err);
console.groupEnd();
return false;
}
// We set the img sources for the unedited miis in the save animation
console.log('grabbing rendered miis for later use');
const miiStudioNeutralUrl = mii.studioUrl({
width: 512,
bgColor: '13173300'
});
const miiStudioSorrowUrl = mii.studioUrl({
width: 512,
bgColor: '13173300',
expression: 'sorrow'
});
document.querySelector('.mii-comparison img.old-mii').setAttribute('data-src', miiStudioNeutralUrl);
document.querySelector('.mii-comparison.confirmed img.old-mii').setAttribute('data-src', miiStudioSorrowUrl);
console.log('initialization complete');
console.groupEnd();
return true;
}
// The Mii data is stored in a script tag in the HTML, so we can just grab it and then remove the element
const encodedUserMiiData = document.querySelector(
'script#encodedUserMiiData'
).textContent;
document.querySelector('script#encodedUserMiiData').remove();
// is valid mii data
const validMiiData = initializeMiiData(encodedUserMiiData);
if (!validMiiData) {
const shouldContinue = window.confirm('Found corrupted mii data, want to continue with a new Mii?');
if (!shouldContinue) {
window.location.assign('/account');
}
initializeMiiData(newMiiData);
}
// we keeep the images here so we can cache them when we need to change the build/height
const miiFaceImg = new Image();
const miiBodyImg = new Image();
// Initial mii render
renderMii();
// This function renders the Mii on the canvas
function renderMii(heightOverride, buildOverride) {
const canvas = document.querySelector('canvas#miiCanvas');
const ctx = canvas.getContext('2d');
const height = heightOverride || mii.height;
const build = buildOverride || mii.build;
// if there isn't an override or the images haven't been cached, we load the images
if ((!heightOverride && !buildOverride) || !miiFaceImg.src || !miiBodyImg.src) {
canvas.style.filter = 'blur(4px) brightness(70%)';
// request a face only render with split depth
miiFaceImg.src = mii.studioUrl({
width: 512,
bgColor: '13173300',
type: 'face_only',
splitMode: 'both' // this will be twice the height
});
miiBodyImg.src = mii.studioAssetUrlBody();
}
// misc calculations
const bodyWidth = (build * 1.7 + 220) * (0.003 * height + 0.6);
const bodyHeight = height * 3.5 + 227;
const bodyXPos = (canvas.width - bodyWidth) / 2;
const bodyYPos = canvas.height - bodyHeight;
const headYPos = bodyYPos - 408;
// we make sure every image is loaded before rendering
if (miiFaceImg.complete) {
onMiiFaceImgLoad();
} else {
miiFaceImg.onload = () => {
onMiiFaceImgLoad();
};
}
function onMiiFaceImgLoad_() {
if (miiBodyImg.complete) {
onBodyImgLoad();
} else {
miiBodyImg.onload = () => {
onBodyImgLoad();
};
}
}
function onBodyImgLoad() {
if (miiFaceImg.complete) {
onMiiFaceImgLoad_();
} else {
miiFaceImg.onload = () => {
onMiiFaceImgLoad_();
};
}
}
function onMiiFaceImgLoad() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// in mii studio split depth mode, the back half is on the top
// and the front half is on the bottom
// the back half needs to be drawn, then the body, then the front
const halfHeight = miiFaceImg.height / 2;
// top half of the image / back half of the head
ctx.drawImage(miiFaceImg, 0, 0, miiFaceImg.width, halfHeight, 0, headYPos, miiFaceImg.width, halfHeight);
// draw body on top
ctx.drawImage(miiBodyImg, bodyXPos, bodyYPos, bodyWidth, bodyHeight);
// we draw a portion of the bald mii on top of the normal mii to hide the mii's neck (see https://i.imgur.com/U0fpkwi.png)
// ctx.drawImage(baldMiiFaceImg, 186, 384, 140, 120, 186, headYPos + 384, 140, 120);
// draw bottom half of the image / front half of the head
ctx.drawImage(miiFaceImg, 0, halfHeight, miiFaceImg.width, halfHeight, 0, headYPos, miiFaceImg.width, halfHeight);
canvas.style.filter = '';
}
if (!heightOverride && !buildOverride) {
const faceMiiStudioUrl = mii.studioUrl({
width: 512,
bgColor: '13173300'
});
const faceMiiStudioSmileUrl = mii.studioUrl({
width: 512,
bgColor: '13173300',
expression: 'smile'
});
// sets the new mii in the save tab to the new mii
document.querySelector('.mii-comparison img.new-mii').setAttribute('data-src', faceMiiStudioUrl);
document.querySelector('.mii-comparison.confirmed img.new-mii').setAttribute('data-src', faceMiiStudioSmileUrl);
}
}
// This function updates a prop of the Mii and rerenders it
function updateMii(e) {
const prop = e.target.name;
let value = e.target.value || e.target.defaultValue;
// if the value comes from a checkbox, we use the checked property
if (value === 'on' || value === 'off') {
value = e.target.checked;
}
// if the prop is disableSharing, we set the value to the opposite of the current value
if (prop === 'disableSharing') {
value = !value;
}
// Handle booleans, on/offs and strings
if (value === 'true' || value === 'false') {
mii[prop] = value === 'true';
} else if (value === 'on' || value === 'off') {
mii[prop] = value === 'on';
} else if (isNaN(parseInt(value))) {
mii[prop] = value;
} else {
mii[prop] = parseInt(value);
}
// if the user is editing the height or the build, we render the mii with the correct override, else we do a straight render
if (prop === 'height') {
renderMii(value, false);
} else if (prop === 'build') {
renderMii(false, value);
} else {
renderMii();
console.log(mii);
}
}
function handleCalendar(e) {
const valueArray = e.target.value.split('-');
const day = valueArray[2];
const month = valueArray[1];
mii.birthDay = parseInt(day);
mii.birthMonth = parseInt(month);
}
function preventEmpty(e) {
if (e.target.value !== '') {
return;
}
e.target.value = e.target.defaultValue;
}
document.querySelectorAll('fieldset').forEach((fieldset) => {
fieldset.addEventListener('change', updateMii);
});
document.querySelectorAll('input[type=\'range\']').forEach((input) => {
input.addEventListener('input', updateMii);
});
document
.querySelectorAll('input[type=\'text\'], input[type=\'number\']')
.forEach((input) => {
input.addEventListener('blur', preventEmpty);
});
document
.querySelector('input[type=\'date\']#birthDate')
.addEventListener('change', handleCalendar);
// FORM
// Here we preselect the options corresponding to the Mii's current values
[
'faceType',
'skinColor',
'makeupType',
'wrinklesType',
'hairType',
'hairColor',
'eyebrowType',
'eyebrowColor',
'eyeType',
'eyeColor',
'noseType',
'mouthType',
'mouthColor',
'glassesType',
'glassesColor',
'beardType',
'facialHairColor',
'mustacheType',
'moleEnabled',
'gender',
'favoriteColor'
].forEach((prop) => {
const el = document.querySelector(`#${prop}${mii[prop]}`);
if (el) {
el.checked = true;
}
console.log(`[info] preselected value for ${prop}`);
});
['favorite', 'allowCopying'].forEach((prop) => {
const el = document.querySelector(`#${prop}`);
if (el) {
el.checked = mii[prop];
}
console.log(`[info] preselected value for ${prop}`);
});
document.querySelector('#disableSharing').checked = !mii.disableSharing;
console.log('[info] preselected value for disableSharing');
[
'eyebrowYPosition',
'eyebrowSpacing',
'eyebrowRotation',
'eyebrowScale',
'eyebrowVerticalStretch',
'eyeYPosition',
'eyeSpacing',
'eyeRotation',
'eyeScale',
'eyeVerticalStretch',
'noseYPosition',
'noseScale',
'mouthYPosition',
'mouthScale',
'mouthHorizontalStretch',
'glassesYPosition',
'glassesScale',
'mustacheYPosition',
'mustacheScale',
'moleYPosition',
'moleXPosition',
'moleScale',
'height',
'build',
'miiName',
'creatorName'
].forEach((prop) => {
document.querySelector(`#${prop}`).value = mii[prop];
document.querySelector(`#${prop}`).defaultValue = mii[prop];
console.log(`[info] preselected value for ${prop}`);
});
const paddedBirthDay = mii.birthDay.toString().padStart(2, '0');
const paddedBirthMonth = mii.birthMonth.toString().padStart(2, '0');
document.querySelector(
'input[type=\'date\']#birthDate'
).value = `2024-${paddedBirthMonth}-${paddedBirthDay}`;
console.log('[info] preselected value for birthMonth && birthDay');
// TABS, SUBTABS, AND ALL THE INHERENT JANK
function openTab(e, tabType) {
e.preventDefault();
// Deselect all subpages
document
.querySelectorAll('.subtab.has-subpages .subpage.active')
.forEach((el) => {
el.classList?.remove('active');
});
document.querySelectorAll('.subtab.active').forEach((el) => {
el.classList?.remove('active');
});
const buttonReplacement =
tabType.charAt(0).toUpperCase() + tabType.slice(1);
document.querySelectorAll(`.${tabType}.active`).forEach((el) => {
el?.classList?.remove('active');
});
document.querySelectorAll(`.${tabType}btn.active`).forEach((el) => {
el?.classList?.remove('active');
});
const elementID = e.target?.id;
document.querySelector(`#${elementID}`).classList.add('active');
const selectedID = elementID
.replace('SubButton', '')
.replace('Button', buttonReplacement);
document.querySelector(`#${selectedID}`).classList.add('active');
// if you selected the save tab...
if (selectedID === 'saveTab') {
// set data-src on images that have it to src
// effectively loading them right now (lazy load)
document
.querySelectorAll('#saveTab img[data-src]')
.forEach((e) => {
if (e.getAttribute('data-src') !== e.src) {
e.setAttribute('src', e.getAttribute('data-src'));
}
});
}
if (tabType === 'tab') {
// Click the first subtab button, if there is one
document.querySelector(`#${selectedID} .subtabbtn`)?.click();
}
setCanvasScale();
// We hide all subpages
document.querySelectorAll('.subpage').forEach((el) => {
el.classList.remove('active');
});
// Selects the first subpage if there is one
document.querySelector(`#${selectedID} .subpage`)?.classList?.add('active');
}
// Here we bind all of the functions to the corresponding buttons
document.querySelectorAll('.tabs button.tabbtn').forEach((el) => {
el.addEventListener('click', e => openTab(e, 'tab'));
});
document.querySelectorAll('.subtabs button.subtabbtn').forEach((el) => {
el.addEventListener('click', e => openTab(e, 'subtab'));
});
// SUBPAGES
function paginationHandler(e) {
e.preventDefault();
// We hide all subpages
document.querySelectorAll('.subpage').forEach((el) => {
el.classList.remove('active');
});
// We get the current subpage
const currentPageIndex = parseInt(
e.target.classList[2].replace('index-', '')
);
let newPageIndex = currentPageIndex;
// We calculate the new subpage
if (e.target.classList.contains('next')) {
newPageIndex += 1;
} else {
newPageIndex -= 1;
}
// We find the new subpage and activate it
e.target.parentNode.parentNode.parentNode.children[
newPageIndex
].classList.add('active');
}
// This adds 1 to the rendered page indexes to make them start from 1 instead of 0
document.querySelectorAll('span.current-page-index').forEach((el) => {
el.textContent = parseInt(el.textContent) + 1;
});
// Here we bind the functions to the corresponding buttons
document.querySelectorAll('button.page-btn').forEach((el) => {
el.addEventListener('click', paginationHandler);
});
// mii saving business (animation jank & actual saving)
document
.querySelector('#saveTab #saveButton')
.addEventListener('click', (e) => {
e.preventDefault();
document
.querySelector('#saveTab #saveButton')
.classList.add('inactive', 'fade-out');
document.querySelector('.tabs').style.pointerEvents = 'none';
document.querySelector('.mii-comparison.confirmed').style.opacity = 1;
document
.querySelector('#saveTab p.save-prompt')
.classList.add('fade-out');
setTimeout(() => {
document.querySelector(
'.mii-comparison.unconfirmed'
).style.opacity = 0;
}, 500);
setTimeout(() => {
document
.querySelector('.mii-comparison.confirmed .old-mii')
.classList.add('fade-out');
document
.querySelector('.mii-comparison.confirmed svg')
.classList.add('fade-out');
}, 1500);
setTimeout(() => {
document
.querySelector('.mii-comparison.confirmed .new-mii-wrapper')
.classList.add('centered-mii-img');
}, 2000);
try {
const miiData = mii.encode().toString('base64');
const tokenType = document.cookie.split('; ').find(row => row.startsWith('token_type=')).split('=')[1];
const accessToken = document.cookie.split('; ').find(row => row.startsWith('access_token=')).split('=')[1];
fetch('https://api.pretendo.cc/v1/user', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': `${tokenType} ${decodeURIComponent(accessToken)}`
},
body: JSON.stringify({
mii: {
name: mii.miiName,
primary: 'Y',
data: miiData
}
})
}).then(({ status }) => {
// TODO - Make this prettier
alert('Mii has been updated. It may take some time for the cached image on the website to update');
if (status === 200) {
window.onbeforeunload = null;
window.location.assign('/account');
}
}).catch(console.log);
} catch (error) {
alert(error);
}
});

View File

@@ -1,54 +0,0 @@
/* global Chart -- chart.js */
document.querySelectorAll('.feature-list-wrapper').forEach((progressListElement) => {
// Find and generate all relevant data
const percentageOverride = progressListElement.querySelector('canvas.percentage-chart').dataset.percentageoverride;
const allFeatureNodes = progressListElement.querySelectorAll('.feature');
const allDoneFeatureNodes = progressListElement.querySelectorAll('.feature .done');
const allStartedFeatureNodes = progressListElement.querySelectorAll('.feature .ongoing');
// Use percentage override data attribute if present, else calculate
const progressPercentage = Math.round(percentageOverride) || Math.round(Math.min((allDoneFeatureNodes.length + allStartedFeatureNodes.length * 0.5) / allFeatureNodes.length * 100, 100)) || 0;
const remainingPercentage = 100 - progressPercentage;
// Set inner paragraph
progressListElement.querySelectorAll('.percentage-label').forEach((p) => {
if (progressPercentage === 0) {
p.innerText = progressPercentage.toString() + '%';
} else {
p.innerText = progressPercentage.toString().padStart(2, '0') + '%';
}
});
// Create chart
const data = [progressPercentage, remainingPercentage];
Chart.defaults.plugins.legend = {
display: false
};
Chart.defaults.plugins.tooltip = {
enabled: false
};
const isInBrightCard = !!progressListElement.closest('.right.sect');
new Chart(progressListElement.querySelector('canvas'), {
type: 'doughnut',
data: {
labels: ['Done', 'Todo'],
datasets: [
{
data,
backgroundColor: isInBrightCard ? ['white', 'rgba(195, 178, 227, 0.5)'] : ['#9D6FF3', '#31365A']
}
]
},
options: {
elements: {
arc: {
borderWidth: 0
}
},
cutout: '70%'
}
});
});

View File

@@ -1,30 +0,0 @@
const passwordInput = document.querySelector('#password');
const passwordConfirmInput = document.querySelector('#password_confirm');
const tokenInput = document.querySelector('#token');
document.querySelector('form').addEventListener('submit', function (event) {
event.preventDefault();
fetch('https://api.pretendo.cc/v1/reset-password', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
password: passwordInput.value,
password_confirm: passwordConfirmInput.value,
token: tokenInput.value
})
})
.then(response => response.json())
.then((body) => {
if (body.error) {
alert(`Error: ${body.error}. TODO: red error message thing`);
} else {
alert('Password reset. TODO: reword this and green success');
window.location.assign('/account/login');
}
})
.catch(console.log);
});

View File

@@ -1,114 +0,0 @@
const buttons = {
submit: document.getElementById('submitButton'),
unsubModal: {
show: document.getElementById('unsubModalShowButton'),
close: document.getElementById('unsubModalCloseButton'),
confirm: document.getElementById('unsubModalConfirmButton')
},
switchTierModal: {
show: document.getElementById('switchTierShowButton'),
close: document.getElementById('switchTierCloseButton'),
confirm: document.getElementById('switchTierConfirmButton')
}
};
const currentTierID = document.querySelector('form').dataset.currentTier || undefined;
const currentTierElement = document.querySelector(`#${currentTierID}`) || undefined;
// if the condition is met, we disable the submit button and enable the unsubscribe button
function conditionalSubmitButton(condition, target) {
if (condition) {
buttons.submit.innerText = 'Already subscribed to this tier';
buttons.unsubModal.show.innerText = `Unsubscribe from ${currentTierElement.dataset.tierName}`;
buttons.submit.disabled = true;
buttons.submit.classList.add('disabled');
buttons.unsubModal.show.classList.remove('hidden');
} else {
buttons.submit.classList.remove('disabled');
buttons.unsubModal.show.classList.add('hidden');
buttons.submit.disabled = false;
buttons.submit.innerText = `Subscribe to ${target.dataset.tierName}`;
}
}
function submitForm(cancel) {
const form = document.querySelector('form');
if (cancel) {
form.action = '/account/stripe/unsubscribe';
} else {
const selectedTier = form.querySelector('input[type="radio"]:checked').value;
form.action = `/account/stripe/checkout/${selectedTier}`;
}
form.submit();
}
// If the currect tier exists, select it from the list and disable the submit button.
if (currentTierElement) {
currentTierElement.click();
conditionalSubmitButton(true);
}
// If a tier is selected, conditionally enable the submit button.
document.querySelector('form').addEventListener('change', function (e) {
e.preventDefault();
// If the selected tier is the current tier, set the button to disabled. Else we enable the button
conditionalSubmitButton(e.target.value === currentTierElement?.value, e.target);
});
// handle the submit button
buttons.submit.addEventListener('click', function (e) {
e.preventDefault();
// If the user is already subscribed to another tier, we show the confirm modal, else if this is a new subscription we submit the form.
if (currentTierElement) {
const oldTierNameSpan = document.querySelector('#switchtier .modal-caption span.oldtier');
const newTierNameSpan = document.querySelector('#switchtier .modal-caption span.newtier');
oldTierNameSpan.innerText = currentTierElement.dataset.tierName;
newTierNameSpan.innerText = document.querySelector('input[name="tier"]:checked').dataset.tierName;
document.body.classList.add('modal-open');
document.querySelector('.modal-wrapper#switchtier').classList.remove('hidden');
} else {
submitForm();
}
});
buttons.unsubModal.show.addEventListener('click', function (e) {
e.preventDefault();
const tierNameSpan = document.querySelector('#unsub .modal-caption span');
tierNameSpan.innerText = currentTierElement.dataset.tierName;
// Show the unsubscribe modal
document.body.classList.add('modal-open');
document.querySelector('.modal-wrapper#unsub').classList.remove('hidden');
});
buttons.unsubModal.close.addEventListener('click', function (e) {
e.preventDefault();
// Hide the unsubscribe modal
document.body.classList.remove('modal-open');
document.querySelector('.modal-wrapper#unsub').classList.add('hidden');
});
buttons.unsubModal.confirm.addEventListener('click', function (e) {
e.preventDefault();
submitForm(true);
});
buttons.switchTierModal.close.addEventListener('click', function (e) {
e.preventDefault();
// Hide the switch tier modal
document.body.classList.remove('modal-open');
document.querySelector('.modal-wrapper#switchtier').classList.add('hidden');
});
buttons.switchTierModal.confirm.addEventListener('click', function (e) {
e.preventDefault();
submitForm(false);
});

View File

@@ -1,19 +0,0 @@
{
"name": "Pretendo Network",
"short_name": "Pretendo Network",
"icons": [
{
"src": "https://pretendo.network/assets/images/icons/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "https://pretendo.network/assets/images/icons/android-chrome-384x384.png",
"sizes": "384x384",
"type": "image/png"
}
],
"theme_color": "#1b1f3b",
"background_color": "#1b1f3b",
"display": "standalone"
}

View File

@@ -1,19 +0,0 @@
{
"name": "Pretendo",
"short_name": "Pretendo",
"icons": [
{
"src": "/assets/icons/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/assets/icons/android-chrome-384x384.png",
"sizes": "384x384",
"type": "image/png"
}
],
"theme_color": "#673db6",
"background_color": "#673db6",
"display": "standalone"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 592 KiB

View File

@@ -0,0 +1,59 @@
import { usePapr } from '~~/server/utils/papr';
import { CheckoutSchema } from '~~/shared/api-types';
import type { ApiAccountCheckoutLink } from '~~/shared/api-types';
export default defineEventHandler(async (event): Promise<ApiAccountCheckoutLink> => {
const auth = enforceLoggedIn(event);
const papr = await usePapr(event);
const stripe = useStripe(event);
const config = useRuntimeConfig(event);
if (!stripe || !papr) {
throw createApiError('INTEGRATION_DISABLED');
}
const body = await readZodBody(event, CheckoutSchema);
const { data: searchResults } = await stripe.customers.search({
query: `metadata['pnid_pid']:'${auth.pid}'`
});
let customer = searchResults[0];
if (!customer) {
customer = await stripe.customers.create({
email: auth.email,
metadata: {
pnid_pid: auth.pid
}
});
}
// ensure PNID always has latest customer ID
if (auth.accessLevel >= 2) {
throw createApiError('STAFF_NO_DONATE');
}
await papr.Pnid.updateOne({ pid: auth.pid }, {
$set: {
'connections.stripe.customer_id': customer.id,
'connections.stripe.latest_webhook_timestamp': 0
}
});
const priceId = body.priceId;
const session = await stripe.checkout.sessions.create({
line_items: [
{
price: priceId,
quantity: 1
}
],
customer: customer.id,
mode: 'subscription',
success_url: new URL('/account?upgrade_success=true', config.public.baseUrl).toString(),
cancel_url: new URL('/account?upgrade_success=false', config.public.baseUrl).toString()
});
if (!session.url) {
throw new Error('Failed to create session');
}
return {
url: session.url
};
});

View File

@@ -0,0 +1,6 @@
export default defineEventHandler(async (event): Promise<void> => {
const auth = enforceLoggedIn(event);
const grpc = useApiGrpc(event);
await grpc.deleteAccount({ pid: auth.pid });
});

View File

@@ -0,0 +1,22 @@
import { useDiscord } from '~~/server/utils/discord';
import type { ApiAccountDiscordLink } from '~~/shared/api-types';
export default defineEventHandler(async (event): Promise<ApiAccountDiscordLink> => {
enforceLoggedIn(event);
const discord = useDiscord(event);
if (!discord) {
throw createApiError('INTEGRATION_DISABLED');
}
const redirectUrl = discord.makeCallbackUrl();
const url = new URL('https://discord.com/oauth2/authorize');
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', discord.clientId);
url.searchParams.set('scope', 'identify');
url.searchParams.set('redirect_uri', redirectUrl);
url.searchParams.set('prompt', 'scope');
url.searchParams.set('integration_type', '1');
return {
url: url.toString()
};
});

View File

@@ -0,0 +1,33 @@
import { removeDiscordMemberSupporterRole, removeDiscordMemberTesterRole } from '~~/server/utils/discord';
export default defineEventHandler(async (event): Promise<void> => {
const auth = enforceLoggedIn(event);
const discord = useDiscord(event);
if (!discord) {
throw createApiError('INTEGRATION_DISABLED');
}
const grpc = useApiGrpcWithToken(event, auth.token);
const oldUserData = await grpc.getUserData({});
const oldDiscordId = oldUserData.connections?.discord?.id;
await grpc.setDiscordConnectionData({
id: ''
});
const priceId = oldUserData.connections?.stripe?.priceId;
const stripe = useStripe(event);
if (stripe) {
if (priceId && oldDiscordId) {
const price = await stripe.prices.retrieve(priceId);
const product = await stripe.products.retrieve(price.product as string);
const discordRoleId = product.metadata.discord_role_id;
if (discordRoleId) {
await removeDiscordMemberSupporterRole(discord, oldDiscordId, discordRoleId);
}
if (product.metadata.beta === 'true') {
await removeDiscordMemberTesterRole(discord, oldDiscordId);
}
}
}
});

View File

@@ -0,0 +1,45 @@
import type { ApiAccountTiers, TierItem } from '~~/shared/api-types';
export default defineEventHandler(async (event): Promise<ApiAccountTiers> => {
enforceLoggedIn(event);
const stripe = useStripe(event);
if (!stripe) {
throw createApiError('INTEGRATION_DISABLED');
}
const prices = await stripe.prices.list().autoPagingToArray({ limit: 10 });
const products = await stripe.products.list().autoPagingToArray({ limit: 10 });
const tiers: TierItem[] = [];
for (const product of products) {
if (!product.active) {
continue;
}
const price = prices.find(price => price.id === product.default_price);
if (!price) {
continue;
}
const tierLevel = Number(product.metadata.tier_level ?? '0');
const hasDiscordReadPerk = product.metadata.discord_read === 'true';
const hasBetaAccessPerk = product.metadata.beta === 'true';
tiers.push({
priceId: price.id,
tierLevel,
priceCents: price.unit_amount ?? 0,
thumbnailUrl: product.images[0] ?? null,
name: product.name,
description: product.description,
perks: {
discordRead: hasDiscordReadPerk,
beta: hasBetaAccessPerk
}
});
}
return {
tiers
};
});

View File

@@ -0,0 +1,33 @@
import { usePapr } from '~~/server/utils/papr';
export default defineEventHandler(async (event): Promise<void> => {
const auth = enforceLoggedIn(event);
const papr = await usePapr(event);
const stripe = useStripe(event);
if (!stripe || !papr) {
throw createApiError('INTEGRATION_DISABLED');
}
if (!auth.stripeSubscriptionId) {
return; // No subscription, do nothing
}
await stripe.subscriptions.cancel(auth.stripeSubscriptionId);
let newAccessLevel = 0;
if (auth.accessLevel >= 2) {
newAccessLevel = auth.accessLevel; // Staff shouldn't be downgraded on unsubscribe
}
await papr.Pnid.updateOne({ pid: auth.pid }, {
$unset: {
'connections.stripe.subscription_id': 1,
'connections.stripe.price_id': 1,
'connections.stripe.tier_name': 1
},
$set: {
'connections.stripe.tier_level': 0,
'access_level': newAccessLevel
}
});
});

View File

@@ -0,0 +1,19 @@
import { AccountUpdateSchema } from '~~/shared/api-types';
export default defineEventHandler(async (event): Promise<void> => {
const body = await readZodBody(event, AccountUpdateSchema);
const auth = enforceLoggedIn(event);
const apiFetch = useHttpApi(event, auth.token);
// There's no equivalent GRPC endpoint to use, so we're using the HTTP api
await apiFetch('/v1/user', {
method: 'POST',
body: JSON.stringify({
mii: body.mii,
environment: body.environment
}),
headers: {
'Content-type': 'application/json'
}
});
});

View File

@@ -0,0 +1,16 @@
import { hcaptchaVerify } from '~~/server/utils/hcaptcha';
import { ForgotPasswordSchema } from '~~/shared/api-types';
export default defineEventHandler(async (event): Promise<void> => {
const body = await readZodBody(event, ForgotPasswordSchema);
const grpc = useApiGrpc(event);
const captchaResult = await hcaptchaVerify(event, body.captchaResponse);
if (!captchaResult) {
throw createApiError('INVALID_CAPTCHA');
}
await grpc.forgotPassword({
emailAddressOrUsername: body.emailOrPassword
});
});

View File

@@ -0,0 +1,44 @@
import { ClientError } from 'nice-grpc';
import { LoginSchema } from '#shared/api-types';
import type { ApiErrorCodes } from '~~/shared/errors';
import type { ApiAuthLogin } from '#shared/api-types';
const bucket = createRatelimitBucket({
id: 'login',
points: 30,
durationSec: 5 * 60, // 5 minutes
blockDurationSec: 1 * 60 * 60 // 1 hour
});
const errors: Record<string, ApiErrorCodes> = {
'INVALID_ARGUMENT: User not found': 'INVALID_USERNAME',
'INVALID_ARGUMENT: Password is incorrect': 'INVALID_PASSWORD',
'UNAUTHENTICATED: Account has been deleted': 'ACCOUNT_DELETED'
};
export default defineEventHandler(async (event): Promise<ApiAuthLogin> => {
await enforceRatelimit(event, bucket);
const body = await readZodBody(event, LoginSchema);
const grpc = useApiGrpc(event);
try {
const res = await grpc.login({
username: body.username,
password: body.password,
grantType: 'password'
});
return {
accessToken: res.accessToken,
refreshToken: res.refreshToken
};
} catch (error: unknown) {
if (error instanceof ClientError) {
const errorCode = errors[error.details];
if (errorCode) {
throw createApiError(errorCode);
}
}
throw error;
}
});

View File

@@ -0,0 +1,28 @@
import { getDiscordUser } from '~~/server/utils/discord';
import type { GetApiAuthMeConections } from '~~/shared/api-types';
import type { DiscordUser } from '~~/server/utils/discord';
export default defineEventHandler(async (event): Promise<GetApiAuthMeConections> => {
const auth = enforceLoggedIn(event);
const discord = useDiscord(event);
const apiGrpc = useApiGrpcWithToken(event, auth.token);
const data = await apiGrpc.getUserData({});
let discordUser: DiscordUser | null = null;
if (data.connections?.discord?.id && discord) {
discordUser = await getDiscordUser(discord, data.connections.discord.id);
}
return {
pid: data.pid,
discord: discordUser
? {
id: discordUser.id,
username: discordUser.username,
discriminator: discordUser.discriminator,
avatar: discordUser.avatar ?? null,
avatarUrl: discordUser.avatar ? `https://cdn.discordapp.com/avatars/${discordUser.id}/${discordUser.avatar}.png` : null
}
: null
};
});

36
server/api/auth/me.get.ts Normal file
View File

@@ -0,0 +1,36 @@
import type { GetApiAuthMe } from '#shared/api-types';
export default defineEventHandler(async (event): Promise<GetApiAuthMe> => {
const config = useRuntimeConfig(event);
const auth = enforceLoggedIn(event);
const apiGrpc = useApiGrpcWithToken(event, auth.token);
const data = await apiGrpc.getUserData({ });
return {
pid: data.pid,
username: data.username,
accessLevel: data.accessLevel,
birthday: data.birthday,
gender: data.gender,
country: data.country,
timezone: data.timezone,
emailAddress: data.emailAddress,
serverAccessLevel: data.serverAccessLevel as any,
discordId: data.connections?.discord?.id ?? null,
stripeTier: data.connections?.stripe?.subscriptionId
? {
subscriptionId: data.connections.stripe.subscriptionId,
priceId: data.connections.stripe.priceId ?? '',
tierName: data.connections.stripe.tierName ?? '',
tierLevel: data.connections.stripe.tierLevel ?? ''
}
: null,
mii: data.mii
? {
imageUrl: `${config.public.cdnBaseUrl}/mii/${data.pid}/normal_face.png`,
name: data.mii.name,
data: data.mii.data
}
: null
};
});

View File

@@ -0,0 +1,35 @@
import { ClientError } from 'nice-grpc';
import { RefreshSchema } from '#shared/api-types';
import type { ApiAuthLogin } from '#shared/api-types';
const bucket = createRatelimitBucket({
id: 'refresh',
points: 10,
durationSec: 1 * 60, // 1 minute
blockDurationSec: 1 * 60 * 60 // 1 hour
});
export default defineEventHandler(async (event): Promise<ApiAuthLogin> => {
await enforceRatelimit(event, bucket);
const body = await readZodBody(event, RefreshSchema);
const grpc = useApiGrpc(event);
try {
const res = await grpc.login({
refreshToken: body.token,
grantType: 'refresh_token'
});
return {
accessToken: res.accessToken,
refreshToken: res.refreshToken
};
} catch (error: unknown) {
if (error instanceof ClientError) {
if (error.details === 'INVALID_ARGUMENT: Invalid or missing refresh token') {
throw createApiError('UNAUTHENTICATED');
}
}
throw error;
}
});

View File

@@ -0,0 +1,78 @@
import { ClientError } from 'nice-grpc';
import { RegisterSchema } from '#shared/api-types';
import type { ApiErrorCodes } from '~~/shared/errors';
import type { ApiAuthLogin } from '#shared/api-types';
const bucket = createRatelimitBucket({
id: 'register',
points: 15,
durationSec: 5 * 60, // 5 minutes
blockDurationSec: 1 * 60 * 60 // 1 hour
});
const errors: Record<string, ApiErrorCodes> = {
'INVALID_ARGUMENT: Captcha verification failed': 'INVALID_CAPTCHA',
'INVALID_ARGUMENT: Invalid email address': 'INVALID_EMAIL',
'INVALID_ARGUMENT: Username is too short': 'USERNAME_TOO_SHORT',
'INVALID_ARGUMENT: Username is too long': 'USERNAME_TOO_LONG',
'INVALID_ARGUMENT: Username contains invalid characters': 'USERNAME_INVALID_CHARS',
'INVALID_ARGUMENT: Username cannot begin with punctuation characters': 'USERNAME_INVALID_CHARS',
'INVALID_ARGUMENT: Username cannot end with punctuation characters': 'USERNAME_INVALID_CHARS',
'INVALID_ARGUMENT: Two or more punctuation characters cannot be used in a row': 'USERNAME_INVALID_CHARS',
'INVALID_ARGUMENT: PNID already in use': 'USERNAME_IN_USE',
'INVALID_ARGUMENT: Mii name too long': 'MIINAME_TOO_LONG',
'INVALID_ARGUMENT: Password must be between 6 and 16 characters long': 'INVALID_PASSWORD_INPUT',
'INVALID_ARGUMENT: Password cannot be the same as username': 'INVALID_PASSWORD_INPUT',
'INVALID_ARGUMENT: Password must have combination of letters, numbers, and/or punctuation characters': 'INVALID_PASSWORD_INPUT',
'INVALID_ARGUMENT: Password may not have 3 repeating characters': 'INVALID_PASSWORD_INPUT',
'INVALID_ARGUMENT: Passwords do not match': 'INVALID_PASSWORD_NO_MATCH'
};
function getCutoffDateForAge(today: Date, age: number) {
return new Date(today.getFullYear() - age, today.getMonth(), today.getDate());
}
function assertAge(birthDate: string | undefined) {
if (!birthDate) {
throw createApiError('INVALID_INPUT');
}
const date = new Date(birthDate);
const today = new Date();
// Prevent users below 13
if (date > getCutoffDateForAge(today, 13)) {
throw createApiError('UNDER_THIRTEEN');
}
}
export default defineEventHandler(async (event): Promise<ApiAuthLogin> => {
await enforceRatelimit(event, bucket);
const body = await readZodBody(event, RegisterSchema);
const grpc = useApiGrpc(event);
assertAge(body.birthday);
try {
// TODO Add ip
const res = await grpc.register({
email: body.email,
miiName: body.miiName,
captchaResponse: body.captchaResponse,
username: body.username,
password: body.password,
passwordConfirm: body.passwordConfirm
});
return {
accessToken: res.accessToken,
refreshToken: res.refreshToken
};
} catch (error: unknown) {
if (error instanceof ClientError) {
const errorCode = errors[error.details];
if (errorCode) {
throw createApiError(errorCode);
}
}
throw error;
}
});

View File

@@ -0,0 +1,19 @@
import { ResetPasswordSchema } from '~~/shared/api-types';
export default defineEventHandler(async (event): Promise<void> => {
const body = await readZodBody(event, ResetPasswordSchema);
const apiFetch = useHttpApi(event);
// The GRPC version requires a login token, which the user doesnt have when resetting password, so we're using the HTTP api
await apiFetch('/v1/reset-password', {
method: 'POST',
body: JSON.stringify({
password: body.password,
password_confirm: body.passwordConfirm,
token: body.resetToken
}),
headers: {
'Content-type': 'application/json'
}
});
});

View File

@@ -0,0 +1,42 @@
import { getGithubProjects } from '../utils/getGithubProgress';
import { getStripeDonations } from '../utils/getStripeDonations';
import { useCacher } from '../utils/cache';
import type { GetProgress, ProgressItem } from '#shared/api-types';
const donationGoalCents = 3000 * 100;
export default defineEventHandler(async (event): Promise<GetProgress> => {
const octokit = useOctokit(event);
const stripe = useStripe(event);
const cacher = useCacher(event);
const donationData = await getStripeDonations(cacher, stripe);
const { projects } = await getGithubProjects(cacher, octokit);
const items: ProgressItem[] = projects.map((v) => {
const totalTasks = v.tasks.length;
const completedTasks = v.tasks.filter(v => v.status === 'completed').length;
const halfCompletedTasks = v.tasks.filter(v => v.status === 'inprogress').length;
const percentage = Math.round((completedTasks + (halfCompletedTasks * 0.5)) / totalTasks * 100);
return {
title: v.title,
githubUrl: v.url,
completion: percentage,
tasks: v.tasks.map(task => ({
status: task.status,
title: task.title
}))
};
});
const summedCompletion = items.reduce((a, v) => a + v.completion, 0);
const completionPercentage = Math.round(summedCompletion / items.length);
return {
completion: completionPercentage,
donations: {
currentCents: donationData.totalDonationsCents,
goalCents: donationGoalCents
},
items
};
});

29
server/middleware/auth.ts Normal file
View File

@@ -0,0 +1,29 @@
export default defineEventHandler(async (event) => {
const authHeader = getRequestHeader(event, 'authorization');
setAuthContext(event, null);
if (authHeader) {
try {
const [type, token] = authHeader.split(' ', 2);
if (type !== 'Bearer') {
throw new Error('Invalid token type');
}
if (!token) {
throw new Error('Invalid token');
}
const grpc = useLegacyApiGrpcWithToken(event, token);
const userData = await grpc.getUserData({});
setAuthContext(event, {
pid: userData.pid,
username: userData.username,
token: token,
accessLevel: userData.accessLevel,
email: userData.emailAddress,
stripeSubscriptionId: userData.connections?.stripe?.subscriptionId ?? null
});
} catch (err) {
console.error('Failed to request user data: ', err);
return; // Continue like nothing happened, further steps will validate if authed
}
}
});

View File

@@ -0,0 +1,79 @@
import { assignDiscordMemberSupporterRole, assignDiscordMemberTesterRole } from '~~/server/utils/discord';
type DiscordTokenResponse = {
access_token: string;
token_type: string;
expires_in: number;
refresh_token: string;
scope: string;
};
type DiscordUserResponse = {
user?: {
id: string;
};
};
// Discord oauth callback
export default defineEventHandler(async (event) => {
const discord = useDiscord(event);
if (!discord) {
return sendRedirect(event, '/');
}
const discordFetch = $fetch.create({
baseURL: discord.baseUrl
});
const accessTokenCookie = getCookie(event, 'access_token');
const query = getQuery(event);
const authCode = query.code?.toString();
if (!authCode || !accessTokenCookie) {
return sendRedirect(event, '/');
}
const tokens = await discordFetch<DiscordTokenResponse>('/oauth2/token', {
method: 'POST',
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: discord.clientId,
client_secret: discord.clientSecret,
code: authCode,
redirect_uri: discord.makeCallbackUrl()
})
});
const authInfo = await discordFetch<DiscordUserResponse>('/oauth2/@me', {
headers: {
Authorization: `Bearer ${tokens.access_token}`
}
});
if (!authInfo.user) {
return sendRedirect(event, '/account?discord_link_success=false'); // No identify scope
}
const discordId = authInfo.user.id;
const grpc = useApiGrpcWithToken(event, accessTokenCookie ?? '');
await grpc.setDiscordConnectionData({
id: discordId
});
const userData = await grpc.getUserData({});
const priceId = userData.connections?.stripe?.priceId;
const stripe = useStripe(event);
if (stripe && priceId) {
if (priceId) {
const price = await stripe.prices.retrieve(priceId);
const product = await stripe.products.retrieve(price.product as string);
const discordRoleId = product.metadata.discord_role_id;
if (discordRoleId) {
await assignDiscordMemberSupporterRole(discord, discordId, discordRoleId);
}
if (product.metadata.beta === 'true') {
await assignDiscordMemberTesterRole(discord, discordId);
}
}
}
return sendRedirect(event, '/account?discord_link_success=true');
});

Some files were not shown because too many files have changed in this diff Show More