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

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>