you know what let's just change frontend framework again
This commit is contained in:
parent
c8cd483d20
commit
0d47f1fb01
115 changed files with 4407 additions and 10824 deletions
54
Foxnouns.Frontend/src/lib/api/error.ts
Normal file
54
Foxnouns.Frontend/src/lib/api/error.ts
Normal 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;
|
||||
};
|
92
Foxnouns.Frontend/src/lib/api/index.ts
Normal file
92
Foxnouns.Frontend/src/lib/api/index.ts
Normal 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);
|
||||
}
|
23
Foxnouns.Frontend/src/lib/api/models/auth.ts
Normal file
23
Foxnouns.Frontend/src/lib/api/models/auth.ts
Normal 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;
|
||||
};
|
4
Foxnouns.Frontend/src/lib/api/models/index.ts
Normal file
4
Foxnouns.Frontend/src/lib/api/models/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export * from "./meta";
|
||||
export * from "./user";
|
||||
export * from "./member";
|
||||
export * from "./auth";
|
9
Foxnouns.Frontend/src/lib/api/models/member.ts
Normal file
9
Foxnouns.Frontend/src/lib/api/models/member.ts
Normal 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;
|
||||
};
|
19
Foxnouns.Frontend/src/lib/api/models/meta.ts
Normal file
19
Foxnouns.Frontend/src/lib/api/models/meta.ts
Normal 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;
|
||||
};
|
139
Foxnouns.Frontend/src/lib/api/models/user.ts
Normal file
139
Foxnouns.Frontend/src/lib/api/models/user.ts
Normal 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,
|
||||
},
|
||||
});
|
14
Foxnouns.Frontend/src/lib/components/Avatar.svelte
Normal file
14
Foxnouns.Frontend/src/lib/components/Avatar.svelte
Normal 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"}
|
||||
/>
|
32
Foxnouns.Frontend/src/lib/components/Error.svelte
Normal file
32
Foxnouns.Frontend/src/lib/components/Error.svelte
Normal 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>
|
11
Foxnouns.Frontend/src/lib/components/ErrorAlert.svelte
Normal file
11
Foxnouns.Frontend/src/lib/components/ErrorAlert.svelte
Normal 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>
|
34
Foxnouns.Frontend/src/lib/components/Logo.svelte
Normal file
34
Foxnouns.Frontend/src/lib/components/Logo.svelte
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 7.9 KiB |
69
Foxnouns.Frontend/src/lib/components/Navbar.svelte
Normal file
69
Foxnouns.Frontend/src/lib/components/Navbar.svelte
Normal 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>
|
17
Foxnouns.Frontend/src/lib/components/StatusIcon.svelte
Normal file
17
Foxnouns.Frontend/src/lib/components/StatusIcon.svelte
Normal 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>
|
|
@ -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>
|
|
@ -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}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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}
|
|
@ -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}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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}
|
|
@ -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>
|
36
Foxnouns.Frontend/src/lib/errorCodes.svelte.ts
Normal file
36
Foxnouns.Frontend/src/lib/errorCodes.svelte.ts
Normal 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");
|
||||
}
|
24
Foxnouns.Frontend/src/lib/i18n/index.ts
Normal file
24
Foxnouns.Frontend/src/lib/i18n/index.ts
Normal 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);
|
28
Foxnouns.Frontend/src/lib/i18n/locales/en-PR.json
Normal file
28
Foxnouns.Frontend/src/lib/i18n/locales/en-PR.json
Normal 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"
|
||||
}
|
||||
}
|
63
Foxnouns.Frontend/src/lib/i18n/locales/en.json
Normal file
63
Foxnouns.Frontend/src/lib/i18n/locales/en.json
Normal 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"
|
||||
}
|
||||
}
|
12
Foxnouns.Frontend/src/lib/index.ts
Normal file
12
Foxnouns.Frontend/src/lib/index.ts
Normal 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";
|
4
Foxnouns.Frontend/src/lib/log.ts
Normal file
4
Foxnouns.Frontend/src/lib/log.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
import { Logger } from "tslog";
|
||||
|
||||
const log = new Logger();
|
||||
export default log;
|
18
Foxnouns.Frontend/src/lib/markdown.ts
Normal file
18
Foxnouns.Frontend/src/lib/markdown.ts
Normal 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));
|
Loading…
Add table
Add a link
Reference in a new issue