add blog page
Co-authored-by: Evan Song <ferothefox@users.noreply.github.com>
This commit is contained in:
parent
b3f806b22b
commit
a4c581c875
9 changed files with 389 additions and 27 deletions
29
package-lock.json
generated
29
package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
24
src/lib/util/animation/index.ts
Normal file
24
src/lib/util/animation/index.ts
Normal 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(", ");
|
||||
}
|
|
@ -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>
|
||||
|
|
37
src/routes/blog/+page.server.ts
Normal file
37
src/routes/blog/+page.server.ts
Normal 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,
|
||||
};
|
||||
}
|
121
src/routes/blog/+page.svelte
Normal file
121
src/routes/blog/+page.svelte
Normal 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>
|
29
src/routes/blog/[page]/+page.server.ts
Normal file
29
src/routes/blog/[page]/+page.server.ts
Normal 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],
|
||||
},
|
||||
};
|
||||
}
|
84
src/routes/blog/[page]/+page.svelte
Normal file
84
src/routes/blog/[page]/+page.svelte
Normal 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>
|
60
static/blog/nullptr-getting-started-with-ldn.md
Normal file
60
static/blog/nullptr-getting-started-with-ldn.md
Normal 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!
|
Loading…
Reference in a new issue