mirror of
https://git.suyu.dev/suyu/website.git
synced 2025-12-27 09:44:55 +01:00
feat: new dropdown, friendship stuffz, passwords :3c
Co-authored-by: Evan Song <ferothefox@users.noreply.github.com>
This commit is contained in:
parent
23c20112c9
commit
7e49d2dc92
27 changed files with 878 additions and 153 deletions
|
|
@ -23,6 +23,6 @@
|
|||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
%sveltekit.body%
|
||||
<div>%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
78
src/components/AccountButton.svelte
Normal file
78
src/components/AccountButton.svelte
Normal 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>
|
||||
101
src/components/Dropdown.svelte
Normal file
101
src/components/Dropdown.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
162
src/routes/account/lobby/+page.svelte
Normal file
162
src/routes/account/lobby/+page.svelte
Normal 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>
|
||||
|
|
@ -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>({
|
||||
|
|
|
|||
48
src/routes/api/user/login/+server.ts
Normal file
48
src/routes/api/user/login/+server.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
12
src/routes/login/+page.server.ts
Normal file
12
src/routes/login/+page.server.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
73
src/routes/login/+page.svelte
Normal file
73
src/routes/login/+page.svelte
Normal 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>
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
13
src/types/api.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue