you know what let's just change frontend framework again

This commit is contained in:
sam 2024-11-24 15:55:29 +01:00
parent c8cd483d20
commit 0d47f1fb01
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
115 changed files with 4407 additions and 10824 deletions

13
Foxnouns.Frontend/src/app.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,25 @@
@use "bootstrap/scss/bootstrap" with (
$color-mode-type: media-query,
$font-family-sans-serif: (
"FiraGO",
system-ui,
-apple-system,
"Segoe UI",
Roboto,
"Helvetica Neue",
"Noto Sans",
"Liberation Sans",
Arial,
sans-serif,
"Apple Color Emoji",
"Segoe UI Emoji",
"Segoe UI Symbol",
"Noto Color Emoji",
)
);
@import "bootstrap-icons/font/bootstrap-icons.css";
@import "@fontsource/firago/400.css";
@import "@fontsource/firago/400-italic.css";
@import "@fontsource/firago/700.css";
@import "@fontsource/firago/700-italic.css";

View file

@ -0,0 +1,13 @@
import { PRIVATE_API_HOST, PRIVATE_INTERNAL_API_HOST } from "$env/static/private";
import { PUBLIC_API_BASE } from "$env/static/public";
import type { HandleFetch } from "@sveltejs/kit";
export const handleFetch: HandleFetch = async ({ request, fetch }) => {
if (request.url.startsWith(`${PUBLIC_API_BASE}/internal`)) {
request = new Request(request.url.replace(PUBLIC_API_BASE, PRIVATE_INTERNAL_API_HOST), request);
} else if (request.url.startsWith(PUBLIC_API_BASE)) {
request = new Request(request.url.replace(PUBLIC_API_BASE, PRIVATE_API_HOST), request);
}
return await fetch(request);
};

View file

@ -0,0 +1,54 @@
export default class ApiError {
raw?: RawApiError;
code: ErrorCode;
constructor(err?: RawApiError, code?: ErrorCode) {
this.raw = err;
this.code = err?.code || code || ErrorCode.InternalServerError;
}
get obj(): RawApiError {
return this.toObject();
}
toObject(): RawApiError {
return {
status: this.raw?.status || 500,
code: this.code,
message: this.raw?.message || "Internal server error",
errors: this.raw?.errors,
};
}
}
export type RawApiError = {
status: number;
message: string;
code: ErrorCode;
errors?: Array<{ key: string; errors: ValidationError[] }>;
};
export enum ErrorCode {
InternalServerError = "INTERNAL_SERVER_ERROR",
Forbidden = "FORBIDDEN",
BadRequest = "BAD_REQUEST",
AuthenticationError = "AUTHENTICATION_ERROR",
AuthenticationRequired = "AUTHENTICATION_REQUIRED",
MissingScopes = "MISSING_SCOPES",
GenericApiError = "GENERIC_API_ERROR",
UserNotFound = "USER_NOT_FOUND",
MemberNotFound = "MEMBER_NOT_FOUND",
AccountAlreadyLinked = "ACCOUNT_ALREADY_LINKED",
LastAuthMethod = "LAST_AUTH_METHOD",
// This code isn't actually returned by the API
Non204Response = "(non 204 response)",
}
export type ValidationError = {
message: string;
min_length?: number;
max_length?: number;
actual_length?: number;
allowed_values?: any[];
actual_value?: any;
};

View file

@ -0,0 +1,92 @@
import { PUBLIC_API_BASE } from "$env/static/public";
import type { Cookies } from "@sveltejs/kit";
import ApiError, { ErrorCode } from "./error";
import { TOKEN_COOKIE_NAME } from "$lib";
import log from "$lib/log";
export type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
export type RequestArgs = {
token?: string;
isInternal?: boolean;
body?: any;
fetch?: typeof fetch;
cookies?: Cookies;
};
/**
* Makes a raw request to the API.
* @param method The HTTP method for this request
* @param path The path for this request, without the /api/v2 prefix, starting with a slash.
* @param args Optional arguments to the request function.
* @returns A Promise object.
*/
export async function baseRequest(
method: Method,
path: string,
args: RequestArgs = {},
): Promise<Response> {
const token = args.token ?? args.cookies?.get(TOKEN_COOKIE_NAME);
const fetchFn = args.fetch ?? fetch;
const url = `${PUBLIC_API_BASE}/${args.isInternal ? "internal" : "v2"}${path}`;
log.debug("Sending request to %s %s", method, url);
const headers = {
...(args.body ? { "Content-Type": "application/json; charset=utf-8" } : {}),
...(token ? { Authorization: token } : {}),
};
return await fetchFn(url, {
method,
headers,
body: args.body ? JSON.stringify(args.body) : undefined,
});
}
/**
* Makes a request to the API and parses the returned object.
* @param method The HTTP method for this request
* @param path The path for this request, without the /api/v2 prefix, starting with a slash.
* @param args Optional arguments to the request function.
* @returns The response deserialized as `T`.
*/
export async function apiRequest<T>(
method: Method,
path: string,
args: RequestArgs = {},
): Promise<T> {
const resp = await baseRequest(method, path, args);
if (resp.status < 200 || resp.status > 299) {
const err = await resp.json();
if ("code" in err) throw new ApiError(err);
else throw new ApiError();
}
return (await resp.json()) as T;
}
/**
* Makes a request without reading the body (unless the API returns an error).
* @param method The HTTP method for this request
* @param path The path for this request, without the /api/v2 prefix, starting with a slash.
* @param args Optional arguments to the request function.
* @param enforce204 Whether to throw an error on a non-204 status code.
*/
export async function fastRequest(
method: Method,
path: string,
args: RequestArgs = {},
enforce204: boolean = false,
): Promise<void> {
const resp = await baseRequest(method, path, args);
if (resp.status < 200 || resp.status > 299) {
const err = await resp.json();
if ("code" in err) throw new ApiError(err);
else throw new ApiError();
}
if (enforce204 && resp.status !== 204) throw new ApiError(undefined, ErrorCode.Non204Response);
}

