forked from mirrors/pronouns.cc
fix dark mode, add member page
This commit is contained in:
parent
27cec4e77e
commit
d011c703ed
12 changed files with 298 additions and 31 deletions
|
@ -49,6 +49,22 @@ export interface PartialMember {
|
||||||
avatar_urls: 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 {
|
export interface APIError {
|
||||||
code: ErrorCode;
|
code: ErrorCode;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import type { APIError } from "./entities";
|
import type { APIError } from "./entities";
|
||||||
|
import { PUBLIC_BASE_URL } from "$env/static/public";
|
||||||
|
|
||||||
export async function apiFetch<T>(
|
export async function apiFetch<T>(
|
||||||
path: string,
|
path: string,
|
||||||
{ method, body, token }: { method?: string; body?: any; token?: string },
|
{ method, body, token }: { method?: string; body?: any; token?: string },
|
||||||
) {
|
) {
|
||||||
const apiBase = typeof process !== "undefined" ? process.env.ORIGIN : "";
|
|
||||||
|
|
||||||
const resp = await fetch(`${apiBase}/api/v1${path}`, {
|
const resp = await fetch(`${PUBLIC_BASE_URL}/api/v1${path}`, {
|
||||||
method: method || "GET",
|
method: method || "GET",
|
||||||
headers: {
|
headers: {
|
||||||
...(token ? { Authorization: token } : {}),
|
...(token ? { Authorization: token } : {}),
|
||||||
|
|
28
src/lib/components/FallbackImage.svelte
Normal file
28
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}
|
15
src/lib/components/PartialMemberCard.svelte
Normal file
15
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>
|
|
@ -1,6 +1,14 @@
|
||||||
import { writable } from "svelte/store";
|
import { writable } from "svelte/store";
|
||||||
|
import { browser } from "$app/environment";
|
||||||
|
|
||||||
import type { MeUser } from "./api/entities";
|
import type { MeUser } from "./api/entities";
|
||||||
|
|
||||||
export const userStore = writable<MeUser | null>(null);
|
export const userStore = writable<MeUser | null>(null);
|
||||||
export const tokenStore = writable<string | 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);
|
||||||
|
|
|
@ -7,6 +7,8 @@
|
||||||
import type { PageData } from "./$types";
|
import type { PageData } from "./$types";
|
||||||
import StatusIcon from "$lib/components/StatusIcon.svelte";
|
import StatusIcon from "$lib/components/StatusIcon.svelte";
|
||||||
import PronounLink from "$lib/components/PronounLink.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;
|
export let data: PageData;
|
||||||
|
|
||||||
|
@ -21,13 +23,13 @@
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
{#if data.avatar_urls}
|
<div class="col-md text-center">
|
||||||
<div class="col-md" />
|
<FallbackImage urls={data.avatar_urls} alt="Avatar for @{data.name}" />
|
||||||
{/if}
|
</div>
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
{#if data.display_name}
|
{#if data.display_name}
|
||||||
<h2>{data.display_name}</h2>
|
<h2>{data.display_name}</h2>
|
||||||
<h4>@{data.name}</h4>
|
<h5 class="text-body-secondary">@{data.name}</h5>
|
||||||
{:else}
|
{:else}
|
||||||
<h2>@{data.name}</h2>
|
<h2>@{data.name}</h2>
|
||||||
{/if}
|
{/if}
|
||||||
|
@ -64,7 +66,7 @@
|
||||||
{#each data.pronouns as pronouns}
|
{#each data.pronouns as pronouns}
|
||||||
<li>
|
<li>
|
||||||
<StatusIcon status={pronouns.status} />
|
<StatusIcon status={pronouns.status} />
|
||||||
<PronounLink pronouns={pronouns} />
|
<PronounLink {pronouns} />
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -81,4 +83,17 @@
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
|
|
10
src/routes/@[username]/[memberName]/+page.server.ts
Normal file
10
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
src/routes/@[username]/[memberName]/+page.svelte
Normal file
87
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>
|
|
@ -1,10 +1,11 @@
|
||||||
import { apiFetch } from "$lib/api/fetch";
|
import { apiFetch } from "$lib/api/fetch";
|
||||||
|
import { PUBLIC_BASE_URL } from "$env/static/public";
|
||||||
|
|
||||||
export const load = async () => {
|
export const load = async () => {
|
||||||
const resp = await apiFetch<UrlsResponse>("/auth/urls", {
|
const resp = await apiFetch<UrlsResponse>("/auth/urls", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
callback_domain: process.env.ORIGIN,
|
callback_domain: PUBLIC_BASE_URL,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
33
src/routes/login/discord/+page.server.ts
Normal file
33
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;
|
||||||
|
}
|
|
@ -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}
|
|
@ -2,34 +2,65 @@
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
|
|
||||||
import { Collapse,Icon, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from "sveltestrap";
|
import {
|
||||||
|
Collapse,
|
||||||
|
Icon,
|
||||||
|
Nav,
|
||||||
|
Navbar,
|
||||||
|
NavbarBrand,
|
||||||
|
NavbarToggler,
|
||||||
|
NavItem,
|
||||||
|
NavLink,
|
||||||
|
} from "sveltestrap";
|
||||||
|
|
||||||
import Logo from "./Logo.svelte";
|
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 darkTheme: boolean = false;
|
let theme: string;
|
||||||
|
let currentUser: MeUser | null;
|
||||||
let showMenu: boolean = false;
|
let showMenu: boolean = false;
|
||||||
|
|
||||||
|
$: currentUser = $userStore;
|
||||||
|
$: theme = $themeStore;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
darkTheme =
|
const localUser = localStorage.getItem("pronouns-user");
|
||||||
localStorage.theme === "dark" ||
|
userStore.set(localUser ? JSON.parse(localUser) : null);
|
||||||
(!("theme" in localStorage) && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
|
||||||
|
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(darkTheme);
|
$: updateTheme(theme);
|
||||||
|
|
||||||
const updateTheme = (isDark: boolean) => {
|
const updateTheme = (newTheme: string) => {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
|
|
||||||
if (isDark) {
|
document.documentElement.setAttribute("data-bs-theme", newTheme);
|
||||||
document.documentElement.setAttribute("data-bs-theme", "dark");
|
localStorage.setItem("pronouns-theme", newTheme);
|
||||||
} else {
|
|
||||||
document.documentElement.setAttribute("data-bs-theme", "light");
|
|
||||||
}
|
|
||||||
localStorage.setItem("theme", isDark ? "dark" : "light");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
darkTheme = !darkTheme;
|
themeStore.set(theme === "dark" ? "light" : "dark")
|
||||||
};
|
};
|
||||||
const toggleMenu = () => {
|
const toggleMenu = () => {
|
||||||
showMenu = !showMenu;
|
showMenu = !showMenu;
|
||||||
|
@ -37,9 +68,9 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Navbar
|
<Navbar
|
||||||
color={darkTheme ? "dark" : "light"}
|
color={theme === "dark" ? "dark" : "light"}
|
||||||
light={!darkTheme}
|
light={theme !== "dark"}
|
||||||
dark={darkTheme}
|
dark={theme === "dark"}
|
||||||
expand="lg"
|
expand="lg"
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
>
|
>
|
||||||
|
@ -48,15 +79,21 @@
|
||||||
<Collapse isOpen={showMenu} navbar expand="lg">
|
<Collapse isOpen={showMenu} navbar expand="lg">
|
||||||
<Nav class="ms-auto" navbar>
|
<Nav class="ms-auto" navbar>
|
||||||
<NavItem>
|
<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>
|
<NavLink href="/login">Log in</NavLink>
|
||||||
|
{/if}
|
||||||
</NavItem>
|
</NavItem>
|
||||||
<NavItem>
|
<NavItem>
|
||||||
<NavLink
|
<NavLink
|
||||||
on:click={() => toggleTheme()}
|
on:click={() => toggleTheme()}
|
||||||
title={darkTheme ? "Switch to light mode" : "Switch to dark mode"}
|
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||||
>
|
>
|
||||||
<Icon name={darkTheme ? "sun" : "moon-stars"} height="24" />
|
<Icon name={theme === "dark" ? "sun" : "moon-stars"} height="24" />
|
||||||
{darkTheme ? "Light mode" : "Dark mode"}
|
{theme === "dark" ? "Light mode" : "Dark mode"}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</NavItem>
|
</NavItem>
|
||||||
</Nav>
|
</Nav>
|
||||||
|
|
Loading…
Reference in a new issue