add new sveltekit frontend
This commit is contained in:
commit
fc4334932a
40 changed files with 3802 additions and 0 deletions
3
frontend/src/app.css
Normal file
3
frontend/src/app.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
12
frontend/src/app.d.ts
vendored
Normal file
12
frontend/src/app.d.ts
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
12
frontend/src/app.html
Normal file
12
frontend/src/app.html
Normal file
|
@ -0,0 +1,12 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
99
frontend/src/lib/api/entities.ts
Normal file
99
frontend/src/lib/api/entities.ts
Normal file
|
@ -0,0 +1,99 @@
|
|||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string | null;
|
||||
bio: string | null;
|
||||
avatar_urls: string[] | null;
|
||||
links: string[] | null;
|
||||
|
||||
names: FieldEntry[];
|
||||
pronouns: Pronoun[];
|
||||
members: PartialMember[];
|
||||
fields: Field[];
|
||||
}
|
||||
|
||||
export interface MeUser extends User {
|
||||
discord: string | null;
|
||||
discord_username: string | null;
|
||||
}
|
||||
|
||||
export interface Field {
|
||||
name: string;
|
||||
entries: FieldEntry[];
|
||||
}
|
||||
|
||||
export interface FieldEntry {
|
||||
value: string;
|
||||
status: WordStatus;
|
||||
}
|
||||
|
||||
export interface Pronoun {
|
||||
pronouns: string;
|
||||
display_text: string | null;
|
||||
status: WordStatus;
|
||||
}
|
||||
|
||||
export enum WordStatus {
|
||||
Unknown = 0,
|
||||
Favourite = 1,
|
||||
Okay = 2,
|
||||
Jokingly = 3,
|
||||
FriendsOnly = 4,
|
||||
Avoid = 5,
|
||||
}
|
||||
|
||||
export interface PartialMember {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string | null;
|
||||
avatar_urls: string[] | null;
|
||||
}
|
||||
|
||||
export interface Member extends PartialMember {
|
||||
bio: string | null;
|
||||
links: string | null;
|
||||
names: FieldEntry[];
|
||||
pronouns: Pronoun[];
|
||||
fields: Field[];
|
||||
user: MemberPartialUser;
|
||||
}
|
||||
|
||||
export interface MemberPartialUser {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string | null;
|
||||
avatar_urls: string[] | null;
|
||||
}
|
||||
|
||||
export interface APIError {
|
||||
code: ErrorCode;
|
||||
message?: string;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
export enum ErrorCode {
|
||||
BadRequest = 400,
|
||||
Forbidden = 403,
|
||||
NotFound = 404,
|
||||
MethodNotAllowed = 405,
|
||||
TooManyRequests = 429,
|
||||
InternalServerError = 500,
|
||||
|
||||
InvalidState = 1001,
|
||||
InvalidOAuthCode = 1002,
|
||||
InvalidToken = 1003,
|
||||
InviteRequired = 1004,
|
||||
InvalidTicket = 1005,
|
||||
InvalidUsername = 1006,
|
||||
UsernameTaken = 1007,
|
||||
InvitesDisabled = 1008,
|
||||
InviteLimitReached = 1009,
|
||||
InviteAlreadyUsed = 1010,
|
||||
|
||||
UserNotFound = 2001,
|
||||
|
||||
MemberNotFound = 3001,
|
||||
MemberLimitReached = 3002,
|
||||
|
||||
RequestTooBig = 4001,
|
||||
}
|
24
frontend/src/lib/api/fetch.ts
Normal file
24
frontend/src/lib/api/fetch.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import type { APIError } from "./entities";
|
||||
import { PUBLIC_BASE_URL } from "$env/static/public";
|
||||
|
||||
export async function apiFetch<T>(
|
||||
path: string,
|
||||
{ method, body, token }: { method?: string; body?: any; token?: string },
|
||||
) {
|
||||
|
||||
const resp = await fetch(`${PUBLIC_BASE_URL}/api/v1${path}`, {
|
||||
method: method || "GET",
|
||||
headers: {
|
||||
...(token ? { Authorization: token } : {}),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: body ? JSON.stringify(body) : null,
|
||||
});
|
||||
|
||||
const data = await resp.json();
|
||||
if (resp.status < 200 || resp.status >= 300) throw data as APIError;
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export const apiFetchClient = async <T>(path: string, method: string = "GET", body: any = null) =>
|
||||
apiFetch<T>(path, { method, body, token: localStorage.getItem("pronouns-token") || undefined });
|
28
frontend/src/lib/components/FallbackImage.svelte
Normal file
28
frontend/src/lib/components/FallbackImage.svelte
Normal file
|
@ -0,0 +1,28 @@
|
|||
<script lang="ts">
|
||||
export let urls: string[] | null;
|
||||
export let alt: string;
|
||||
|
||||
const contentTypeFor = (url: string) => {
|
||||
if (url.endsWith(".webp")) {
|
||||
return "image/webp";
|
||||
} else if (url.endsWith(".jpg") || url.endsWith(".jpeg")) {
|
||||
return "image/jpeg";
|
||||
} else if (url.endsWith(".png")) {
|
||||
return "image/png";
|
||||
} else {
|
||||
return "application/octet-stream";
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if urls}
|
||||
<picture class="rounded-circle img-fluid">
|
||||
{#each urls as url}
|
||||
<source width=300 srcSet={url} type={contentTypeFor(url)} />
|
||||
{/each}
|
||||
<img width=300 src={urls[0]} {alt} class="rounded-circle img-fluid" />
|
||||
</picture>
|
||||
{:else}
|
||||
<!-- TODO: actual placeholder that isn't a cat -->
|
||||
<img width=300 class="rounded-circle img-fluid" src="https://placekitten.com/512/512" {alt} />
|
||||
{/if}
|
18
frontend/src/lib/components/FieldCard.svelte
Normal file
18
frontend/src/lib/components/FieldCard.svelte
Normal file
|
@ -0,0 +1,18 @@
|
|||
<script lang="ts">
|
||||
import { Card, CardHeader, CardTitle, ListGroup, ListGroupItem } from "sveltestrap";
|
||||
|
||||
import type { Field } from "$lib/api/entities";
|
||||
|
||||
import StatusIcon from "./StatusIcon.svelte";
|
||||
|
||||
export let field: Field;
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<h5>{field.name}</h5>
|
||||
<ul class="list-unstyled">
|
||||
{#each field.entries as entry}
|
||||
<li><StatusIcon status={entry.status} /> {entry.value}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
15
frontend/src/lib/components/PartialMemberCard.svelte
Normal file
15
frontend/src/lib/components/PartialMemberCard.svelte
Normal file
|
@ -0,0 +1,15 @@
|
|||
<script lang="ts">
|
||||
import type { PartialMember, User } from "$lib/api/entities";
|
||||
import FallbackImage from "./FallbackImage.svelte";
|
||||
|
||||
export let user: User;
|
||||
export let member: PartialMember;
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<FallbackImage
|
||||
urls={member.avatar_urls}
|
||||
alt="Avatar for {member.name}"
|
||||
/>
|
||||
<a class="text-reset" href="/@{user.name}/{member.name}"><h5 class="m-2">{member.display_name ?? member.name}</h5></a>
|
||||
</div>
|
16
frontend/src/lib/components/PronounLink.svelte
Normal file
16
frontend/src/lib/components/PronounLink.svelte
Normal file
|
@ -0,0 +1,16 @@
|
|||
<script lang="ts">
|
||||
import type { Pronoun } from "$lib/api/entities";
|
||||
|
||||
export let pronouns: Pronoun;
|
||||
|
||||
let pronounText: string;
|
||||
if (pronouns.display_text) {
|
||||
pronounText = pronouns.display_text;
|
||||
} else {
|
||||
const split = pronouns.pronouns.split("/");
|
||||
if (split.length < 2) pronounText = split.join("/");
|
||||
else pronounText = split.slice(0, 2).join("/")
|
||||
}
|
||||
</script>
|
||||
|
||||
<a href="/pronouns/{pronouns.pronouns}">{pronounText}</a>
|
52
frontend/src/lib/components/StatusIcon.svelte
Normal file
52
frontend/src/lib/components/StatusIcon.svelte
Normal file
|
@ -0,0 +1,52 @@
|
|||
<script lang="ts">
|
||||
import { Icon, Tooltip } from "sveltestrap";
|
||||
|
||||
import { WordStatus } from "$lib/api/entities";
|
||||
|
||||
export let status: WordStatus;
|
||||
|
||||
const iconFor = (wordStatus: WordStatus) => {
|
||||
switch (wordStatus) {
|
||||
case WordStatus.Favourite:
|
||||
return "heart-fill";
|
||||
case WordStatus.Okay:
|
||||
return "hand-thumbs-up";
|
||||
case WordStatus.Jokingly:
|
||||
return "emoji-laughing";
|
||||
case WordStatus.FriendsOnly:
|
||||
return "people";
|
||||
case WordStatus.Avoid:
|
||||
return "hand-thumbs-down";
|
||||
default:
|
||||
return "hand-thumbs-up";
|
||||
}
|
||||
};
|
||||
|
||||
const textFor = (wordStatus: WordStatus) => {
|
||||
switch (wordStatus) {
|
||||
case WordStatus.Favourite:
|
||||
return "Favourite";
|
||||
case WordStatus.Okay:
|
||||
return "Okay";
|
||||
case WordStatus.Jokingly:
|
||||
return "Jokingly";
|
||||
case WordStatus.FriendsOnly:
|
||||
return "Friends only";
|
||||
case WordStatus.Avoid:
|
||||
return "Avoid";
|
||||
default:
|
||||
return "Okay";
|
||||
}
|
||||
};
|
||||
|
||||
let statusIcon: string;
|
||||
$: statusIcon = iconFor(status);
|
||||
|
||||
let statusText: string;
|
||||
$: statusText = textFor(status);
|
||||
|
||||
let iconElement: HTMLElement;
|
||||
</script>
|
||||
|
||||
<span bind:this={iconElement} tabindex={0}><Icon name={statusIcon} /></span>
|
||||
<Tooltip target={iconElement} placement="top">{statusText}</Tooltip>
|
14
frontend/src/lib/store.ts
Normal file
14
frontend/src/lib/store.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { writable } from "svelte/store";
|
||||
import { browser } from "$app/environment";
|
||||
|
||||
import type { MeUser } from "./api/entities";
|
||||
|
||||
export const userStore = writable<MeUser | null>(null);
|
||||
export const tokenStore = writable<string | null>(null);
|
||||
|
||||
let defaultThemeValue = "dark";
|
||||
const initialThemeValue = browser
|
||||
? window.localStorage.getItem("pronouns-theme") ?? defaultThemeValue
|
||||
: defaultThemeValue;
|
||||
|
||||
export const themeStore = writable<string>(initialThemeValue);
|
9
frontend/src/routes/+layout.svelte
Normal file
9
frontend/src/routes/+layout.svelte
Normal file
|
@ -0,0 +1,9 @@
|
|||
<script>
|
||||
import "bootstrap/dist/css/bootstrap.min.css";
|
||||
import "bootstrap-icons/font/bootstrap-icons.css";
|
||||
|
||||
import Navigation from "./nav/Navigation.svelte";
|
||||
</script>
|
||||
|
||||
<Navigation />
|
||||
<slot />
|
6
frontend/src/routes/+page.svelte
Normal file
6
frontend/src/routes/+page.svelte
Normal file
|
@ -0,0 +1,6 @@
|
|||
<svelte:head>
|
||||
<title>pronouns.cc</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1>Welcome to SvelteKit</h1>
|
||||
<p>Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation</p>
|
10
frontend/src/routes/@[username]/+page.server.ts
Normal file
10
frontend/src/routes/@[username]/+page.server.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { apiFetch } from "$lib/api/fetch";
|
||||
import type { User } from "$lib/api/entities";
|
||||
|
||||
export const load = async ({ params }) => {
|
||||
const resp = await apiFetch<User>(`/users/${params.username}`, {
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
return resp;
|
||||
};
|
99
frontend/src/routes/@[username]/+page.svelte
Normal file
99
frontend/src/routes/@[username]/+page.svelte
Normal file
|
@ -0,0 +1,99 @@
|
|||
<script lang="ts">
|
||||
import { marked } from "marked";
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
|
||||
import FieldCard from "$lib/components/FieldCard.svelte";
|
||||
|
||||
import type { PageData } from "./$types";
|
||||
import StatusIcon from "$lib/components/StatusIcon.svelte";
|
||||
import PronounLink from "$lib/components/PronounLink.svelte";
|
||||
import PartialMemberCard from "$lib/components/PartialMemberCard.svelte";
|
||||
import FallbackImage from "$lib/components/FallbackImage.svelte";
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
let bio: string | null;
|
||||
$: bio = data.bio ? sanitizeHtml(marked.parse(data.bio)) : null;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>@{data.name} - pronouns.cc</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container">
|
||||
<div class="grid">
|
||||
<div class="row">
|
||||
<div class="col-md text-center">
|
||||
<FallbackImage urls={data.avatar_urls} alt="Avatar for @{data.name}" />
|
||||
</div>
|
||||
<div class="col-md">
|
||||
{#if data.display_name}
|
||||
<h2>{data.display_name}</h2>
|
||||
<h5 class="text-body-secondary">@{data.name}</h5>
|
||||
{:else}
|
||||
<h2>@{data.name}</h2>
|
||||
{/if}
|
||||
<hr />
|
||||
{#if bio}
|
||||
<p>{@html bio}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if data.links}
|
||||
<div class="col-md">
|
||||
<ul>
|
||||
{#each data.links as link}
|
||||
<li><a href={link}>{link}</a></li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="row">
|
||||
{#if data.names}
|
||||
<div class="col-md">
|
||||
<h4>Names</h4>
|
||||
<ul class="list-unstyled">
|
||||
{#each data.names as name}
|
||||
<li><StatusIcon status={name.status} /> {name.value}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.pronouns}
|
||||
<div class="col-md">
|
||||
<h4>Pronouns</h4>
|
||||
<ul class="list-unstyled">
|
||||
{#each data.pronouns as pronouns}
|
||||
<li>
|
||||
<StatusIcon status={pronouns.status} />
|
||||
<PronounLink {pronouns} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if data.fields}
|
||||
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3">
|
||||
{#each data.fields as field}
|
||||
<div class="col">
|
||||
<FieldCard {field} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.members}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<hr />
|
||||
<h2>Members</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-5 text-center">
|
||||
{#each data.members as member}
|
||||
<PartialMemberCard user={data} {member} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
10
frontend/src/routes/@[username]/[memberName]/+page.server.ts
Normal file
10
frontend/src/routes/@[username]/[memberName]/+page.server.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { apiFetch } from "$lib/api/fetch";
|
||||
import type { Member } from "$lib/api/entities";
|
||||
|
||||
export const load = async ({ params }) => {
|
||||
const resp = await apiFetch<Member>(`/users/${params.username}/members/${params.memberName}`, {
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
return resp;
|
||||
};
|
87
frontend/src/routes/@[username]/[memberName]/+page.svelte
Normal file
87
frontend/src/routes/@[username]/[memberName]/+page.svelte
Normal file
|
@ -0,0 +1,87 @@
|
|||
<script lang="ts">
|
||||
import { marked } from "marked";
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
|
||||
import FieldCard from "$lib/components/FieldCard.svelte";
|
||||
|
||||
import type { PageData } from "./$types";
|
||||
import StatusIcon from "$lib/components/StatusIcon.svelte";
|
||||
import PronounLink from "$lib/components/PronounLink.svelte";
|
||||
import FallbackImage from "$lib/components/FallbackImage.svelte";
|
||||
import { Button, Icon } from "sveltestrap";
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
let bio: string | null;
|
||||
$: bio = data.bio ? sanitizeHtml(marked.parse(data.bio)) : null;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.display_name ?? data.name} - @{data.user.name} - pronouns.cc</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container">
|
||||
<div>
|
||||
<Button color="secondary" href="/@{data.user.name}">
|
||||
<Icon name="arrow-left" /> Back to {data.user.display_name ?? data.user.name}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="row">
|
||||
<div class="col-md text-center">
|
||||
<FallbackImage urls={data.avatar_urls} alt="Avatar for @{data.name}" />
|
||||
</div>
|
||||
<div class="col-md">
|
||||
<h2>{data.display_name ?? data.name}</h2>
|
||||
<h5 class="text-body-secondary">{data.name} (@{data.user.name})</h5>
|
||||
<hr />
|
||||
{#if bio}
|
||||
<p>{@html bio}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if data.links}
|
||||
<div class="col-md">
|
||||
<ul>
|
||||
{#each data.links as link}
|
||||
<li><a href={link}>{link}</a></li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="row">
|
||||
{#if data.names}
|
||||
<div class="col-md">
|
||||
<h4>Names</h4>
|
||||
<ul class="list-unstyled">
|
||||
{#each data.names as name}
|
||||
<li><StatusIcon status={name.status} /> {name.value}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.pronouns}
|
||||
<div class="col-md">
|
||||
<h4>Pronouns</h4>
|
||||
<ul class="list-unstyled">
|
||||
{#each data.pronouns as pronouns}
|
||||
<li>
|
||||
<StatusIcon status={pronouns.status} />
|
||||
<PronounLink {pronouns} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if data.fields}
|
||||
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3">
|
||||
{#each data.fields as field}
|
||||
<div class="col">
|
||||
<FieldCard {field} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
17
frontend/src/routes/login/+page.server.ts
Normal file
17
frontend/src/routes/login/+page.server.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { apiFetch } from "$lib/api/fetch";
|
||||
import { PUBLIC_BASE_URL } from "$env/static/public";
|
||||
|
||||
export const load = async () => {
|
||||
const resp = await apiFetch<UrlsResponse>("/auth/urls", {
|
||||
method: "POST",
|
||||
body: {
|
||||
callback_domain: PUBLIC_BASE_URL,
|
||||
},
|
||||
});
|
||||
|
||||
return resp;
|
||||
};
|
||||
|
||||
interface UrlsResponse {
|
||||
discord: string;
|
||||
}
|
23
frontend/src/routes/login/+page.svelte
Normal file
23
frontend/src/routes/login/+page.svelte
Normal file
|
@ -0,0 +1,23 @@
|
|||
<script lang="ts">
|
||||
import type { PageData } from "./$types";
|
||||
|
||||
export let data: PageData;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Login - pronouns.cc</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container">
|
||||
<h1>Log in or sign up</h1>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<p>
|
||||
<a class="btn btn-primary" href={data.discord} role="button">Log in with Discord</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="col">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
33
frontend/src/routes/login/discord/+page.server.ts
Normal file
33
frontend/src/routes/login/discord/+page.server.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import type { MeUser } from "$lib/api/entities";
|
||||
import { apiFetch } from "$lib/api/fetch";
|
||||
import type { PageServerLoad } from "./$types";
|
||||
import { PUBLIC_BASE_URL } from "$env/static/public";
|
||||
|
||||
export const load = (async (event) => {
|
||||
try {
|
||||
const resp = await apiFetch<CallbackResponse>("/auth/discord/callback", {
|
||||
method: "POST",
|
||||
body: {
|
||||
callback_domain: PUBLIC_BASE_URL,
|
||||
code: event.url.searchParams.get("code"),
|
||||
state: event.url.searchParams.get("state"),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...resp,
|
||||
};
|
||||
} catch (e) {
|
||||
return { error: e };
|
||||
}
|
||||
}) satisfies PageServerLoad;
|
||||
|
||||
interface CallbackResponse {
|
||||
has_account: boolean;
|
||||
token?: string;
|
||||
user?: MeUser;
|
||||
|
||||
discord?: string;
|
||||
ticket?: string;
|
||||
require_invite: boolean;
|
||||
}
|
17
frontend/src/routes/login/discord/+page.svelte
Normal file
17
frontend/src/routes/login/discord/+page.svelte
Normal file
|
@ -0,0 +1,17 @@
|
|||
<script lang="ts">
|
||||
import type { PageData } from "./$types";
|
||||
|
||||
export let data: PageData;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Log in with Discord - pronouns.cc</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1>Log in with Discord</h1>
|
||||
|
||||
{#if data.error}
|
||||
|
||||
{:else}
|
||||
|
||||
{/if}
|
34
frontend/src/routes/nav/Logo.svelte
Normal file
34
frontend/src/routes/nav/Logo.svelte
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 11 KiB |
12
frontend/src/routes/nav/NavItem.svelte
Normal file
12
frontend/src/routes/nav/NavItem.svelte
Normal file
|
@ -0,0 +1,12 @@
|
|||
<script lang="ts">
|
||||
export let href: string;
|
||||
export let plain: boolean = false;
|
||||
</script>
|
||||
|
||||
{#if plain}
|
||||
<a {href} class="hover:text-sky-500 dark:hover:text-sky-400"><slot /></a>
|
||||
{:else}
|
||||
<li>
|
||||
<a {href} class="hover:text-sky-500 dark:hover:text-sky-400"><slot /></a>
|
||||
</li>
|
||||
{/if}
|
101
frontend/src/routes/nav/Navigation.svelte
Normal file
101
frontend/src/routes/nav/Navigation.svelte
Normal file
|
@ -0,0 +1,101 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { browser } from "$app/environment";
|
||||
|
||||
import {
|
||||
Collapse,
|
||||
Icon,
|
||||
Nav,
|
||||
Navbar,
|
||||
NavbarBrand,
|
||||
NavbarToggler,
|
||||
NavItem,
|
||||
NavLink,
|
||||
} from "sveltestrap";
|
||||
|
||||
import Logo from "./Logo.svelte";
|
||||
import { userStore, themeStore } from "$lib/store";
|
||||
import { ErrorCode, type APIError, type MeUser } from "$lib/api/entities";
|
||||
import { apiFetch } from "$lib/api/fetch";
|
||||
|
||||
let theme: string;
|
||||
let currentUser: MeUser | null;
|
||||
let showMenu: boolean = false;
|
||||
|
||||
$: currentUser = $userStore;
|
||||
$: theme = $themeStore;
|
||||
|
||||
onMount(() => {
|
||||
const localUser = localStorage.getItem("pronouns-user");
|
||||
userStore.set(localUser ? JSON.parse(localUser) : null);
|
||||
|
||||
const token = localStorage.getItem("pronouns-token");
|
||||
if (token) {
|
||||
apiFetch<MeUser>("/users/@me", { token })
|
||||
.then((user) => {
|
||||
userStore.set(user);
|
||||
localStorage.setItem("pronouns-user", JSON.stringify(user));
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log("getting /users/@me:", e);
|
||||
|
||||
if (
|
||||
(e as APIError).code == ErrorCode.InvalidToken ||
|
||||
(e as APIError).code == ErrorCode.Forbidden
|
||||
) {
|
||||
localStorage.removeItem("pronouns-token");
|
||||
localStorage.removeItem("pronouns-user");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$: updateTheme(theme);
|
||||
|
||||
const updateTheme = (newTheme: string) => {
|
||||
if (!browser) return;
|
||||
|
||||
document.documentElement.setAttribute("data-bs-theme", newTheme);
|
||||
localStorage.setItem("pronouns-theme", newTheme);
|
||||
};
|
||||
|
||||
const toggleTheme = () => {
|
||||
themeStore.set(theme === "dark" ? "light" : "dark")
|
||||
};
|
||||
const toggleMenu = () => {
|
||||
showMenu = !showMenu;
|
||||
};
|
||||
</script>
|
||||
|
||||
<Navbar
|
||||
color={theme === "dark" ? "dark" : "light"}
|
||||
light={theme !== "dark"}
|
||||
dark={theme === "dark"}
|
||||
expand="lg"
|
||||
class="mb-4"
|
||||
>
|
||||
<NavbarBrand href="/"><Logo /></NavbarBrand>
|
||||
<NavbarToggler on:click={toggleMenu} />
|
||||
<Collapse isOpen={showMenu} navbar expand="lg">
|
||||
<Nav class="ms-auto" navbar>
|
||||
<NavItem>
|
||||
{#if currentUser}
|
||||
<NavLink href="/@{currentUser.name}">@{currentUser.name}</NavLink>
|
||||
<NavLink href="/settings">Settings</NavLink>
|
||||
<NavLink href="/logout">Log out</NavLink>
|
||||
{:else}
|
||||
<NavLink href="/login">Log in</NavLink>
|
||||
{/if}
|
||||
</NavItem>
|
||||
<NavItem>
|
||||
<NavLink
|
||||
on:click={() => toggleTheme()}
|
||||
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||
>
|
||||
<Icon name={theme === "dark" ? "sun" : "moon-stars"} height="24" />
|
||||
{theme === "dark" ? "Light mode" : "Dark mode"}
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
</Nav>
|
||||
</Collapse>
|
||||
</Navbar>
|
Loading…
Add table
Add a link
Reference in a new issue