View file

@ -0,0 +1,23 @@
import type { User } from "./user";
export type AuthResponse = {
user: User;
token: string;
expires_at: string;
};
export type CallbackResponse = {
has_account: boolean;
ticket?: string;
remote_username?: string;
user?: User;
token?: string;
expires_at?: string;
};
export type AuthUrls = {
email_enabled: boolean;
discord?: string;
google?: string;
tumblr?: string;
};

View file

@ -0,0 +1,4 @@
export * from "./meta";
export * from "./user";
export * from "./member";
export * from "./auth";

View file

@ -0,0 +1,9 @@
import type { Field, PartialMember, PartialUser, PrideFlag } from "./user";
export type Member = PartialMember & {
fields: Field[];
flags: PrideFlag[];
links: string[];
user: PartialUser;
};

View file

@ -0,0 +1,19 @@
export type Meta = {
repository: string;
version: string;
hash: string;
users: {
total: number;
active_month: number;
active_week: number;
active_day: number;
};
members: number;
limits: Limits;
};
export type Limits = {
member_count: number;
bio_length: number;
custom_preferences: number;
};

View file

@ -0,0 +1,139 @@
export type PartialUser = {
id: string;
username: string;
display_name: string | null;
avatar_url: string | null;
custom_preferences: Record<string, CustomPreference>;
};
export type User = PartialUser & {
bio: string | null;
member_title: string | null;
links: string[];
names: FieldEntry[];
pronouns: Pronoun[];
fields: Field[];
flags: PrideFlag[];
role: "USER" | "MODERATOR" | "ADMIN";
};
export type MeUser = UserWithMembers & {
auth_methods: AuthMethod[];
member_list_hidden: boolean;
last_active: string;
last_sid_reroll: string;
};
export type UserWithMembers = User & { members: PartialMember[] };
export type UserWithHiddenFields = User & {
auth_methods?: unknown[];
member_list_hidden: boolean;
last_active: string;
};
export type UserSettings = {
dark_mode: boolean | null;
};
export type PartialMember = {
id: string;
name: string;
display_name: string;
bio: string | null;
avatar_url: string | null;
names: FieldEntry[];
pronouns: Pronoun[];
unlisted: boolean | null;
};
export type FieldEntry = {
value: string;
status: string;
};
export type Pronoun = FieldEntry & { display_text: string | null };
export type Field = {
name: string;
entries: FieldEntry[];
};
export type PrideFlag = {
id: string;
image_url: string;
name: string;
description: string | null;
};
export type AuthMethod = {
id: string;
type: "DISCORD" | "GOOGLE" | "TUMBLR" | "FEDIVERSE" | "EMAIL";
remote_id: string;
remote_username?: string;
};
export type CustomPreference = {
icon: string;
tooltip: string;
muted: boolean;
favourite: boolean;
size: PreferenceSize;
};
export enum PreferenceSize {
Large = "LARGE",
Normal = "NORMAL",
Small = "SMALL",
}
export function mergePreferences(
prefs: Record<string, CustomPreference>,
): Record<string, CustomPreference> {
return Object.assign({}, defaultPreferences, prefs);
}
export const defaultPreferences = Object.freeze({
favourite: {
icon: "heart-fill",
tooltip: "Favourite",
size: PreferenceSize.Large,
muted: false,
favourite: true,
},
okay: {
icon: "hand-thumbs-up",
tooltip: "Okay",
size: PreferenceSize.Normal,
muted: false,
favourite: false,
},
jokingly: {
icon: "emoji-laughing",
tooltip: "Jokingly",
size: PreferenceSize.Normal,
muted: false,
favourite: false,
},
friends_only: {
icon: "people",
tooltip: "Friends only",
size: PreferenceSize.Normal,
muted: false,
favourite: false,
},
avoid: {
icon: "hand-thumbs-down",
tooltip: "Avoid",
size: PreferenceSize.Small,
muted: true,
favourite: false,
},
missing: {
icon: "question-lg",
tooltip: "Unknown (missing)",
size: PreferenceSize.Normal,
muted: false,
favourite: false,
},
});

View file

@ -0,0 +1,14 @@
<script lang="ts">
import { DEFAULT_AVATAR } from "$lib";
type Props = { url: string | null; alt: string; lazyLoad?: boolean };
let { url, alt, lazyLoad }: Props = $props();
</script>
<img
class="rounded-circle img-fluid"
src={url || DEFAULT_AVATAR}
{alt}
width={200}
loading={lazyLoad ? "lazy" : "eager"}
/>

View file

@ -0,0 +1,32 @@
<script lang="ts">
import { ErrorCode, type RawApiError } from "$api/error";
import errorDescription from "$lib/errorCodes.svelte";
import { t } from "$lib/i18n";
import KeyedValidationErrors from "./errors/KeyedValidationErrors.svelte";
type Props = { headerElem?: string; error: RawApiError };
let { headerElem, error }: Props = $props();
</script>
<svelte:element this={headerElem ?? "h4"}>
{#if error.code === ErrorCode.BadRequest}
{$t("error.bad-request-header")}
{:else}
{$t("error.generic-header")}
{/if}
</svelte:element>
<p>{errorDescription($t, error.code)}</p>
{#if error.errors}
<details>
<summary>{$t("error.extra-info-header")}</summary>
<ul>
{#each error.errors as val}
<KeyedValidationErrors key={val.key} errors={val.errors} />
{/each}
</ul>
</details>
{/if}
<details>
<summary>{$t("error.raw-header")}</summary>
<pre>{JSON.stringify(error, undefined, " ")}</pre>
</details>

View file

@ -0,0 +1,11 @@
<script lang="ts">
import type { RawApiError } from "$api/error";
import Error from "./Error.svelte";
type Props = { error: RawApiError };
let { error }: Props = $props();
</script>
<div class="alert alert-danger" role="alert">
<Error {error} />
</div>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.9 KiB

View file

@ -0,0 +1,69 @@
<script lang="ts">
import {
Navbar,
NavbarBrand,
NavbarToggler,
Collapse,
Nav,
NavLink,
NavItem,
} from "@sveltestrap/sveltestrap";
import { page } from "$app/stores";
import type { User, Meta } from "$api/models/index";
import Logo from "$components/Logo.svelte";
import { t } from "$lib/i18n";
type Props = { user: User | null; meta: Meta };
let { user, meta }: Props = $props();
let isOpen = $state(true);
const toggleMenu = () => (isOpen = !isOpen);
</script>
<Navbar expand="lg" class="mb-4 mx-2">
<NavbarBrand href="/">
<Logo />
{#if meta.version.endsWith(".dirty")}
<strong id="beta-text" class="text-danger">dev</strong>
{:else}
<span id="beta-text">beta</span>
{/if}
</NavbarBrand>
<NavbarToggler onclick={toggleMenu} aria-label="Toggle menu" />
<Collapse {isOpen} navbar expand="lg">
<Nav navbar class="ms-auto">
{#if user}
<NavItem>
<NavLink
href="/@{user.username}"
active={$page.url.pathname.startsWith(`/@${user.username}`)}
>
@{user.username}
</NavLink>
</NavItem>
<NavItem>
<NavLink href="/settings" active={$page.url.pathname.startsWith("/settings")}>
{$t("nav.settings")}
</NavLink>
</NavItem>
{:else}
<NavItem>
<NavLink href="/auth/log-in" active={$page.url.pathname === "/auth/log-in"}>
{$t("nav.log-in")}
</NavLink>
</NavItem>
{/if}
</Nav>
</Collapse>
</Navbar>
<style>
/* These exact values make it look almost identical to the SVG version, which is what we want */
#beta-text {
font-size: 0.7em;
position: relative;
font-style: italic;
bottom: 12px;
right: 3px;
}
</style>

View file

@ -0,0 +1,17 @@
<script lang="ts">
import { Icon, Tooltip } from "@sveltestrap/sveltestrap";
import type { CustomPreference } from "$api/models/user";
type Props = { preference: CustomPreference };
let { preference }: Props = $props();
// svelte-ignore non_reactive_update
let elem: HTMLSpanElement;
</script>
<span bind:this={elem} aria-hidden={true}>
<Icon name={preference.icon} />
</span>
<span class="visually-hidden">{preference.tooltip}:</span>
<Tooltip aria-hidden target={elem} placement="top">{preference.tooltip}</Tooltip>

View file

@ -0,0 +1,16 @@
<script lang="ts">
import type { ValidationError } from "$api/error";
import RequestValidationError from "./RequestValidationError.svelte";
type Props = { key: string; errors: ValidationError[] };
let { key, errors }: Props = $props();
</script>
<li>
<code>{key}</code>:
<ul>
{#each errors as error}
<RequestValidationError {error} />
{/each}
</ul>
</li>

View file

@ -0,0 +1,41 @@
<script lang="ts">
import type { ValidationError } from "$api/error";
import { t } from "$lib/i18n";
type Props = { error: ValidationError };
let { error }: Props = $props();
let isLengthError = $derived(error.min_length && error.max_length && error.actual_length);
let isDisallowedValueError = $derived(error.allowed_values && error.actual_value);
</script>
{#if isLengthError}
{#if error.actual_length! > error.max_length!}
<li>
{$t("error.validation-max-length-error", {
max: error.max_length,
actual: error.actual_length,
})}
</li>
{:else}
<li>
{$t("error.validation-min-length-error", {
min: error.min_length,
actual: error.actual_length,
})}
</li>
{/if}
{:else if isDisallowedValueError}
<li>
{$t("error.validation-disallowed-value-1")}: <code>{error.actual_value}</code><br />
{$t("error.validation-disallowed-value-2")}:
<code>{error.allowed_values!.map((v) => v.toString()).join(", ")}</code>
</li>
{:else if error.actual_value}
<li>
{$t("error.validation-disallowed-value-1")}: <code>{error.actual_value}</code><br />
{$t("error.validation-reason")}: {error.message}
</li>
{:else}
<li>{$t("error.validation-generic")}: {error.message}</li>
{/if}

View file

@ -0,0 +1,16 @@
<script lang="ts">
import { t } from "$lib/i18n";
type Props = { memberName?: string; editLink: string };
let { memberName, editLink }: Props = $props();
</script>
<div class="alert alert-secondary">
{#if memberName}
{$t("profile.edit-member-profile-notice", { memberName })}
{:else}
{$t("profile.edit-user-profile-notice")}
{/if}
<br />
<a href={editLink}>{$t("profile.edit-profile-link")}</a>
</div>

View file

@ -0,0 +1,26 @@
<script lang="ts">
import type { CustomPreference, Member, User } from "$api/models";
import ProfileField from "./field/ProfileField.svelte";
import { t } from "$lib/i18n";
type Props = { profile: User | Member; allPreferences: Record<string, CustomPreference> };
let { profile, allPreferences }: Props = $props();
</script>
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3">
{#if profile.names.length > 0}
<ProfileField name={$t("profile.names-header")} entries={profile.names} {allPreferences} />
{/if}
{#if profile.pronouns.length > 0}
<ProfileField
name={$t("profile.pronouns-header")}
entries={profile.pronouns}
{allPreferences}
/>
{/if}
{#each profile.fields as field}
{#if field.entries.length > 0}
<ProfileField name={field.name} entries={field.entries} {allPreferences} />
{/if}
{/each}
</div>

View file

@ -0,0 +1,24 @@
<script lang="ts">
import type { PrideFlag } from "$api/models/user";
import { Tooltip } from "@sveltestrap/sveltestrap";
type Props = { flag: PrideFlag };
let { flag }: Props = $props();
// svelte-ignore non_reactive_update
let elem: HTMLImageElement;
</script>
<span class="mx-2 my-1">
<Tooltip target={elem} aria-hidden placement="top">{flag.description ?? flag.name}</Tooltip>
<img bind:this={elem} class="flag" src={flag.image_url} alt={flag.description ?? flag.name} />
{flag.name}
</span>
<style>
.flag {
height: 1.5rem;
max-width: 200px;
border-radius: 3px;
}
</style>

View file

@ -0,0 +1,69 @@
<script lang="ts">
import type { User, Member } from "$api/models";
import { t } from "$lib/i18n";
import { renderMarkdown } from "$lib/markdown";
import ProfileLink from "./ProfileLink.svelte";
import ProfileFlag from "./ProfileFlag.svelte";
import Avatar from "$components/Avatar.svelte";
type Props = {
name: string;
profile: User | Member;
lazyLoadAvatar?: boolean;
};
let { name, profile, lazyLoadAvatar }: Props = $props();
// renderMarkdown sanitizes the output HTML for us
let bio = $derived(renderMarkdown(profile.bio));
</script>
<div class="grid row-gap-3">
<div class="row">
<div class="col-md-4 text-center">
<Avatar
url={profile.avatar_url}
alt={$t("avatar-tooltip", { name })}
lazyLoad={lazyLoadAvatar}
/>
<!-- Flags show up below the avatar if the profile has a bio, otherwise they show up below the row entirely -->
{#if profile.flags && profile.bio}
<div class="d-flex flex-wrap m-4">
{#each profile.flags as flag}
<ProfileFlag {flag} />
{/each}
</div>
{/if}
</div>
<div class="col-md">
{#if profile.display_name}
<div>
<h2>{profile.display_name}</h2>
<p class="fs-5 text-body-secondary">{name}</p>
</div>
{:else}
<h2>{name}</h2>
{/if}
{#if bio}
<hr />
<p>{@html bio}</p>
{/if}
</div>
{#if profile.links.length > 0}
<div class="col-md d-flex align-items-center">
<ul class="list-unstyled">
{#each profile.links as link}
<ProfileLink {link} />
{/each}
</ul>
</div>
{/if}
</div>
</div>
{#if profile.flags && !profile.bio}
<div class="d-flex flex-wrap m-4">
{#each profile.flags as flag}
<ProfileFlag {flag} />
{/each}
</div>
{/if}

View file

@ -0,0 +1,33 @@
<script lang="ts">
import { Icon } from "@sveltestrap/sveltestrap";
type Props = { link: string };
let { link }: Props = $props();
const prettifyLink = (raw: string) => {
let out = raw;
if (raw.startsWith("https://")) out = raw.substring("https://".length);
else if (raw.startsWith("http://")) out = raw.substring("http://".length);
if (raw.endsWith("/")) out = raw.substring(0, raw.length - 1);
return out;
};
let isLink = $derived(link.startsWith("http://") || link.startsWith("https://"));
let displayLink = $derived(prettifyLink(link));
</script>
{#if isLink}
<a href={link} class="text-decoration-none" rel="me nofollow noreferrer" target="_blank">
<li class="py-2 py-lg-0">
<Icon name="globe" aria-hidden class="text-body" />
<span class="text-decoration-underline">{displayLink}</span>
</li>
</a>
{:else}
<li class="py-2 py-lg-0">
<Icon name="globe" aria-hidden />
<span>{displayLink}</span>
</li>
{/if}

View file

@ -0,0 +1,30 @@
<script lang="ts">
import type { CustomPreference, FieldEntry, Pronoun } from "$api/models";
import ProfileFieldEntry from "./ProfileFieldEntry.svelte";
import PronounLink from "./PronounLink.svelte";
type Props = {
name: string;
entries: Array<FieldEntry | Pronoun>;
allPreferences: Record<string, CustomPreference>;
isCol?: boolean;
};
let { name, entries, allPreferences, isCol }: Props = $props();
</script>
<div class:col={isCol === false}>
<h3>{name}</h3>
<ul class="list-unstyled fs-5">
{#each entries as entry}
<li>
<ProfileFieldEntry status={entry.status} {allPreferences}>
{#if "display_text" in entry}
<PronounLink pronouns={entry} />
{:else}
{entry.value}
{/if}
</ProfileFieldEntry>
</li>
{/each}
</ul>
</div>

View file

@ -0,0 +1,28 @@
<script lang="ts">
import { defaultPreferences, PreferenceSize, type CustomPreference } from "$api/models";
import StatusIcon from "$components/StatusIcon.svelte";
import type { Snippet } from "svelte";
type Props = {
status: string;
allPreferences: Record<string, CustomPreference>;
children: Snippet;
};
let { status, allPreferences, children }: Props = $props();
let preference = $derived(
status in allPreferences ? allPreferences[status] : defaultPreferences.missing,
);
let elemType = $derived(preference.size === PreferenceSize.Large ? "strong" : "span");
</script>
<svelte:element
this={elemType}
class:text-muted={preference.muted}
class:fs-5={preference.size === PreferenceSize.Large}
class:fs-6={preference.size === PreferenceSize.Small}
>
<StatusIcon {preference} />
{@render children?.()}
</svelte:element>

View file

@ -0,0 +1,41 @@
<script lang="ts">
import type { Pronoun } from "$api/models/user";
type Props = { pronouns: Pronoun };
let { pronouns }: Props = $props();
// TODO: this entire component is only made with English pronouns in mind.
// It's gonna need a major rework to work with other languages.
const updatePronouns = (pronouns: Pronoun) => {
if (pronouns.display_text) {
return pronouns.display_text;
} else {
const split = pronouns.value.split("/");
if (split.length === 5) return split.splice(0, 2).join("/");
return pronouns.value;
}
};
const linkPronouns = (pronouns: Pronoun) => {
const linkBase = pronouns.value
.split("/")
.map((snippet) => encodeURIComponent(snippet))
.join("/");
if (pronouns.display_text) {
return `${linkBase},${encodeURIComponent(pronouns.display_text)}`;
}
return linkBase;
};
let pronounText = $derived(updatePronouns(pronouns));
let link = $derived(linkPronouns(pronouns));
let shouldLink = $derived(pronouns.value.split("/").length === 5);
</script>
{#if shouldLink}
<a class="text-reset" href="/pronouns/{link}">{pronounText}</a>
{:else}
{pronounText}
{/if}

View file

@ -0,0 +1,49 @@
<script lang="ts">
import type { CustomPreference, PartialMember } from "$api/models";
import Avatar from "$components/Avatar.svelte";
import { t } from "$lib/i18n";
type Props = {
username: string;
member: PartialMember;
allPreferences: Record<string, CustomPreference>;
};
let { username, member, allPreferences }: Props = $props();
const getPronouns = (member: PartialMember) => {
const filteredPronouns = member.pronouns.filter(
(entry) => (allPreferences[entry.status] || { favourite: false }).favourite,
);
if (filteredPronouns.length === 0) {
return undefined;
}
return filteredPronouns
.map((pronouns) => {
if (pronouns.display_text) {
return pronouns.display_text;
} else {
const split = pronouns.value.split("/");
if (split.length === 5) return split.splice(0, 2).join("/");
return pronouns.value;
}
})
.join(", ");
};
let pronouns = $derived(getPronouns(member));
</script>
<div>
<a href="/@{username}/{member.name}">
<Avatar url={member.avatar_url} lazyLoad alt={$t("avatar-tooltip", { name: member.name })} />
</a>
<p class="m-2">
<a class="text-reset fs-5 text-break" href="/@{username}/{member.name}">
{member.name}
</a>
{#if pronouns}
<br />
{pronouns}
{/if}
</p>
</div>

View file

@ -0,0 +1,36 @@
import { ErrorCode } from "$api/error";
import type { Modifier } from "sveltekit-i18n";
type TranslateFn = (key: string, payload?: any, props?: Modifier.Props<{}> | undefined) => any;
export default function errorDescription(t: TranslateFn, code: ErrorCode): string {
switch (code) {
case ErrorCode.InternalServerError:
return t("error.internal-server-error");
case ErrorCode.Forbidden:
return t("error.forbidden");
case ErrorCode.BadRequest:
return t("error.bad-request");
case ErrorCode.AuthenticationError:
return t("error.authentication-error");
case ErrorCode.AuthenticationRequired:
return t("error.authentication-required");
case ErrorCode.MissingScopes:
// This error should never be returned by site tokens, so ask the user if they messed with their cookies
return t("error.missing-scopes");
case ErrorCode.GenericApiError:
return t("error.generic-error");
case ErrorCode.UserNotFound:
return t("error.user-not-found");
case ErrorCode.MemberNotFound:
return t("error.member-not-found");
case ErrorCode.AccountAlreadyLinked:
return t("error.account-already-linked");
case ErrorCode.LastAuthMethod:
return t("error.last-auth-method");
case ErrorCode.Non204Response:
return t("error.generic-error");
}
return t("error.generic-error");
}

View file

@ -0,0 +1,24 @@
import { PUBLIC_LANGUAGE } from "$env/static/public";
import i18n, { type Config } from "sveltekit-i18n";
const config: Config<any> = {
initLocale: PUBLIC_LANGUAGE,
fallbackLocale: "en",
loaders: [
{
locale: "en",
key: "",
loader: async () => (await import("./locales/en.json")).default,
},
{
locale: "en-PR",
key: "",
loader: async () => (await import("./locales/en-PR.json")).default,
},
],
};
export const { t, locales, locale, translations, loadTranslations, setLocale } = new i18n(config);
loadTranslations(PUBLIC_LANGUAGE);
setLocale(PUBLIC_LANGUAGE);

View file

@ -0,0 +1,28 @@
{
"hello": "Ahoy, {{name}}!",
"nav": {
"log-in": "Report for duty",
"settings": "Pref'rences"
},
"avatar-tooltip": "Mugshot for {{name}}",
"profile": {
"edit-member-profile-notice": "You be viewin' the public persona of {memberName}.",
"edit-user-profile-notice": "You be viewin' yer public persona.",
"edit-profile-link": "Edit persona",
"names-header": "Names",
"pronouns-header": "Pronouns",
"default-members-header": "Members",
"create-member-button": "Create member"
},
"title": {
"log-in": "Report for duty",
"welcome": "Ahoy"
},
"auth": {
"log-in-form-title": "Use a message in a bottle",
"log-in-form-email-label": "Address",
"log-in-form-password-label": "Secret phrase",
"register-with-email-button": "Sign up",
"log-in-button": "Report for duty"
}
}

View file

@ -0,0 +1,63 @@
{
"hello": "Hello, {{name}}!",
"nav": {
"log-in": "Log in or sign up",
"settings": "Settings"
},
"avatar-tooltip": "Avatar for {{name}}",
"profile": {
"edit-member-profile-notice": "You are currently viewing the public profile of {memberName}.",
"edit-user-profile-notice": "You are currently viewing your public profile.",
"edit-profile-link": "Edit profile",
"names-header": "Names",
"pronouns-header": "Pronouns",
"default-members-header": "Members",
"create-member-button": "Create member"
},
"title": {
"log-in": "Log in",
"welcome": "Welcome"
},
"auth": {
"log-in-form-title": "Log in with email",
"log-in-form-email-label": "Email address",
"log-in-form-password-label": "Password",
"register-with-email-button": "Register with email",
"log-in-button": "Log in",
"log-in-3rd-party-header": "Log in with another service",
"log-in-3rd-party-desc": "If you prefer, you can also log in with one of these services:",
"log-in-with-discord": "Log in with Discord",
"log-in-with-google": "Log in with Google",
"log-in-with-tumblr": "Log in with Tumblr",
"log-in-with-the-fediverse": "Log in with the Fediverse",
"remote-fediverse-account-label": "Your Fediverse account",
"register-username-label": "Username",
"register-button": "Register account",
"register-with-mastodon": "Register with a Fediverse account",
"log-in-with-fediverse-error-blurb": "Is your instance returning an error?",
"log-in-with-fediverse-force-refresh-button": "Force a refresh on our end"
},
"error": {
"bad-request-header": "Something was wrong with your input",
"generic-header": "Something went wrong",
"raw-header": "Raw error",
"authentication-error": "Something went wrong when logging you in.",
"bad-request": "Your input was rejected by the server, please check for any mistakes and try again.",
"forbidden": "You are not allowed to perform that action.",
"internal-server-error": "Server experienced an internal error, please try again later.",
"authentication-required": "You need to log in first.",
"missing-scopes": "The current token is missing a required scope. Did you manually edit your cookies?",
"generic-error": "An unknown error occurred.",
"user-not-found": "User not found, please check your spelling and try again. Remember that usernames are case sensitive.",
"member-not-found": "Member not found, please check your spelling and try again.",
"account-already-linked": "This account is already linked with a pronouns.cc account.",
"last-auth-method": "You cannot remove your last authentication method.",
"validation-max-length-error": "Value is too long, maximum length is {{max}}, current length is {{actual}}.",
"validation-min-length-error": "Value is too long, minimum length is {{min}}, current length is {{actual}}.",
"validation-disallowed-value-1": "The following value is not allowed here",
"validation-disallowed-value-2": "Allowed values are",
"validation-reason": "Reason",
"validation-generic": "The value you entered is not allowed here. Reason",
"extra-info-header": "Extra error information"
}
}

View file

@ -0,0 +1,12 @@
// place files you want to import through the `$lib` alias in this folder.
import type { Cookies } from "@sveltejs/kit";
export const TOKEN_COOKIE_NAME = "__Host-pronounscc-token";
export const setToken = (cookies: Cookies, token: string) =>
cookies.set(TOKEN_COOKIE_NAME, token, { path: "/" });
export const clearToken = (cookies: Cookies) => cookies.delete(TOKEN_COOKIE_NAME, { path: "/" });
// TODO: change this to something we actually clearly have the rights to use
export const DEFAULT_AVATAR = "https://pronouns.cc/default/512.webp";

View file

@ -0,0 +1,4 @@
import { Logger } from "tslog";
const log = new Logger();
export default log;

View file

@ -0,0 +1,18 @@
import MarkdownIt from "markdown-it";
import sanitize from "sanitize-html";
const md = new MarkdownIt({
html: false,
breaks: true,
linkify: true,
}).disable(["heading", "lheading", "link", "table", "blockquote"]);
const unsafeMd = new MarkdownIt({
html: false,
breaks: true,
linkify: true,
});
export const renderMarkdown = (src: string | null) => (src ? sanitize(md.render(src)) : null);
export const renderUnsafeMarkdown = (src: string) => sanitize(unsafeMd.render(src));

View file

@ -0,0 +1,21 @@
import { clearToken, TOKEN_COOKIE_NAME } from "$lib";
import { apiRequest } from "$api";
import ApiError, { ErrorCode } from "$api/error";
import type { Meta, User } from "$api/models";
import log from "$lib/log";
import type { LayoutServerLoad } from "./$types";
export const load = (async ({ fetch, cookies }) => {
let meUser: User | null = null;
if (cookies.get(TOKEN_COOKIE_NAME)) {
try {
meUser = await apiRequest<User>("GET", "/users/@me", { fetch, cookies });
} catch (e) {
if (e instanceof ApiError && e.code === ErrorCode.AuthenticationRequired) clearToken(cookies);
else log.error("Could not fetch /users/@me and token has not expired:", e);
}
}
const meta = await apiRequest<Meta>("GET", "/meta", { fetch, cookies });
return { meta, meUser };
}) satisfies LayoutServerLoad;

View file

@ -0,0 +1,13 @@
<script lang="ts">
import type { Snippet } from "svelte";
import "../app.scss";
import type { LayoutData } from "./$types";
import Navbar from "$components/Navbar.svelte";
type Props = { children: Snippet; data: LayoutData };
let { children, data }: Props = $props();
</script>
<Navbar user={data.meUser} meta={data.meta} />
{@render children?.()}

View file

@ -0,0 +1,21 @@
<script lang="ts">
import type { PageData } from "./$types";
type Props = { data: PageData };
let { data }: Props = $props();
</script>
<svelte:head>
<title>pronouns.cc</title>
</svelte:head>
<div class="container">
<h1>pronouns.cc</h1>
<p>
{data.meta.repository}
{data.meta.version}
{data.meta.users.total}
{data.meta.limits.bio_length}
</p>
</div>

View file

@ -0,0 +1,20 @@
import { apiRequest } from "$api";
import type { UserWithMembers } from "$api/models";
export const load = async ({ params, fetch, cookies, url }) => {
const user = await apiRequest<UserWithMembers>("GET", `/users/${params.username}`, {
fetch,
cookies,
});
// Paginate members on the server side
let currentPage = Number(url.searchParams.get("page") || "0");
const pageCount = Math.ceil(user.members.length / 20);
let members = user.members.slice(currentPage * 20, (currentPage + 1) * 20);
if (members.length === 0) {
members = user.members.slice(0, 20);
currentPage = 0;
}
return { user, members, currentPage, pageCount };
};

View file

@ -0,0 +1,60 @@
<script lang="ts">
import type { PageData } from "./$types";
import ProfileHeader from "$components/profile/ProfileHeader.svelte";
import OwnProfileNotice from "$components/profile/OwnProfileNotice.svelte";
import { mergePreferences } from "$api/models";
import ProfileFields from "$components/profile/ProfileFields.svelte";
import { t } from "$lib/i18n";
import { Icon } from "@sveltestrap/sveltestrap";
import Paginator from "./Paginator.svelte";
import MemberCard from "$components/profile/user/MemberCard.svelte";
type Props = { data: PageData };
let { data }: Props = $props();
let allPreferences = $derived(mergePreferences(data.user.custom_preferences));
let isMeUser = $derived(data.meUser && data.meUser.id === data.user.id);
</script>
<svelte:head>
<title>@{data.user.username} • pronouns.cc</title>
</svelte:head>
<div class="container">
{#if isMeUser}
<OwnProfileNotice editLink="/settings/profile" />
{/if}
<ProfileHeader name="@{data.user.username}" profile={data.user} lazyLoadAvatar={true} />
<ProfileFields profile={data.user} {allPreferences} />
{#if data.members.length > 0}
<hr />
<h2>
{data.user.member_title || $t("profile.default-members-header")}
{#if isMeUser}
<a class="btn btn-success" href="/settings/create-member">
<Icon name="person-plus-fill" aria-hidden={true} />
{$t("profile.create-member-button")}
</a>
{/if}
<Paginator
currentPage={data.currentPage}
pageCount={data.pageCount}
href="/@{data.user.username}"
/>
</h2>
<div class="row row-cols-2 row-cols-md-3 row-cols-lg-4 row-cols-xl-5 text-center">
{#each data.members as member (member.id)}
<MemberCard username={data.user.username} {member} {allPreferences} />
{/each}
</div>
<div class="text-center">
<Paginator
currentPage={data.currentPage}
pageCount={data.pageCount}
href="/@{data.user.username}"
/>
</div>
{/if}
</div>

View file

@ -0,0 +1,31 @@
<script lang="ts">
import { Pagination, PaginationItem, PaginationLink } from "@sveltestrap/sveltestrap";
type Props = { currentPage: number; pageCount: number; href: string };
let { currentPage, pageCount, href }: Props = $props();
let prevPage = $derived(currentPage > 0 ? currentPage - 1 : 0);
let prevLink = $derived(prevPage !== 0 ? `${href}?page=${prevPage}` : href);
let nextPage = $derived(currentPage < pageCount - 1 ? currentPage + 1 : pageCount - 1);
</script>
{#if pageCount > 1}
<Pagination>
<PaginationItem>
<PaginationLink first {href} />
</PaginationItem>
<PaginationItem>
<PaginationLink previous href={prevLink} />
</PaginationItem>
<PaginationItem active>
<PaginationLink href="{href}?page={currentPage}">{currentPage + 1}</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink next href="{href}?page={nextPage}" />
</PaginationItem>
<PaginationItem>
<PaginationLink last href="{href}?page={pageCount - 1}" />
</PaginationItem>
</Pagination>
{/if}

View file

@ -0,0 +1,62 @@
import { apiRequest } from "$api";
import ApiError, { ErrorCode, type RawApiError } from "$api/error";
import type { AuthResponse, CallbackResponse } from "$api/models/auth.js";
import { setToken } from "$lib";
import log from "$lib/log.js";
import { isRedirect, redirect } from "@sveltejs/kit";
export const load = async ({ parent, params, url, fetch, cookies }) => {
const { meUser } = await parent();
if (meUser) redirect(303, `/@${meUser.username}`);
const code = url.searchParams.get("code") as string | null;
const state = url.searchParams.get("state") as string | null;
if (!code || !state) throw new ApiError(undefined, ErrorCode.BadRequest).obj;
const resp = await apiRequest<CallbackResponse>("POST", "/auth/fediverse/callback", {
body: { code, state, instance: params.instance },
isInternal: true,
fetch,
});
if (resp.has_account) {
setToken(cookies, resp.token!);
redirect(303, `/@${resp.user!.username}`);
}
return {
hasAccount: false,
instance: params.instance,
ticket: resp.ticket!,
remoteUser: resp.remote_username!,
};
};
export const actions = {
default: async ({ request, fetch, cookies }) => {
const data = await request.formData();
const username = data.get("username") as string | null;
const ticket = data.get("ticket") as string | null;
if (!username || !ticket)
return {
error: { message: "Bad request", code: ErrorCode.BadRequest, status: 403 } as RawApiError,
};
try {
const resp = await apiRequest<AuthResponse>("POST", "/auth/fediverse/register", {
body: { username, ticket },
isInternal: true,
fetch,
});
setToken(cookies, resp.token);
redirect(303, "/auth/welcome");
} catch (e) {
if (isRedirect(e)) throw e;
log.error("Could not sign up user with username %s:", username, e);
if (e instanceof ApiError) return { error: e.obj };
throw e;
}
},
};

View file

@ -0,0 +1,35 @@
<script lang="ts">
import { Button, Input, Label } from "@sveltestrap/sveltestrap";
import type { ActionData, PageData } from "./$types";
import { t } from "$lib/i18n";
import { enhance } from "$app/forms";
import ErrorAlert from "$components/ErrorAlert.svelte";
type Props = { data: PageData; form: ActionData };
let { data, form }: Props = $props();
</script>
<svelte:head>
<title>{$t("auth.register-with-mastodon")} • pronouns.cc</title>
</svelte:head>
<div class="container">
<h1>{$t("auth.register-with-mastodon")}</h1>
{#if form?.error}
<ErrorAlert error={form?.error} />
{/if}
<form method="POST" use:enhance>
<div class="mb-3">
<Label>{$t("auth.remote-fediverse-account-label")}</Label>
<Input type="text" readonly value={data.remoteUser} />
</div>
<div class="mb-3">
<Label>{$t("auth.register-username-label")}</Label>
<Input type="text" name="username" required />
</div>
<input type="hidden" name="ticket" value={data.ticket} />
<Button color="primary" type="submit">{$t("auth.register-button")}</Button>
</form>
</div>

View file

@ -0,0 +1,79 @@
import { isRedirect, redirect } from "@sveltejs/kit";
import { apiRequest } from "$api";
import type { AuthResponse, AuthUrls } from "$api/models/auth";
import { setToken } from "$lib";
import ApiError, { ErrorCode } from "$api/error";
export const load = async ({ fetch, parent }) => {
const parentData = await parent();
if (parentData.meUser) redirect(303, `/@${parentData.meUser.username}`);
const urls = await apiRequest<AuthUrls>("POST", "/auth/urls", { fetch, isInternal: true });
return { urls };
};
export const actions = {
login: async ({ request, fetch, cookies }) => {
const body = await request.formData();
const email = body.get("email") as string | null;
const password = body.get("password") as string | null;
try {
const resp = await apiRequest<AuthResponse>("POST", "/auth/email/login", {
body: { email, password },
fetch,
isInternal: true,
});
setToken(cookies, resp.token);
redirect(303, `/@${resp.user.username}`);
} catch (e) {
if (isRedirect(e)) throw e;
if (e instanceof ApiError) return { error: e.obj };
throw e;
}
},
fediToggle: () => {
return { error: null, showFediBox: true };
},
fedi: async ({ request, fetch }) => {
const body = await request.formData();
const instance = body.get("instance") as string | null;
if (!instance) return { error: new ApiError(undefined, ErrorCode.BadRequest).obj };
try {
const resp = await apiRequest<{ url: string }>(
"GET",
`/auth/fediverse?instance=${encodeURIComponent(instance)}`,
{ fetch, isInternal: true },
);
redirect(303, resp.url);
} catch (e) {
if (isRedirect(e)) throw e;
if (e instanceof ApiError) return { error: e.obj };
throw e;
}
},
fediForceRefresh: async ({ request, fetch }) => {
const body = await request.formData();
const instance = body.get("instance") as string | null;
if (!instance) return { error: new ApiError(undefined, ErrorCode.BadRequest).obj };
try {
const resp = await apiRequest<{ url: string }>(
"GET",
`/auth/fediverse?instance=${encodeURIComponent(instance)}&forceRefresh=true`,
{ fetch, isInternal: true },
);
redirect(303, resp.url);
} catch (e) {
if (isRedirect(e)) throw e;
if (e instanceof ApiError) return { error: e.obj };
throw e;
}
},
};

View file

@ -0,0 +1,88 @@
<script lang="ts">
import type { ActionData, PageData } from "./$types";
import { t } from "$lib/i18n";
import { enhance } from "$app/forms";
import { Button, ButtonGroup, Input, InputGroup } from "@sveltestrap/sveltestrap";
import ErrorAlert from "$components/ErrorAlert.svelte";
type Props = { data: PageData; form: ActionData };
let { data, form }: Props = $props();
</script>
<svelte:head>
<title>{$t("title.log-in")} • pronouns.cc</title>
</svelte:head>
<div class="container">
<div class="row">
{#if form?.error}
<ErrorAlert error={form.error} />
{/if}
</div>
<div class="row">
{#if data.urls.email_enabled}
<div class="col col-md mb-4">
<h2>{$t("auth.log-in-form-title")}</h2>
<form method="POST" action="?/login" use:enhance>
<div class="mb-2">
<label class="form-label" for="email">{$t("auth.log-in-form-email-label")}</label>
<Input type="email" id="email" name="email" placeholder="me@example.com" />
</div>
<div class="mb-2">
<label class="form-label" for="password">{$t("auth.log-in-form-password-label")}</label>
<Input type="password" id="password" name="password" />
</div>
<ButtonGroup>
<Button type="submit" color="primary">{$t("auth.log-in-button")}</Button>
<a class="btn btn-secondary" href="/auth/register">
{$t("auth.register-with-email-button")}
</a>
</ButtonGroup>
</form>
</div>
{:else}
<div class="col-lg-3"></div>
{/if}
<div class="col col-md">
<h3>{$t("auth.log-in-3rd-party-header")}</h3>
<p>{$t("auth.log-in-3rd-party-desc")}</p>
<form method="POST" action="?/fediToggle" use:enhance>
<div class="list-group">
{#if data.urls.discord}
<a href={data.urls.discord} class="list-group-item list-group-item-action">
{$t("auth.log-in-with-discord")}
</a>
{/if}
{#if data.urls.google}
<a href={data.urls.google} class="list-group-item list-group-item-action">
{$t("auth.log-in-with-google")}
</a>
{/if}
{#if data.urls.tumblr}
<a href={data.urls.tumblr} class="list-group-item list-group-item-action">
{$t("auth.log-in-with-tumblr")}
</a>
{/if}
<button type="submit" class="list-group-item list-group-item-action">
{$t("auth.log-in-with-the-fediverse")}
</button>
</div>
</form>
{#if form?.showFediBox}
<h4 class="mt-4">{$t("auth.log-in-with-the-fediverse")}</h4>
<form method="POST" action="?/fedi" use:enhance>
<InputGroup>
<Input name="instance" type="text" placeholder="Your instance (i.e. mastodon.social)" />
<Button type="submit" color="secondary">{$t("auth.log-in-button")}</Button>
</InputGroup>
<p>
{$t("auth.log-in-with-fediverse-error-blurb")}
<Button formaction="?/fediForceRefresh" type="submit" color="link">
{$t("auth.log-in-with-fediverse-force-refresh-button")}
</Button>
</p>
</form>
{/if}
</div>
</div>
</div>

View file

@ -0,0 +1,6 @@
import { redirect } from "@sveltejs/kit";
export const load = async ({ parent }) => {
const { meUser } = await parent();
if (!meUser) redirect(303, "/auth/log-in");
};

View file

@ -0,0 +1,34 @@
<script lang="ts">
import { t } from "$lib/i18n";
import type { PageData } from "./$types";
type Props = { data: PageData };
let { data }: Props = $props();
let user = $derived(data.meUser!);
</script>
<svelte:head>
<title>{$t("title.welcome")} • pronouns.cc</title>
</svelte:head>
<div class="container">
<h2>Welcome to pronouns.cc!</h2>
<div class="alert alert-secondary">
<a href="/@{user.username}">If you just want to go to your profile, click here</a>
</div>
<h3>Customize your profile</h3>
<!-- TODO: this needs actual content, obviously -->
<p>(todo)</p>
<h3>Create members</h3>
<p>(todo)</p>
<h3>Create custom preferences</h3>
<p>(todo)</p>
<p>
<a class="btn btn-primary btn-lg" href="/@{user.username}">Check out your profile</a>
</p>
</div>