add blog page

Co-authored-by: Evan Song <ferothefox@users.noreply.github.com>
This commit is contained in:
not-nullptr 2024-03-13 21:55:05 +00:00
parent b3f806b22b
commit a4c581c875
9 changed files with 389 additions and 27 deletions

29
package-lock.json generated
View file

@ -21,6 +21,7 @@
"sequelize": "^6.37.1",
"sqlite3": "^5.1.7",
"svelte-countup": "^0.2.6",
"svelte-markdown": "^0.4.1",
"typeorm": "^0.3.20",
"uuid": "^9.0.1",
"verify-hcaptcha": "^1.0.0",
@ -1592,6 +1593,11 @@
"@types/node": "*"
}
},
"node_modules/@types/marked": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@types/marked/-/marked-5.0.2.tgz",
"integrity": "sha512-OucS4KMHhFzhz27KxmWg7J+kIYqyqoW5kdIEI319hqARQQUTqhao3M/F+uFnDXD0Rg72iDDZxZNxq5gvctmLlg=="
},
"node_modules/@types/ms": {
"version": "0.7.34",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz",
@ -8061,6 +8067,17 @@
"node": ">=0.10.0"
}
},
"node_modules/marked": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-5.1.2.tgz",
"integrity": "sha512-ahRPGXJpjMjwSOlBoTMZAK7ATXkli5qCPxZ21TG44rx1KEo44bii4ekgTDQPNRQ4Kh7JMb9Ub1PVk1NxRSsorg==",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/matchdep": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/matchdep/-/matchdep-2.0.0.tgz",
@ -11918,6 +11935,18 @@
"svelte": "^3.19.0 || ^4.0.0"
}
},
"node_modules/svelte-markdown": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/svelte-markdown/-/svelte-markdown-0.4.1.tgz",
"integrity": "sha512-pOlLY6EruKJaWI9my/2bKX8PdTeP5CM0s4VMmwmC2prlOkjAf+AOmTM4wW/l19Y6WZ87YmP8+ZCJCCwBChWjYw==",
"dependencies": {
"@types/marked": "^5.0.1",
"marked": "^5.1.2"
},
"peerDependencies": {
"svelte": "^4.0.0"
}
},
"node_modules/svelte-parse-markup": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/svelte-parse-markup/-/svelte-parse-markup-0.1.2.tgz",

View file

@ -59,6 +59,7 @@
"sequelize": "^6.37.1",
"sqlite3": "^5.1.7",
"svelte-countup": "^0.2.6",
"svelte-markdown": "^0.4.1",
"typeorm": "^0.3.20",
"uuid": "^9.0.1",
"verify-hcaptcha": "^1.0.0",

View file

@ -0,0 +1,24 @@
interface Animation {
duration: number;
delay: number;
property: string | string[];
timingFunction: string;
}
export const transition =
"linear(0,0.006,0.025 2.8%,0.101 6.1%,0.539 18.9%,0.721 25.3%,0.849 31.5%,0.937 38.1%,0.968 41.8%,0.991 45.7%,1.006 50.1%,1.015 55%,1.017 63.9%,1.001)";
export function generateTransition(animations: Animation[]) {
return animations
.map((animation) =>
Array.isArray(animation.property)
? animation.property
.map(
(property) =>
`${property} ${animation.duration}ms ${animation.timingFunction} ${animation.delay * 50}ms`,
)
.join(", ")
: `${animation.property} ${animation.duration}ms ${animation.timingFunction} ${animation.delay * 50}ms`,
)
.join(", ");
}

View file

@ -9,6 +9,7 @@
import type { TransitionConfig } from "svelte/transition";
import type { PageData } from "./$types";
import { bounceOut } from "svelte/easing";
import { generateTransition, transition } from "$lib/util/animation";
export let data: PageData;
@ -18,31 +19,7 @@
href: string;
}
interface Animation {
duration: number;
delay: number;
property: string | string[];
timingFunction: string;
}
function generateTransition(animations: Animation[]) {
return animations
.map((animation) =>
Array.isArray(animation.property)
? animation.property
.map(
(property) =>
`${property} ${animation.duration}ms ${animation.timingFunction} ${animation.delay * 50}ms`,
)
.join(", ")
: `${animation.property} ${animation.duration}ms ${animation.timingFunction} ${animation.delay * 50}ms`,
)
.join(", ");
}
const token = writable("");
const transition =
"linear(0,0.006,0.025 2.8%,0.101 6.1%,0.539 18.9%,0.721 25.3%,0.849 31.5%,0.937 38.1%,0.968 41.8%,0.991 45.7%,1.006 50.1%,1.015 55%,1.017 63.9%,1.001)";
function transitionIn(node: HTMLElement, { duration = 360 }: TransitionConfig) {
const UA = navigator.userAgent;
@ -117,7 +94,7 @@
],
{
easing: transition,
duration,
duration: duration,
},
);
return {
@ -139,7 +116,7 @@
const navItems: NavItem[] = [
{
name: "Blog",
href: "/coming-soon",
href: "/blog",
},
{
name: "Docs",
@ -251,7 +228,7 @@
<div
class="flex w-full flex-row items-center justify-center gap-2 text-sm font-medium text-[#A6A5A7] max-[625px]:hidden"
>
<a href="/coming-soon" class="px-5 py-3 transition hover:text-white">Blog</a>
<a href="/blog" class="px-5 py-3 transition hover:text-white">Blog</a>
<a href="/coming-soon" class="px-5 py-3 transition hover:text-white">Docs</a>
<a href="/coming-soon" class="px-5 py-3 transition hover:text-white">FAQ</a>
</div>

View file

@ -0,0 +1,37 @@
import path from "path";
import fs from "fs/promises";
import { error } from "@sveltejs/kit";
export async function load({ params }) {
const basePath = "static/blog";
const files = await fs.readdir(basePath);
// get all file contents in an array
const posts = await Promise.all(
files.map(async (filename) => {
const filePath = path.join(basePath, filename);
let contents = await fs.readFile(filePath, "utf-8");
const title =
contents
.split("\n")
.find((line) => line.startsWith("#"))
?.slice(1) ||
filename
.split("-")
.slice(1)
.join(" ")
.split(".md")
.slice(0, -1)
.join(".md")
.replace(/^\w/gm, (c) => c.toUpperCase());
// remove title from contents
return {
contents: contents.split("\n").slice(1).join("\n"),
title,
slug: filename.split(".md").slice(0, -1).join(".md").split("-").slice(1).join("-"),
};
}),
);
return {
posts,
};
}

View file

@ -0,0 +1,121 @@
<script lang="ts">
import { page } from "$app/stores";
import { onDestroy, onMount } from "svelte";
import type { PageData } from "./$types";
import { transition } from "$lib/util/animation";
import SvelteMarkdown from "svelte-markdown";
let cardsContainer: HTMLDivElement;
export function transitionIn() {
// const cardWidth = 285;
// const cardGap = 24;
const bounds = cardsContainer.getBoundingClientRect();
const cards = cardsContainer.querySelectorAll("div");
// how many cards fit on a row
// const cardsPerRow = Math.floor(bounds.width / (cardWidth + cardGap));
cards.forEach((card, i) => {
// const x = Math.floor(i / cardsPerRow);
const x = i;
card.style.zIndex = ((i + 1) * 5).toString();
card.animate(
[
{
transform: "translateY(-200px)",
opacity: "0",
filter: "blur(20px)",
},
{
transform: "translateY(0px)",
opacity: "1",
filter: "blur(0px)",
},
],
{
duration: 700,
easing: transition,
delay: x * 40,
fill: "forwards",
},
);
});
}
onMount(() => {
transitionIn();
});
function doTransition(e: MouseEvent) {
const card = (e.target as HTMLDivElement).closest(".card");
if (!card) return;
// get bounds of card
const bounds = card.getBoundingClientRect();
// how much does the card need to scale to become 100vw, 100vh
const scaleX = window.innerWidth / bounds.width;
const scaleY = window.innerHeight / bounds.height;
// how much does the card need to move to become centered
const translateX = window.innerWidth / 2 - bounds.x - bounds.width / 2;
const translateY = window.innerHeight / 2 - bounds.y - bounds.height / 2;
// animate the card to become fullscreen
card.animate(
[
{
transform: "translate(0px, 0px) scale(1)",
opacity: "1",
},
{
transform: `translate(${translateX}px, ${translateY}px) scale(${scaleX}, ${scaleY})`,
opacity: "0",
backgroundColor: "transparent",
},
],
{
duration: 850,
easing: "cubic-bezier(0.19, 1, 0.22, 1)",
fill: "forwards",
},
);
}
export let data: PageData;
</script>
<h1 class="mb-8 text-[40px] leading-[1.41] md:text-[60px] md:leading-[1.1]">Blog Posts</h1>
<div class="grid max-w-full grid-cols-1 gap-8 lg:grid-cols-2" bind:this={cardsContainer}>
{#each data.posts as post}
<a href={`/blog/${post.slug}`}>
<div
class="card relative h-[250px] w-full translate-y-[-200px] overflow-hidden rounded-[2.25rem] border-2 border-solid border-zinc-700 bg-black p-8 opacity-0 blur-[20px] lg:h-[400px]"
>
<div
style="--mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 0%, rgba(0,0,0,0) 100%); mask-image: var(--mask-image); -webkit-mask-image: var(--mask-image);"
class="h-full"
>
<h1 class="mb-4 text-[24px] leading-[1.41] md:text-[36px] md:leading-[1.1]">
{post.title}
</h1>
<p class="excerpt">
<SvelteMarkdown source={post.contents} />
</p>
</div>
</div>
</a>
{/each}
</div>
<style>
.excerpt :global(*) {
font-family: "DM Sans", sans-serif;
font-size: 1rem;
}
.excerpt :global(h1),
.excerpt :global(h2),
.excerpt :global(h3),
.excerpt :global(h4),
.excerpt :global(h5),
.excerpt :global(h6) {
font-family: "DM Sans", sans-serif;
font-weight: 500;
}
</style>

View file

@ -0,0 +1,29 @@
import path from "path";
import fs from "fs/promises";
import { error } from "@sveltejs/kit";
export async function load({ params }) {
const basePath = "static/blog";
const page = params.page;
// const contents = await fs.readFile(filePath, "utf-8");
const files = await fs.readdir(basePath);
const searchableFiles = files.map((f) =>
f.split("-").slice(1).join("-").split(".md").slice(0, -1).join(".md"),
);
const index = searchableFiles.indexOf(page);
const filename = files.at(index);
if (!filename || index === -1) {
error(404, "Not found");
}
const filePath = path.join(basePath, filename);
const contents = await fs.readFile(filePath, "utf-8");
return {
props: {
contents,
author: filename.split("-")[0],
},
};
}

View file

@ -0,0 +1,84 @@
<script lang="ts">
import type { PageData } from "./$types";
import SvelteMarkdown from "svelte-markdown";
export let data: PageData;
</script>
<p class="author">{data.props.author} presents...</p>
<div class="md">
<SvelteMarkdown source={data.props.contents} />
</div>
<style>
.author {
font-size: 1.1rem;
color: rgba(255, 255, 255, 0.5);
margin-bottom: -2rem;
}
.md {
margin-top: 8px;
}
.md :global(pre > code) {
background: black;
display: block;
padding: 24px 32px;
border-radius: 2.25rem;
}
.md :global(h1),
.md :global(h2),
.md :global(h3),
.md :global(h4),
.md :global(h5),
.md :global(h6) {
margin-bottom: 1rem;
margin-top: 2rem;
color: white;
}
.md :global(h1) {
font-size: 3rem;
line-height: 1.41;
}
.md :global(h2) {
font-size: 2.5rem;
line-height: 1.5;
}
.md :global(h3) {
font-size: 2rem;
line-height: 1.5;
}
.md :global(h4) {
font-size: 1.5rem;
line-height: 1.5;
}
.md :global(h5) {
font-size: 1.25rem;
line-height: 1.5;
}
.md :global(h6) {
font-size: 1rem;
line-height: 1.5;
}
.md :global(li) {
margin-bottom: 0.5rem;
margin-top: 1rem;
list-style: circle;
margin-left: 2rem;
padding-left: 1rem;
}
.md :global(*) {
font-size: 1.25rem;
color: rgba(255, 255, 255, 0.7);
}
</style>

View file

@ -0,0 +1,60 @@
# Getting Started with Suyu's LDN (Local Wireless)
Before Suyu, there was Yuzu. And the Yuzu developers, one day, chose to implement LDN (Local Wireless Multiplayer) which emulated Nintendo's local wireless stack. This post won't go into the details of Nintendo's stack, since this is a post to document the undocumented: Yuzu's closed-source lobby-listing and authentication API.
## Initial Authentication
The lobby server operates using key-like authentication. You sign up on some external site, which then provides you with a key, base64 encoded in the following format: <br/><br/>
`username:[random hex data]`<br/><br/>
The difficulty of generating this key is that:<br/>
- The longer your username is, the less secure it becomes
- The base64 is limited to 80 characters. Calculating that length is hard.
- Generally parsing it is a nuisance (what if the username contains a colon?)<br/><br/>
Once you've worked around that, you generate a key. In Suyu/Yuzu, go into the settings and attempt to verify it. This may first send a request to `/jwt/external/key.pem` in order to retrieve the JWT public key. The server will send a valid key in .pem format.<br/><br/>
Next, it'll send a POST request to `/jwt/internal`, with the username and token in `x-username` and `x-token` headers respectively. The server will verify the username and token combination, then sign a JWT for you using RS256 and send it back. This JWT is then verified against the pubkey, then used for all future requests.<br/><br/>
Finally, it GETs /profile, which will decode your JWT, find your user and return your username (`{ username: string }`). This is to verify that the JWT is valid and that the user exists.<br/><br/>
## Lobby Listing
Here are all the types for lobbies ("rooms") used in the suyu codebase:<br/><br/>
```ts
export interface IRoom {
address: string;
description: string;
externalGuid: string;
hasPassword: boolean;
id: string;
maxPlayers: number;
name: string;
netVersion: number;
owner: string;
players: RoomPlayer[];
port: number;
preferredGameId: number;
preferredGameName: string;
}
export interface RoomPlayer {
gameId: number;
gameName: string;
nickname: string;
}
```
<br /><br />
`GET /lobby` takes no parameters (other than an auth header, Bearer {token} afaik) and returns a list of rooms, `{ rooms: IRoom[] }`.<br/><br/>
`POST /lobby` takes a JSON body of type `IRoom` (ish) and returns the room info. This is used to create a room.<br/><br/>
`POST /lobby/{id}` takes a JSON body of type `{ players: RoomPlayer[] }` and returns `{ message: "Lobby successfully updated." }`. This is used to update the room's player list, and should only be able to be called by the room's owner.<br/><br/>
`DELETE /lobby/{id}` takes no parameters and returns the room's info as an `IRoom`. This is used to delete a room, and should also only be able to be called by the room's owner.<br/><br/>
## Conclusion
This is a very basic overview of the LDN API. It's not very complex, but it's also not very well documented. Hopefully this post will help you understand how to use it. If you have any questions, tag me in the Discord (`notnullptr`) or check the source code for more information. I hope this post was helpful to you. Good luck!