feat: new dropdown, friendship stuffz, passwords :3c

Co-authored-by: Evan Song <ferothefox@users.noreply.github.com>
This commit is contained in:
not-nullptr 2024-03-18 02:15:05 +00:00
parent 23c20112c9
commit 7e49d2dc92
27 changed files with 878 additions and 153 deletions

208
package-lock.json generated
View file

@ -11,6 +11,7 @@
"@benzara/svelte-animated-counter": "^0.0.3",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/enhanced-img": "^0.1.8",
"bcrypt": "^5.1.1",
"better-sqlite3": "^9.4.3",
"carbon-components-svelte": "^0.84.0",
"cookie": "^0.6.0",
@ -39,6 +40,7 @@
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@tailwindcss/typography": "^0.5.10",
"@types/bcrypt": "^5.0.2",
"@types/cookie": "^0.6.0",
"@types/jsonwebtoken": "^9.0.6",
"@types/prismjs": "^1.26.3",
@ -58,6 +60,7 @@
"sharp": "^0.33.2",
"svelte": "^4.2.7",
"svelte-check": "^3.6.0",
"sveltekit-rate-limiter": "^0.5.0",
"svgo": "^3.2.0",
"tailwindcss": "^3.3.6",
"tslib": "^2.4.1",
@ -946,6 +949,15 @@
"node": ">=12"
}
},
"node_modules/@isaacs/ttlcache": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz",
"integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==",
"dev": true,
"engines": {
"node": ">=12"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
@ -989,6 +1001,145 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mapbox/node-pre-gyp": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
"integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==",
"dependencies": {
"detect-libc": "^2.0.0",
"https-proxy-agent": "^5.0.0",
"make-dir": "^3.1.0",
"node-fetch": "^2.6.7",
"nopt": "^5.0.0",
"npmlog": "^5.0.1",
"rimraf": "^3.0.2",
"semver": "^7.3.5",
"tar": "^6.1.11"
},
"bin": {
"node-pre-gyp": "bin/node-pre-gyp"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/are-we-there-yet": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz",
"integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==",
"dependencies": {
"delegates": "^1.0.0",
"readable-stream": "^3.6.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
},
"node_modules/@mapbox/node-pre-gyp/node_modules/gauge": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz",
"integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==",
"dependencies": {
"aproba": "^1.0.3 || ^2.0.0",
"color-support": "^1.1.2",
"console-control-strings": "^1.0.0",
"has-unicode": "^2.0.1",
"object-assign": "^4.1.1",
"signal-exit": "^3.0.0",
"string-width": "^4.2.3",
"strip-ansi": "^6.0.1",
"wide-align": "^1.1.2"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/make-dir": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
"integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
"dependencies": {
"semver": "^6.0.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/make-dir/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/npmlog": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
"integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==",
"dependencies": {
"are-we-there-yet": "^2.0.0",
"console-control-strings": "^1.1.0",
"gauge": "^3.0.0",
"set-blocking": "^2.0.0"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="
},
"node_modules/@mapbox/node-pre-gyp/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -1602,6 +1753,15 @@
"node": ">=10.13.0"
}
},
"node_modules/@types/bcrypt": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz",
"integrity": "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
@ -1700,8 +1860,7 @@
"node_modules/abbrev": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
"optional": true
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="
},
"node_modules/accepts": {
"version": "1.3.8",
@ -1731,7 +1890,6 @@
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"optional": true,
"dependencies": {
"debug": "4"
},
@ -1890,8 +2048,7 @@
"node_modules/aproba": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz",
"integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==",
"optional": true
"integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ=="
},
"node_modules/arch": {
"version": "2.2.0",
@ -2359,6 +2516,19 @@
}
]
},
"node_modules/bcrypt": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz",
"integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==",
"hasInstallScript": true,
"dependencies": {
"@mapbox/node-pre-gyp": "^1.0.11",
"node-addon-api": "^5.0.0"
},
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
@ -2367,6 +2537,11 @@
"tweetnacl": "^0.14.3"
}
},
"node_modules/bcrypt/node_modules/node-addon-api": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
"integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA=="
},
"node_modules/better-sqlite3": {
"version": "9.4.3",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.4.3.tgz",
@ -3643,7 +3818,6 @@
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
"integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
"devOptional": true,
"bin": {
"color-support": "bin.js"
}
@ -7143,7 +7317,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"optional": true,
"dependencies": {
"agent-base": "6",
"debug": "4"
@ -8845,7 +9018,6 @@
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"dev": true,
"dependencies": {
"whatwg-url": "^5.0.0"
},
@ -8910,7 +9082,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
"integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
"optional": true,
"dependencies": {
"abbrev": "1"
},
@ -12278,6 +12449,18 @@
}
}
},
"node_modules/sveltekit-rate-limiter": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/sveltekit-rate-limiter/-/sveltekit-rate-limiter-0.5.0.tgz",
"integrity": "sha512-q5RnqgAWP3fhDR+InWdOvsmYZ/x27Qojrk00scy+ob3Q+hawzHmLjQSL6XmSJrx82ceGZlw3G/hKyb/952oGWA==",
"dev": true,
"dependencies": {
"@isaacs/ttlcache": "^1.4.1"
},
"peerDependencies": {
"@sveltejs/kit": "1.x || 2.x"
}
},
"node_modules/sver-compat": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/sver-compat/-/sver-compat-1.5.0.tgz",
@ -12890,8 +13073,7 @@
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"dev": true
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"node_modules/trim-repeated": {
"version": "1.0.0",
@ -14072,14 +14254,12 @@
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"dev": true
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"dev": true,
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"

View file

@ -18,6 +18,7 @@
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@tailwindcss/typography": "^0.5.10",
"@types/bcrypt": "^5.0.2",
"@types/cookie": "^0.6.0",
"@types/jsonwebtoken": "^9.0.6",
"@types/prismjs": "^1.26.3",
@ -37,6 +38,7 @@
"sharp": "^0.33.2",
"svelte": "^4.2.7",
"svelte-check": "^3.6.0",
"sveltekit-rate-limiter": "^0.5.0",
"svgo": "^3.2.0",
"tailwindcss": "^3.3.6",
"tslib": "^2.4.1",
@ -51,6 +53,7 @@
"@benzara/svelte-animated-counter": "^0.0.3",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/enhanced-img": "^0.1.8",
"bcrypt": "^5.1.1",
"better-sqlite3": "^9.4.3",
"carbon-components-svelte": "^0.84.0",
"cookie": "^0.6.0",

View file

@ -23,6 +23,6 @@
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
%sveltekit.body%
<div>%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,78 @@
<script lang="ts">
import { transition } from "$lib/util/animation";
import type { GetUserResponseSuccess } from "$types/api";
import { getContext, onMount } from "svelte";
import type { Writable } from "svelte/store";
import type { PageData } from "../routes/$types";
import cookie from "cookiejs";
export let user: PageData["user"];
const token = getContext<Writable<string>>("token");
let open = false;
function signOut() {
setTimeout(() => {
$token = "";
cookie.remove("token");
}, 330); // 360ms is transition duration, 330ms
// is to prevent GC on chromium. :3c
// hi evan i know ur reading thisss
}
function toggleMenu() {
open = !open;
}
onMount(() => {
function closeMenu(e: MouseEvent) {
if (e.target instanceof HTMLElement) {
if (!e.target.closest(".user-profile-menu")) {
open = false;
}
}
}
window.addEventListener("mousedown", closeMenu);
return () => window.removeEventListener("mousedown", closeMenu);
});
</script>
<button class="user-profile-menu relative ml-3" on:click={toggleMenu}>
<img
style="transition: 240ms transform {transition}"
src={`${user.avatarUrl}`}
alt="{user.username}'s avatar"
class="h-6 w-6 rounded-full"
/>
<div
style="transition: 360ms {transition}"
class={`${open ? "rotate-0 scale-100 opacity-100" : "-rotate-90 scale-0 opacity-0"} absolute right-0 top-full mt-2 flex h-fit origin-top-right transform-gpu flex-col overflow-hidden rounded-[20px] rounded-tr-none border-2 border-solid border-[#ffffff34] bg-[#110d10] p-[2px] opacity-0 shadow-lg shadow-[rgba(0,0,0,0.25)] motion-reduce:transition-none [&>.nav-btn:first-child]:rounded-tl-[16px] [&>.nav-btn:first-child]:rounded-tr-none [&>.nav-btn:last-child]:rounded-bl-[16px] [&>.nav-btn:last-child]:rounded-br-[16px]`}
>
<div
role="button"
class="nav-btn flex items-center whitespace-nowrap hover:bg-[#1d1d1d] [&>*]:w-full [&>*]:px-4 [&>*]:py-2 [&>*]:text-left"
>
<a href="/account">Multiplayer</a>
</div>
<div
role="separator"
class="-ml-[2px] mb-[2px] mt-[2px] h-[2px] w-[calc(100%+4px)] bg-[#423e41]"
/>
<div
role="button"
class="nav-btn flex items-center whitespace-nowrap hover:bg-[#1d1d1d] [&>*]:w-full [&>*]:px-4 [&>*]:py-2 [&>*]:text-left"
>
<button on:click={signOut}>Sign out</button>
</div>
</div>
</button>
<style>
.user-profile-menu > img:hover {
transform: scale(1.17) rotate(7deg);
}
.user-profile-menu > img:active {
transform: scale(0.85) rotate(0deg);
}
</style>

View file

@ -0,0 +1,101 @@
<script lang="ts">
import { onMount, tick } from "svelte";
export let items: { name: string; value: string }[] = [];
export let selected: (typeof items)[0] = items[0];
let selectedIndex = 0;
$: selected = items[selectedIndex];
let expanded = false;
let navItems: HTMLUListElement;
function recalculatePos() {
const { right } = navItems.getBoundingClientRect();
console.log(right, window.innerWidth);
if (right > window.innerWidth) {
navItems.style.left = `${window.innerWidth - right - 36}px`;
} else {
navItems.style.left = "0";
}
}
async function toggle() {
expanded = !expanded;
await tick();
// do we have enough space to the right of the navItems?
recalculatePos();
}
onMount(() => {
function close(e: MouseEvent | UIEvent) {
if ("clientX" in e) {
// check if we're clicking outside the dropdown
if (
e.target instanceof HTMLElement &&
!e.target.classList.contains("dropdown") &&
!e.target.closest(".dropdown")
) {
expanded = false;
}
} else {
expanded = false;
}
}
window.addEventListener("mousedown", close);
window.addEventListener("resize", close);
return () => {
window.removeEventListener("click", close);
window.removeEventListener("resize", close);
};
});
</script>
<div class="relative flex">
<button
class="flex w-full items-center justify-between rounded-2xl bg-zinc-950 p-2 ring ring-[#ffffff11] focus:ring-[#ffffff44]"
aria-haspopup="listbox"
aria-expanded={expanded}
on:click={toggle}
type="button"
>
<span class="ml-1 mr-4">{selected.name}</span>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"
></path>
</svg>
</button>
<ul
bind:this={navItems}
class="absolute {expanded
? 'block'
: 'hidden'} top-full z-[9999] mt-2 max-h-[50vh] w-fit overflow-y-auto overflow-x-hidden rounded-2xl bg-zinc-950 ring ring-[#ffffff11] focus:ring-[#ffffff44]"
role="listbox"
aria-hidden="true"
>
{#each items as item, i}
<button
role="option"
aria-selected={selectedIndex === i}
on:click={() => {
selectedIndex = i;
toggle();
}}
class="w-full cursor-pointer whitespace-nowrap px-4 py-3 text-left hover:bg-zinc-900"
>
{item.name}
</button>
{/each}
</ul>
</div>
<style>
[aria-expanded="true"] > svg {
transform: rotate(180deg);
}
</style>

View file

@ -18,21 +18,26 @@
); mask-image: var(--mask-image); -webkit-mask-image: var(--mask-image);
"
/>
<div class="relative z-30 flex items-stretch gap-6">
<div class="relative z-30 flex min-h-[0] min-w-[0] items-center gap-6">
{#if room.game?.iconUrl}
<img
src={room.game.iconUrl}
alt="Icon for '{room.preferredGameName}'"
class="w-[100px] rounded-2xl object-cover"
class="flex aspect-square max-h-[84px] max-w-[84px] shrink-0 rounded-2xl object-cover md:max-h-[148px] md:max-w-[148px]"
/>
{/if}
<div class="flex flex-col">
<h2 class="mb-2 text-[20px] leading-[1.41] md:text-[28px] md:leading-[1.1]">
{room.name}
<span class="ml-1 text-base font-normal text-gray-300"
<div class="flex h-full w-full flex-col overflow-hidden">
<div class="flex items-center">
<h2
class="mb-2 overflow-hidden text-ellipsis whitespace-nowrap text-[20px] leading-[1.41] md:text-[28px] md:leading-[1.1]"
>
{room.name}
</h2>
<span
class="mb-[6px] ml-2 overflow-hidden text-ellipsis whitespace-nowrap text-base font-normal text-gray-300"
>({room.game?.name || "No preferred game"})</span
>
</h2>
</div>
<p class="flex-grow">{room.description}</p>
<div class="mt-2 text-sm text-gray-300">
{room.players.length} / {room.maxPlayers} | {#if room.players.length > 4}

View file

@ -39,10 +39,6 @@ async function setupGames() {
const runAllTheInitFunctions = async () => {
if (!db.isInitialized) await db.initialize();
// sigh.
const user = await userRepo.findOne({ where: { username: "nullptr" } });
user!.roles = ["moderator"];
await userRepo.save(user!);
if (!server)
try {
initServer();

View file

@ -1,10 +1,20 @@
import type { CreateAccountRequest, CreateAccountResponse, GetUserResponse } from "$types/api";
import type {
CreateAccountRequest,
CreateAccountResponse,
GetUserResponse,
LoginRequest,
LoginResponse,
} from "$types/api";
const apiUsers = {
async createAccount(body: CreateAccountRequest): Promise<CreateAccountResponse> {
return await SuyuAPI.req("POST", "/api/user", body);
},
async login(body: LoginRequest): Promise<LoginResponse> {
return await SuyuAPI.req("POST", "/api/user/login", body);
},
async deleteAccount() {
return await SuyuAPI.req("DELETE", "/api/user");
},

View file

@ -1,4 +1,5 @@
import { db } from "../db";
import { SuyuUser } from "../schema";
import { FriendshipRequest, SuyuUser } from "../schema";
export const userRepo = db.getRepository(SuyuUser);
export const friendshipRepo = db.getRepository(FriendshipRequest);

View file

@ -1,5 +1,5 @@
import type { Role } from "$types/db";
import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm";
import { BaseEntity, Column, Entity, ManyToMany, OneToOne, PrimaryGeneratedColumn } from "typeorm";
@Entity()
export class SuyuUser extends BaseEntity {
@ -27,4 +27,23 @@ export class SuyuUser extends BaseEntity {
select: false,
})
email: string;
@Column("text", {
select: false,
})
password: string;
@ManyToMany(() => SuyuUser)
friends: SuyuUser[];
}
export class FriendshipRequest extends BaseEntity {
@PrimaryGeneratedColumn("uuid")
id: string;
@OneToOne(() => SuyuUser)
from: SuyuUser;
@OneToOne(() => SuyuUser)
to: SuyuUser;
}

View file

@ -19,3 +19,23 @@ export async function getJwtData(token: string): Promise<IJwtData> {
});
});
}
export class RateLimiter {
// allow 5 requests per minute
cache = new Map<string, number>();
constructor() {}
isLimited(ip: string): boolean {
// if the last request was in the last minute, return true
if (this.cache.has(ip)) {
if (Date.now() - this.cache.get(ip)! < 5000) {
return true;
}
}
// set the last request to now
this.cache.set(ip, Date.now());
return false;
}
}

View file

@ -1,7 +1,11 @@
export function load({ cookies, url }) {
import { useAuth } from "$lib/util/api/index.js";
export async function load({ cookies, url }) {
const token = cookies.get("token");
const user = await useAuth(token || "");
return {
tokenCookie: token,
url: url.pathname,
user: { ...user },
};
}

View file

@ -12,6 +12,7 @@
import { generateTransition, transition } from "$lib/util/animation";
import { reducedMotion } from "$lib/accessibility";
import BackgroundProvider from "$components/BackgroundProvider.svelte";
import AccountButton from "$components/AccountButton.svelte";
export let data: PageData;
@ -146,7 +147,7 @@
},
{
name: "GitLab",
href: "https://gitlab.com/suyu-emu/",
href: "https://gitlab.com/suyu-emu/suyu",
},
{
name: $token || data.tokenCookie ? "Account" : "Sign up",
@ -286,9 +287,16 @@
>
<DiscordSolid />
</a>
<a href={$token ? "/account" : "/signup"} class="button-sm"
{#if $token}
<!-- <a href={$token ? "/account" : "/signup"} class="button-sm"
>{$token ? "Account" : "Sign up"}</a
>
> -->
<!-- <a href="/account" class="button-sm">Account</a> -->
<AccountButton user={data.user} />
{:else}
<a href="/login" class="button-sm">Log in</a>
<a href="/signup" class="button-sm">Sign up</a>
{/if}
</div>
<div class="relative mr-4 hidden flex-row gap-4 max-[625px]:flex">
<button
@ -319,7 +327,7 @@
<div
style="transition: 180ms ease;"
aria-hidden={!dropdownOpenFinished && !dropdownOpen}
class={`fixed left-0 z-[99999] h-screen w-full bg-[#0e0d10] p-9 pt-[120px] ${dropdownOpen ? "pointer-events-auto visible opacity-100" : "pointer-events-none opacity-0"} ${!dropdownOpen && dropdownCloseFinished ? "invisible" : ""}`}
class={`fixed left-0 z-[100] h-screen w-full bg-[#0e0d10] p-9 pt-[120px] ${dropdownOpen ? "pointer-events-auto visible opacity-100" : "pointer-events-none opacity-0"} ${!dropdownOpen && dropdownCloseFinished ? "invisible" : ""}`}
>
<div class={`flex flex-col gap-8`}>
<!-- <a href="##"><h1 class="w-full text-5xl">Blog</h1></a>

View file

@ -117,7 +117,7 @@
</Dialog.Portal>
</Dialog.Root>
<a
href="https://gitlab.com/suyu-emu/"
href="https://gitlab.com/suyu-emu/suyu"
target="_blank"
rel="noreferrer noopener"
class="button text-[#8A8F98]"
@ -259,7 +259,7 @@
</svg>
</a>
<a
href="https://gitlab.com/suyu-emu/"
href="https://gitlab.com/suyu-emu/suyu"
target="_blank"
rel="noreferrer noopener"
class="relative w-full rounded-[2.25rem] bg-[#f78c40] p-12 text-black"

View file

@ -26,8 +26,8 @@
href: "/account",
},
{
name: "Lobbies",
href: "/account/lobbies",
name: "Public Game Lobby",
href: "/account/lobby",
},
// {
// name: "Friends",
@ -61,6 +61,7 @@
return;
indicator.offsetHeight;
const transformFactor = bounds.left - pillBounds.left;
navBar.animate(
[
{
@ -77,17 +78,20 @@
easing: "ease-in",
},
],
{
duration: 360,
delay: 0,
},
$reducedMotion
? {
duration: 360,
delay: 0,
}
: {
duration: 0,
},
);
}
afterNavigate(({ from }) => {
if (from) {
if (!from.url.pathname.startsWith("/account")) {
console.log("!");
navBar.style.opacity = "0";
navBar.animate(
[
@ -164,6 +168,14 @@
};
}
});
onMount(() => {
setTimeout(() => {
const items = Array.from(document.querySelectorAll(".navitem")) as HTMLAnchorElement[];
const item = items.find((i) => new URL(i.href).pathname === data.url);
navClick({ target: item } as unknown as MouseEvent);
}, 10);
});
</script>
{#key data.url}
@ -196,3 +208,12 @@
<slot />
</div>
</div>
<style>
@media (max-width: 750px) {
.navbar {
margin-right: 0;
margin-left: 0;
}
}
</style>

View file

@ -4,6 +4,7 @@
import { getContext } from "svelte";
import type { PageData } from "./$types";
import type { Writable } from "svelte/store";
import cookie from "cookiejs";
const token = getContext<Writable<string>>("token");
@ -28,6 +29,12 @@
copyText = "Copy token";
}, 2000);
}
function signOut() {
$token = "";
cookie.remove("token");
goto("/login");
}
</script>
<div class="relative h-[calc(100vh-200px)] flex-col gap-6 overflow-hidden">

View file

@ -1,74 +0,0 @@
<script lang="ts">
import Room from "$components/Room.svelte";
import { reducedMotion } from "$lib/accessibility";
import { transition } from "$lib/util/animation";
import { onMount } from "svelte";
import type { PageData } from "./$types";
export let data: PageData;
function transitionIn() {
const rooms = document.querySelectorAll<HTMLDivElement>(".room");
rooms.forEach((room, i) => {
const x = parseInt(room.dataset.index!);
room.getAnimations().forEach((animation) => animation.cancel());
room.style.zIndex = ((i + 1) * 5).toString();
room.animate(
[
{
transform: "translateY(-200px)",
opacity: "0",
filter: "blur(20px)",
},
{
transform: "translateY(0px)",
opacity: "1",
filter: "blur(0px)",
},
],
$reducedMotion
? {
duration: 0,
fill: "forwards",
}
: {
duration: 700,
easing: transition,
delay: x * 80,
fill: "forwards",
},
).onfinish = () => {
room.style.opacity = "1";
};
});
}
onMount(() => {
transitionIn();
});
</script>
<div class="relative h-[calc(100vh-200px)]">
<div class="room-grid relative flex w-full gap-4 pb-6">
{#each data.rooms as room, i}
<div class="room opacity-0" data-index={i}>
<Room {room} />
</div>
{/each}
</div>
</div>
<style>
.room-grid {
display: grid;
grid-template-columns: repeat(1, 1fr);
grid-auto-rows: auto;
align-items: stretch;
}
@media (min-width: 750px) {
.room-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View file

@ -0,0 +1,162 @@
<script lang="ts">
import Room from "$components/Room.svelte";
import { reducedMotion } from "$lib/accessibility";
import { transition } from "$lib/util/animation";
import { onMount, tick } from "svelte";
import type { PageData } from "./$types";
import Dropdown from "$components/Dropdown.svelte";
import { browser } from "$app/environment";
export let data: PageData;
function transitionIn() {
const rooms = document.querySelectorAll<HTMLDivElement>(".room");
rooms.forEach((room, i) => {
const x = parseInt(room.dataset.index!);
room.getAnimations().forEach((animation) => animation.cancel());
room.style.zIndex = ((i + 1) * 5).toString();
room.animate(
[
{
transform: "translateY(-200px)",
opacity: "0",
filter: "blur(20px)",
},
{
transform: "translateY(0px)",
opacity: "1",
filter: "blur(0px)",
},
],
$reducedMotion
? {
duration: 0,
fill: "forwards",
}
: {
duration: 700,
easing: transition,
delay: x * 80,
fill: "forwards",
},
).onfinish = () => {
room.style.opacity = "1";
};
});
}
onMount(() => {
transitionIn();
});
let filter: {
name: string;
value: string;
} = { name: "", value: "" };
let extendedContainer: HTMLDivElement;
$: gamesFilters = [
{
name: "All",
value: "",
},
...data.rooms
.map((room) => room.game)
.filter((game) => typeof game !== "undefined" && Boolean(game))
.map((game) => ({
name: game!.name,
value: game!.name,
}))
.filter((game, i, arr) => arr.findIndex((g) => g.value === game.value) === i),
];
$: {
if (browser) {
filter;
(async () => {
await tick(); // wait for dom update :333
transitionIn();
})();
}
}
onMount(() => {
if (extendedContainer.style.opacity === "1") return;
extendedContainer.animate(
[
{
opacity: "0",
},
{
opacity: "1",
},
],
$reducedMotion
? {
duration: 0,
fill: "forwards",
}
: {
duration: 400,
easing: transition,
fill: "forwards",
delay: 150,
},
);
});
$: rooms =
filter.value !== "" ? data.rooms.filter((r) => r.game?.name === filter.value) : data.rooms;
</script>
<div
bind:this={extendedContainer}
class="pointer-events-none absolute -top-[60px] left-[50%] z-[999] flex h-11 w-full translate-x-[-50%] items-center opacity-0"
>
<div class="dropdown pointer-events-auto">
<Dropdown items={gamesFilters} bind:selected={filter} />
</div>
</div>
<div class="relative h-[calc(100vh-200px)]">
{#if rooms.length > 0}
<div class="room-grid relative flex w-full gap-4 pb-6">
{#each rooms as room, i}
<div class="room min-h-0 min-w-0 overflow-hidden opacity-0" data-index={i}>
<Room {room} />
</div>
{/each}
</div>
{:else}
<i class="mt-4 block w-full text-center text-gray-500">
{filter.value ? "No rooms matched your filter" : "No rooms are currently open"}...
</i>
{/if}
</div>
<style>
.room-grid {
display: grid;
grid-template-columns: repeat(1, 1fr);
grid-auto-rows: auto;
align-items: stretch;
min-width: 0;
min-height: 0;
}
@media (min-width: 750px) {
.room-grid {
grid-template-columns: repeat(2, 1fr);
}
.dropdown {
margin-left: 0 !important;
margin-right: 0 !important;
}
}
.dropdown {
margin-left: auto;
margin-right: 0;
}
</style>

View file

@ -2,7 +2,7 @@
import { userRepo } from "$lib/server/repo";
import type { SuyuUser } from "$lib/server/schema";
import { json } from "$lib/server/util";
import { RateLimiter, json } from "$lib/server/util";
import { useAuth } from "$lib/util/api";
import type {
CreateAccountRequest,
@ -16,6 +16,9 @@ import { verify } from "hcaptcha";
import { PUBLIC_SITE_KEY } from "$env/static/public";
import { HCAPTCHA_KEY } from "$env/static/private";
import validator from "validator";
import bcrypt from "bcrypt";
const rateLimit = new RateLimiter();
const randomBytes = promisify(crypto.randomBytes);
@ -32,8 +35,14 @@ async function genKey(username: string) {
}
export async function POST({ request, getClientAddress }) {
if (rateLimit.isLimited(getClientAddress())) {
return json<CreateAccountResponse>({
success: false,
error: "rate limited",
});
}
const body: CreateAccountRequest = await request.json();
if (!body.username || !body.email || !body.captchaToken) {
if (!body.username || !body.email || !body.captchaToken || !body.password) {
return json<CreateAccountResponse>({
success: false,
error: "missing fields",
@ -77,14 +86,22 @@ export async function POST({ request, getClientAddress }) {
}
// the api key can only be 80 characters total, including the username and colon
const key = await genKey(body.username);
const password = await bcrypt.hash(body.password, 10);
// sha256 hash of the email, trimmed and to lowercase
const emailHash = crypto
.createHash("sha256")
.update(body.email.trim().toLowerCase())
.digest("hex");
const createdUser: SuyuUser = userRepo.create({
username: body.username,
avatarUrl: `https://avatars.githubusercontent.com/u/${Math.floor(Math.random() * 100000000)}?v=4`,
avatarUrl: `https://gravatar.com/avatar/${emailHash}?d=retro`,
displayName: body.username,
roles: ["user"],
apiKey: key,
email: body.email,
password,
});
console.log(createdUser);
await userRepo.save(createdUser);
return json<CreateAccountResponse>({
success: true,
@ -93,7 +110,12 @@ export async function POST({ request, getClientAddress }) {
});
}
export async function GET({ request }) {
export async function GET({ request, getClientAddress }) {
if (rateLimit.isLimited(getClientAddress()))
return json<GetUserResponse>({
success: false,
error: "rate limited",
});
const user = await useAuth(request);
if (!user) {
return json<GetUserResponse>({
@ -107,7 +129,12 @@ export async function GET({ request }) {
});
}
export async function DELETE({ request }) {
export async function DELETE({ request, getClientAddress }) {
if (rateLimit.isLimited(getClientAddress()))
return json<DeleteAccountResponse>({
success: false,
error: "rate limited",
});
const user = await useAuth(request);
if (!user) {
return json<DeleteAccountResponse>({

View file

@ -0,0 +1,48 @@
import { userRepo } from "$lib/server/repo";
import { RateLimiter, json } from "$lib/server/util/index.js";
import type { LoginResponse, LoginRequest } from "$types/api";
import bcrypt from "bcrypt";
const rateLimit = new RateLimiter();
export async function POST({ request, getClientAddress }) {
if (rateLimit.isLimited(getClientAddress()))
return json<LoginResponse>({
success: false,
error: "rate limited",
});
const body: LoginRequest = await request.json();
if (
!body.email ||
!body.password ||
body.email.trim() === "" ||
body.password.trim() === "" ||
body.email.length > 320 ||
body.password.length > 320
)
return json<LoginResponse>({
success: false,
error: "missing fields",
});
const user = await userRepo.findOne({
where: {
email: body.email,
},
select: ["password", "apiKey"],
});
if (!user)
return json<LoginResponse>({
success: false,
error: "user not found",
});
if (!(await bcrypt.compare(body.password, user.password))) {
return json<LoginResponse>({
success: false,
error: "invalid password",
});
}
return json<LoginResponse>({
success: true,
token: user.apiKey,
});
}

View file

@ -3,7 +3,7 @@
</svelte:head>
<div
class="relative flex w-full flex-col gap-6 overflow-hidden rounded-[2.25rem] bg-[#110d10] md:p-12"
class="relative flex w-full flex-col gap-6 overflow-hidden rounded-[2.25rem] bg-[#110d10] p-8 md:p-12"
>
<svg
xmlns="http://www.w3.org/2000/svg"

View file

@ -0,0 +1,12 @@
import { RoomManager } from "$lib/server/class/Room.js";
import { useAuth } from "$lib/util/api";
export async function load(opts) {
const apiKey = opts.cookies.get("token");
const user = await useAuth(apiKey || "unused");
return {
user: { ...user },
token: apiKey,
};
}

View file

@ -0,0 +1,73 @@
<script lang="ts">
import { browser } from "$app/environment";
import { goto } from "$app/navigation";
import HCaptcha from "$components/HCaptcha.svelte";
import { PUBLIC_SITE_KEY } from "$env/static/public";
import { SuyuAPI } from "$lib/client/api";
import type { PageData } from "./$types";
import type { Writable } from "svelte/store";
import { getContext, onMount } from "svelte";
const token = getContext<Writable<string>>("token");
if ($token) goto("/account");
let emailInput = "";
let passwordInput = "";
$: disabled = !emailInput || !passwordInput;
export let data: PageData;
if (Object.keys(data.user).length !== 0 && browser) goto("/account");
async function logIn() {
const res = await SuyuAPI.users.login({
email: emailInput,
password: passwordInput,
});
if (!res.success) {
// TODO: modal
alert(res.error);
return;
}
// set "token" cookie
document.cookie = `token=${res.token}; path=/; max-age=31536000; samesite=strict`;
$token = res.token;
goto("/account");
}
function enter(e: KeyboardEvent) {
if (e.key === "Enter") logIn();
}
</script>
<div
class="align-center relative flex h-[calc(100vh-200px)] flex-col items-center justify-center gap-6"
>
<div class="flex h-fit w-full max-w-[500px] flex-col rounded-[2.25rem] bg-[#110d10] p-10">
<h1 class="text-[48px] md:text-[60px] md:leading-[1.1]">Log in</h1>
<div class="mt-4 flex flex-col gap-4">
<p>
Lost your account? <a class="link" href="https://discord.gg/suyu" target="_blank"
>Contact us</a
>.
</p>
<input
bind:value={emailInput}
maxlength="128"
class="input"
type="email"
autocomplete="email"
placeholder="Email"
/>
<input
autocomplete="current-password"
bind:value={passwordInput}
class="input"
type="password"
placeholder="Password"
on:keydown={enter}
/>
<button {disabled} on:click={logIn} class="cta-button mt-2">Log in</button>
</div>
</div>
</div>

View file

@ -4,10 +4,8 @@ import { useAuth } from "$lib/util/api";
export async function load(opts) {
const apiKey = opts.cookies.get("token");
const user = await useAuth(apiKey || "unused");
const rooms = RoomManager.getRooms().map((r) => r.toJSON());
return {
user: { ...user },
rooms,
token: apiKey,
};
}

View file

@ -13,8 +13,9 @@
let usernameInput = "";
let emailInput = "";
let passwordInput = "";
let captchaToken = "";
$: disabled = !usernameInput || !emailInput || !captchaToken;
$: disabled = !usernameInput || !emailInput || !captchaToken || !passwordInput;
export let data: PageData;
@ -25,6 +26,7 @@
username: usernameInput,
email: emailInput,
captchaToken,
password: passwordInput,
});
if (!res.success) {
// TODO: modal
@ -48,37 +50,48 @@
<div class="flex h-fit w-full max-w-[500px] flex-col rounded-[2.25rem] bg-[#110d10] p-10">
<h1 class="text-[48px] md:text-[60px] md:leading-[1.1]">Sign up</h1>
<div class="mt-4 flex flex-col gap-4">
<p class="useless-text">
suyu believes in user privacy; as such, usernames are distributed on a first-come,
first-serve basis, with no password required. Accounts are used for:
</p>
<p class="useless-text">Accounts are used for:</p>
<ul class="list [&>*]:before:mr-3 [&>*]:before:content-['•']">
<li>Creating rooms</li>
<li>Adding friends</li>
</ul>
<p>
Lost your account? <a class="link" href="https://discord.gg/suyu" target="_blank"
>Contact us</a
>.
</p>
<input
bind:value={emailInput}
maxlength="128"
class="input"
type="text"
placeholder="Recovery Email"
/>
<input
bind:value={usernameInput}
maxlength="24"
class="input"
type="text"
placeholder="Username"
/>
<div class="h-[78px]">
<HCaptcha on:success={captchaComplete} theme="dark" sitekey={PUBLIC_SITE_KEY} />
</div>
<button {disabled} on:click={signUp} class="cta-button mt-2">Sign up</button>
<form
class="contents"
on:submit={(e) => {
e.preventDefault();
signUp();
}}
>
<input
bind:value={emailInput}
maxlength="128"
class="input"
type="text"
placeholder="Email"
autocomplete="email"
/>
<input
bind:value={usernameInput}
maxlength="24"
class="input"
type="text"
autocomplete="off"
placeholder="Username"
/>
<input
bind:value={passwordInput}
class="input"
type="password"
placeholder="Password"
autocomplete="new-password"
/>
<div class="h-[78px]">
<HCaptcha on:success={captchaComplete} theme="dark" sitekey={PUBLIC_SITE_KEY} />
</div>
<button {disabled} type="submit" on:click={signUp} class="cta-button mt-2"
>Sign up</button
>
</form>
</div>
</div>
</div>

13
src/types/api.d.ts vendored
View file

@ -11,6 +11,7 @@ export interface CreateAccountRequest {
username: string;
email: string;
captchaToken: string;
password: string;
}
export interface CreateAccountResponseSuccess {
@ -32,3 +33,15 @@ export interface GetUserResponseSuccess {
}
export type GetUserResponse = GetUserResponseSuccess | GenericFailureResponse;
export interface LoginRequest {
email: string;
password: string;
}
export interface LoginResponseSuccess {
success: true;
token: string;
}
export type LoginResponse = LoginResponseSuccess | GenericFailureResponse;