Compare commits

..

3 commits

119 changed files with 4649 additions and 10781 deletions

View file

@ -3,7 +3,7 @@
"tasks": [ "tasks": [
{ {
"name": "run-prettier", "name": "run-prettier",
"command": "yarn", "command": "pnpm",
"args": ["format"], "args": ["format"],
"pathMode": "absolute" "pathMode": "absolute"
}, },

View file

@ -11,6 +11,7 @@ using NodaTime;
using Prometheus; using Prometheus;
using Serilog; using Serilog;
using Serilog.Events; using Serilog.Events;
using Serilog.Sinks.SystemConsole.Themes;
using FediverseAuthService = Foxnouns.Backend.Services.Auth.FediverseAuthService; using FediverseAuthService = Foxnouns.Backend.Services.Auth.FediverseAuthService;
using IClock = NodaTime.IClock; using IClock = NodaTime.IClock;
@ -38,7 +39,7 @@ public static class WebApplicationExtensions
.MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning)
.WriteTo.Console(); .WriteTo.Console(theme: AnsiConsoleTheme.Sixteen);
if (config.Logging.SeqLogUrl != null) if (config.Logging.SeqLogUrl != null)
{ {

View file

@ -0,0 +1,5 @@
# Example .env file--DO NOT EDIT
PUBLIC_LANGUAGE=en
PUBLIC_API_BASE=https://pronouns.cc/api
PRIVATE_API_HOST=http://localhost:5003/api
PRIVATE_INTERNAL_API_HOST=http://localhost:5000/api

View file

@ -1,84 +0,0 @@
/**
* This is intended to be a basic starting point for linting in your app.
* It relies on recommended configs out of the box for simplicity, but you can
* and should modify this configuration to best suit your team's needs.
*/
/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
},
env: {
browser: true,
commonjs: true,
es6: true,
},
ignorePatterns: ["!**/.server", "!**/.client"],
// Base config
extends: ["eslint:recommended"],
overrides: [
// React
{
files: ["**/*.{js,jsx,ts,tsx}"],
plugins: ["react", "jsx-a11y"],
extends: [
"plugin:react/recommended",
"plugin:react/jsx-runtime",
"plugin:react-hooks/recommended",
"plugin:jsx-a11y/recommended",
],
settings: {
react: {
version: "detect",
},
formComponents: ["Form"],
linkComponents: [
{ name: "Link", linkAttribute: "to" },
{ name: "NavLink", linkAttribute: "to" },
],
"import/resolver": {
typescript: {},
},
},
},
// Typescript
{
files: ["**/*.{ts,tsx}"],
plugins: ["@typescript-eslint", "import"],
parser: "@typescript-eslint/parser",
settings: {
"import/internal-regex": "^~/",
"import/resolver": {
node: {
extensions: [".ts", ".tsx"],
},
typescript: {
alwaysTryTypes: true,
},
},
},
extends: [
"plugin:@typescript-eslint/recommended",
"plugin:import/recommended",
"plugin:import/typescript",
],
},
// Node
{
files: [".eslintrc.cjs"],
env: {
node: true,
},
},
],
};

View file

@ -1,5 +1,21 @@
node_modules node_modules
/.cache # Output
.output
.vercel
/.svelte-kit
/build /build
# OS
.DS_Store
Thumbs.db
# Env
.env .env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
Foxnouns.Frontend/.npmrc Normal file
View file

@ -0,0 +1 @@
engine-strict=true

View file

@ -0,0 +1,4 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock

View file

@ -1,4 +1,13 @@
{ {
"useTabs": true, "useTabs": true,
"printWidth": 100 "printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
} }

View file

@ -0,0 +1,8 @@
{
"editor.formatOnSave": true,
"editor.formatOnSaveMode": "modificationsIfAvailable",
"i18n-ally.localesPaths": ["src/lib/i18n", "src/lib/i18n/locales"],
"i18n-ally.keystyle": "nested",
"explorer.sortOrder": "filesFirst",
"explorer.compactFolders": false
}

View file

@ -1,12 +0,0 @@
FROM docker.io/node:22
RUN mkdir -p /app/node_modules && chown -R node:node /app
WORKDIR /app
COPY package.json yarn.lock ./
USER node
RUN yarn
COPY --chown=node:node . .
RUN yarn build
CMD ["yarn", "start"]

View file

@ -1,40 +1,38 @@
# Welcome to Remix! # sv
- 📖 [Remix docs](https://remix.run/docs) Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Development ## Creating a project
Run the dev server: If you're seeing this, you've probably already done this step. Congrats!
```shellscript ```bash
npm run dev # create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
``` ```
## Deployment ## Developing
First, build your app for production: Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh ```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build npm run build
``` ```
Then run the app in production mode: You can preview the production build with `npm run preview`.
```sh > To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
npm start
```
Now you'll need to pick a host to deploy it to.
### DIY
If you're familiar with deploying Node applications, the built-in Remix app server is production-ready.
Make sure to deploy the output of `npm run build`
- `build/server`
- `build/client`
## Styling
This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever css framework you prefer. See the [Vite docs on css](https://vitejs.dev/guide/features.html#css) for more information.

View file

@ -1,150 +0,0 @@
import { TFunction } from "i18next";
import { Alert } from "react-bootstrap";
import { Trans, useTranslation } from "react-i18next";
import {
ApiError,
ErrorCode,
ValidationError,
validationErrorType,
ValidationErrorType,
} from "~/lib/api/error";
export default function ErrorAlert({ error }: { error: ApiError }) {
const { t } = useTranslation();
return (
<Alert variant="danger">
<Alert.Heading as="h4">{t("error.heading")}</Alert.Heading>
{errorCodeDesc(t, error.code)}
{error.errors && (
<ul>
{error.errors.map((e, i) => (
<ValidationErrors key={i} errorKey={e.key} errors={e.errors} />
))}
</ul>
)}
</Alert>
);
}
function ValidationErrors({ errorKey, errors }: { errorKey: string; errors: ValidationError[] }) {
return (
<li>
<strong>
<code>{errorKey}</code>
</strong>
:
<ul>
{errors.map((e, i) => (
<li key={i}>
<ValidationErrorEntry error={e} />
</li>
))}
</ul>
</li>
);
}
function ValidationErrorEntry({ error }: { error: ValidationError }) {
const { t } = useTranslation();
const {
min_length: minLength,
max_length: maxLength,
actual_length: actualLength,
message: reason,
actual_value: actualValue,
allowed_values: allowedValues,
} = error;
switch (validationErrorType(error)) {
case ValidationErrorType.LengthError:
if (error.actual_length! > error.max_length!) {
return (
<Trans
t={t}
i18nKey={"error.validation.too-long"}
values={{ maxLength: error.max_length!, actualLength: error.actual_length! }}
>
Value is too long, maximum length is {{ maxLength }}, current length is{" "}
{{ actualLength }}.
</Trans>
);
}
if (error.actual_length! < error.min_length!) {
return (
<Trans
t={t}
i18nKey={"error.validation.too-short"}
values={{ minLength: error.min_length!, actualLength: error.actual_length! }}
>
Value is too short, minimum length is {{ minLength }}, current length is{" "}
{{ actualLength }}.
</Trans>
);
}
break;
case ValidationErrorType.DisallowedValueError:
return (
<Trans
t={t}
i18nKey={"error.validation.disallowed-value"}
values={{
actualValue: error.actual_value!.toString(),
allowedValues: error.allowed_values!.map((v) => v.toString()).join(", "),
}}
>
{/* @ts-expect-error i18next handles interpolation */}
The value <code>{{ actualValue }}</code> is not allowed here. Allowed values are:{" "}
{/* @ts-expect-error i18next handles interpolation */}
<code>{{ allowedValues }}</code>
</Trans>
);
default:
if (error.actual_value) {
return (
<Trans
t={t}
i18nKey={"error.validation.generic"}
values={{ actualValue: error.actual_value!.toString(), reason: error.message }}
>
{/* @ts-expect-error i18next handles interpolation */}
The value <code>{{ actualValue }}</code> is not allowed here. Reason: {{ reason }}
</Trans>
);
}
return <>{t("error.validation.generic-no-value", { reason: error.message })}</>;
}
}
export const errorCodeDesc = (t: TFunction, code: ErrorCode) => {
switch (code) {
case ErrorCode.AuthenticationError:
return t("error.errors.authentication-error");
case ErrorCode.AuthenticationRequired:
return t("error.errors.authentication-required");
case ErrorCode.BadRequest:
return t("error.errors.bad-request");
case ErrorCode.Forbidden:
return t("error.errors.forbidden");
case ErrorCode.GenericApiError:
return t("error.errors.generic-error");
case ErrorCode.InternalServerError:
return t("error.errors.internal-server-error");
case ErrorCode.MemberNotFound:
return t("error.errors.member-not-found");
case ErrorCode.UserNotFound:
return t("error.errors.user-not-found");
case ErrorCode.AccountAlreadyLinked:
return t("error.errors.account-already-linked");
case ErrorCode.LastAuthMetod:
return t("error.errors.last-auth-method");
}
return t("error.errors.generic-error");
};

View file

@ -1,16 +0,0 @@
import * as icons from "react-bootstrap-icons";
import { IconProps as BaseIconProps } from "react-bootstrap-icons";
import { pascalCase } from "change-case";
const startsWithNumberRegex = /^\d/;
export default function Icon({ iconName, ...props }: BaseIconProps & { iconName: string }) {
let icon = pascalCase(iconName);
if (startsWithNumberRegex.test(icon)) {
icon = `Icon${icon}`;
}
// eslint-disable-next-line import/namespace
const BootstrapIcon = icons[icon as keyof typeof icons];
return <BootstrapIcon {...props} />;
}

View file

@ -1,36 +0,0 @@
import { ApiError, firstErrorFor } from "~/lib/api/error";
import { Trans, useTranslation } from "react-i18next";
import { Alert } from "react-bootstrap";
import { Link } from "@remix-run/react";
import ErrorAlert from "~/components/ErrorAlert";
export default function RegisterError({ error }: { error: ApiError }) {
const { t } = useTranslation();
// TODO: maybe turn these messages into their own error codes?
const ticketMessage = firstErrorFor(error, "ticket")?.message;
const usernameMessage = firstErrorFor(error, "username")?.message;
if (ticketMessage === "Invalid ticket") {
return (
<Alert variant="danger">
<Alert.Heading as="h4">{t("error.heading")}</Alert.Heading>
<Trans t={t} i18nKey={"log-in.callback.invalid-ticket"}>
Invalid ticket (it might have been too long since you logged in), please{" "}
<Link to="/auth/log-in">try again</Link>.
</Trans>
</Alert>
);
}
if (usernameMessage === "Username is already taken") {
return (
<Alert variant="danger">
<Alert.Heading as="h4">{t("log-in.callback.invalid-username")}</Alert.Heading>
{t("log-in.callback.username-taken")}
</Alert>
);
}
return <ErrorAlert error={error} />;
}

View file

@ -1,22 +0,0 @@
import { ReactNode } from "react";
import { Nav, Navbar } from "react-bootstrap";
import { Link } from "@remix-run/react";
import Logo from "~/components/nav/Logo";
export default function BaseNavbar({ children }: { children?: ReactNode }) {
return (
<Navbar expand="lg" className={`mb-4 mx-2`}>
<Navbar.Brand to="/" as={Link}>
<Logo />
</Navbar.Brand>
{children && (
<>
<Navbar.Toggle aria-controls="main-navbar" />
<Navbar.Collapse id="main-navbar">
<Nav className="ms-auto">{children}</Nav>
</Navbar.Collapse>
</>
)}
</Navbar>
);
}

File diff suppressed because one or more lines are too long

View file

@ -1,35 +0,0 @@
import { Link, useFetcher } from "@remix-run/react";
import Meta from "~/lib/api/meta";
import { User } from "~/lib/api/user";
import { Nav, NavDropdown } from "react-bootstrap";
import { useTranslation } from "react-i18next";
import BaseNavbar from "~/components/nav/BaseNavbar";
export default function MainNavbar({ user }: { meta: Meta; user?: User }) {
const fetcher = useFetcher();
const { t } = useTranslation();
const userMenu = user ? (
<NavDropdown title={<>@{user.username}</>} align="end">
<NavDropdown.Item as={Link} to={`/@${user.username}`}>
{t("navbar.view-profile")}
</NavDropdown.Item>
<NavDropdown.Item as={Link} to="/settings">
{t("navbar.settings")}
</NavDropdown.Item>
<NavDropdown.Divider />
<fetcher.Form method="POST" action="/auth/log-out">
<NavDropdown.Item as="button" type="submit">
{t("navbar.log-out")}
</NavDropdown.Item>
</fetcher.Form>
</NavDropdown>
) : (
<Nav.Link to="/auth/log-in" as={Link}>
{t("navbar.log-in")}
</Nav.Link>
);
return <BaseNavbar>{userMenu}</BaseNavbar>;
}

View file

@ -1,22 +0,0 @@
export default function AvatarImage({
src,
width,
alt,
lazyLoad,
}: {
src: string;
width: number;
alt: string;
lazyLoad?: boolean;
}) {
return (
<img
src={src}
alt={alt}
width={width}
height={width}
className="rounded-circle img-fluid"
loading={lazyLoad ? "lazy" : "eager"}
/>
);
}

View file

@ -1,118 +0,0 @@
import { CustomPreference, User } from "~/lib/api/user";
import { Member } from "~/lib/api/member";
import { defaultAvatarUrl } from "~/lib/utils";
import ProfileFlag from "~/components/profile/ProfileFlag";
import ProfileLink from "~/components/profile/ProfileLink";
import ProfileField from "~/components/profile/ProfileField";
import { useTranslation } from "react-i18next";
import { renderMarkdown } from "~/lib/markdown";
import AvatarImage from "~/components/profile/AvatarImage";
export type Props = {
name: string;
fullName?: string;
userI18nKeys: boolean;
profile: User | Member;
customPreferences: Record<string, CustomPreference>;
};
export default function BaseProfile({
name,
userI18nKeys,
fullName,
profile,
customPreferences,
}: Props) {
const { t } = useTranslation();
const bio = renderMarkdown(profile.bio);
return (
<>
<div className="grid row-gap-3">
<div className="row">
<div className="col-md-4 text-center">
{userI18nKeys ? (
<AvatarImage
src={profile.avatar_url || defaultAvatarUrl}
width={200}
alt={t("user.avatar-alt", { username: name })}
/>
) : (
<AvatarImage
src={profile.avatar_url || defaultAvatarUrl}
width={200}
alt={t("member.avatar-alt", { name: name })}
/>
)}
{profile.flags && profile.bio && (
<div className="d-flex flex-wrap m-4">
{profile.flags.map((f, i) => (
<ProfileFlag flag={f} key={i} />
))}
</div>
)}
</div>
<div className="col-md">
{profile.display_name || fullName ? (
<>
<h2>{profile.display_name || name}</h2>
<p className="fs-5 text-body-secondary">{fullName || `@${name}`}</p>
</>
) : (
<>
<h2>{fullName || `@${name}`}</h2>
</>
)}
{bio && (
<>
<hr />
<p dangerouslySetInnerHTML={{ __html: bio }}></p>
</>
)}
</div>
{profile.links.length > 0 && (
<div className="col-md d-flex align-items-center">
<ul className="list-unstyled">
{profile.links.map((l, i) => (
<ProfileLink link={l} key={i} />
))}
</ul>
</div>
)}
</div>
<div className="row row-cols-1 row-cols-sm-2 row-cols-md-3">
{profile.names.length > 0 && (
<ProfileField
name={t("user.heading.names")}
entries={profile.names}
preferences={customPreferences}
/>
)}
{profile.pronouns.length > 0 && (
<ProfileField
name={t("user.heading.pronouns")}
entries={profile.pronouns}
preferences={customPreferences}
/>
)}
{profile.fields.map((f, i) => (
<ProfileField
name={f.name}
entries={f.entries}
preferences={customPreferences}
key={i}
/>
))}
</div>
</div>
{/* If a user or member has no bio, flags are displayed in a row below the other profile info, rather than just below the avatar */}
{profile.flags && !profile.bio && (
<div className="d-flex flex-wrap m-4">
{profile.flags.map((f, i) => (
<ProfileFlag flag={f} key={i} />
))}
</div>
)}
</>
);
}

View file

@ -1,25 +0,0 @@
import { CustomPreference, FieldEntry, Pronoun } from "~/lib/api/user";
import StatusLine from "~/components/profile/StatusLine";
export default function ProfileField({
name,
entries,
preferences,
}: {
name: string;
entries: Array<FieldEntry | Pronoun>;
preferences: Record<string, CustomPreference>;
}) {
return (
<div className="col">
<h3>{name}</h3>
<ul className="list-unstyled fs-5">
{entries.map((e, i) => (
<li key={i}>
<StatusLine entry={e} preferences={preferences} />
</li>
))}
</ul>
</div>
);
}

View file

@ -1,28 +0,0 @@
import type { PrideFlag } from "~/lib/api/user";
import { OverlayTrigger, Tooltip } from "react-bootstrap";
export default function ProfileFlag({ flag }: { flag: PrideFlag }) {
return (
<span className="mx-2 my-1">
<OverlayTrigger
key={flag.id}
placement="top"
overlay={
<Tooltip id={flag.id} aria-hidden={true}>
{flag.description ?? flag.name}
</Tooltip>
}
>
<span>
<img
className="pride-flag"
src={flag.image_url}
alt={flag.description ?? flag.name}
style={{ pointerEvents: "none" }}
/>
</span>
</OverlayTrigger>{" "}
{flag.name}
</span>
);
}

View file

@ -1,27 +0,0 @@
import { Globe } from "react-bootstrap-icons";
export default function ProfileLink({ link }: { link: string }) {
const isLink = link.startsWith("http://") || link.startsWith("https://");
let displayLink = link;
if (link.startsWith("http://")) displayLink = link.substring("http://".length);
else if (link.startsWith("https://")) displayLink = link.substring("https://".length);
if (displayLink.endsWith("/")) displayLink = displayLink.substring(0, displayLink.length - 1);
if (isLink) {
return (
<a href={link} className="text-decoration-none" rel="me nofollow noreferrer" target="_blank">
<li className="py-2 py-lg-0">
<Globe className="text-body" aria-hidden={true} />{" "}
<span className="text-decoration-underline">{displayLink}</span>
</li>
</a>
);
}
return (
<li className="py-2 py-lg-0">
<Globe aria-hidden={true} /> {displayLink}
</li>
);
}

View file

@ -1,32 +0,0 @@
import { Pronoun } from "~/lib/api/user";
import { Link } from "@remix-run/react";
export default function PronounLink({ pronoun }: { pronoun: Pronoun }) {
let displayText: string;
if (pronoun.display_text) displayText = pronoun.display_text;
else {
const split = pronoun.value.split("/");
if (split.length === 5) displayText = split.splice(0, 2).join("/");
else displayText = pronoun.value;
}
let link: string;
const linkBase = pronoun.value
.split("/")
.map((value) => encodeURIComponent(value))
.join("/");
if (pronoun.display_text) {
link = `${linkBase},${encodeURIComponent(pronoun.display_text)}`;
} else {
link = linkBase;
}
return pronoun.value.split("/").length === 5 ? (
<Link className="text-reset" to={`/pronouns/${link}`}>
{displayText}
</Link>
) : (
<>{displayText}</>
);
}

View file

@ -1,34 +0,0 @@
import { CustomPreference, defaultPreferences, mergePreferences } from "~/lib/api/user";
import { OverlayTrigger, Tooltip } from "react-bootstrap";
import Icon from "~/components/KeyedIcon";
export default function StatusIcon({
preferences,
status,
}: {
preferences: Record<string, CustomPreference>;
status: string;
}) {
const mergedPrefs = mergePreferences(preferences);
const currentPref = status in mergedPrefs ? mergedPrefs[status] : defaultPreferences.missing;
const id = crypto.randomUUID();
return (
<>
<OverlayTrigger
key={id}
placement="top"
overlay={
<Tooltip id={id} aria-hidden={true}>
{currentPref.tooltip}
</Tooltip>
}
>
<span className="d-inline-block">
<Icon iconName={currentPref.icon} aria-hidden={true} style={{ pointerEvents: "none" }} />
</span>
</OverlayTrigger>
<span className="visually-hidden">{currentPref.tooltip}:</span>
</>
);
}

View file

@ -1,45 +0,0 @@
import {
CustomPreference,
defaultPreferences,
FieldEntry,
mergePreferences,
PreferenceSize,
Pronoun,
} from "~/lib/api/user";
import classNames from "classnames";
import StatusIcon from "~/components/profile/StatusIcon";
import PronounLink from "~/components/profile/PronounLink";
export default function StatusLine({
entry,
preferences,
}: {
entry: FieldEntry | Pronoun;
preferences: Record<string, CustomPreference>;
}) {
const mergedPrefs = mergePreferences(preferences);
const currentPref =
entry.status in mergedPrefs ? mergedPrefs[entry.status] : defaultPreferences.missing;
const classes = classNames({
"text-muted": currentPref.muted,
"fw-bold fs-5": currentPref.size == PreferenceSize.Large,
"fs-6": currentPref.size == PreferenceSize.Small,
});
if ("display_text" in entry) {
const pronoun = entry as Pronoun;
return (
<span className={classes}>
<StatusIcon preferences={preferences} status={entry.status} />{" "}
<PronounLink pronoun={pronoun} />
</span>
);
}
return (
<span className={classes}>
<StatusIcon preferences={preferences} status={entry.status} /> {entry.value}
</span>
);
}

View file

@ -1,50 +0,0 @@
import { RemixBrowser } from "@remix-run/react";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import i18n from "./i18n";
import i18next from "i18next";
import { I18nextProvider, initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import Backend from "i18next-http-backend";
import { getInitialNamespaces } from "remix-i18next/client";
async function hydrate() {
await i18next
.use(initReactI18next) // Tell i18next to use the react-i18next plugin
.use(LanguageDetector) // Set up a client-side language detector
.use(Backend) // Setup your backend
.init({
...i18n, // spread the configuration
// This function detects the namespaces your routes rendered while SSR use
ns: getInitialNamespaces(),
backend: { loadPath: "/locales/{{lng}}.json" },
detection: {
// Here only enable htmlTag detection, we'll detect the language only
// server-side with remix-i18next, by using the `<html lang>` attribute
// we can communicate to the client the language detected server-side
order: ["htmlTag"],
// Because we only use htmlTag, there's no reason to cache the language
// on the browser, so we disable it
caches: [],
},
});
startTransition(() => {
hydrateRoot(
document,
<I18nextProvider i18n={i18next}>
<StrictMode>
<RemixBrowser />
</StrictMode>
</I18nextProvider>,
);
});
}
if (window.requestIdleCallback) {
window.requestIdleCallback(hydrate);
} else {
// Safari doesn't support requestIdleCallback
// https://caniuse.com/requestidlecallback
window.setTimeout(hydrate, 1);
}

View file

@ -1,72 +0,0 @@
import { PassThrough } from "stream";
import { createReadableStreamFromReadable, type EntryContext } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import { isbot } from "isbot";
import { renderToPipeableStream } from "react-dom/server";
import { createInstance } from "i18next";
import i18next from "./i18next.server";
import { I18nextProvider, initReactI18next } from "react-i18next";
import Backend from "i18next-fs-backend";
import i18n from "./i18n"; // your i18n configuration file
import { resolve } from "node:path";
const ABORT_DELAY = 5000;
export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
) {
const callbackName = isbot(request.headers.get("user-agent")) ? "onAllReady" : "onShellReady";
const instance = createInstance();
const lng = await i18next.getLocale(request);
const ns = i18next.getRouteNamespaces(remixContext);
await instance
.use(initReactI18next) // Tell our instance to use react-i18next
.use(Backend) // Set up our backend
.init({
...i18n, // spread the configuration
lng, // The locale we detected above
ns, // The namespaces the routes about to render wants to use
backend: { loadPath: resolve("./public/locales/{{lng}}.json") },
});
return new Promise((resolve, reject) => {
let didError = false;
const { pipe, abort } = renderToPipeableStream(
<I18nextProvider i18n={instance}>
<RemixServer context={remixContext} url={request.url} />
</I18nextProvider>,
{
[callbackName]: () => {
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(stream, {
headers: responseHeaders,
status: didError ? 500 : responseStatusCode,
}),
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
didError = true;
console.error(error);
},
},
);
setTimeout(abort, ABORT_DELAY);
});
}

View file

@ -1,6 +0,0 @@
import "dotenv/config";
import { env } from "node:process";
export const API_BASE = env.API_BASE || "https://pronouns.localhost/api";
export const INTERNAL_API_BASE = env.INTERNAL_API_BASE || "https://localhost:5000/api";
export const LANGUAGE = env.LANGUAGE || "en";

View file

@ -1,5 +0,0 @@
export default {
supportedLngs: ["en", "en-XX"],
fallbackLng: "en",
defaultNS: "common",
};

View file

@ -1,28 +0,0 @@
import Backend from "i18next-fs-backend";
import { resolve } from "node:path";
import { RemixI18Next } from "remix-i18next/server";
import i18n from "~/i18n";
import { LANGUAGE } from "~/env.server";
const i18next = new RemixI18Next({
detection: {
supportedLanguages: [LANGUAGE],
fallbackLanguage: LANGUAGE,
},
// This is the configuration for i18next used
// when translating messages server-side only
i18next: {
...i18n,
fallbackLng: LANGUAGE,
lng: LANGUAGE,
backend: {
loadPath: resolve("./public/locales/{{lng}}.json"),
},
},
// The i18next plugins you want RemixI18next to use for `i18n.getFixedT` inside loaders and actions.
// E.g. The Backend plugin for loading translations from the file system
// Tip: You could pass `resources` to the `i18next` configuration and avoid a backend here
plugins: [Backend],
});
export default i18next;

View file

@ -1,83 +0,0 @@
import { parse as parseCookie, serialize as serializeCookie } from "cookie";
import { API_BASE, INTERNAL_API_BASE } from "~/env.server";
import { ApiError, ErrorCode } from "./api/error";
import { tokenCookieName } from "~/lib/utils";
export type RequestParams = {
token?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
body?: any;
headers?: Record<string, string>;
isInternal?: boolean;
};
export type RequestMethod = "GET" | "POST" | "PATCH" | "DELETE";
export async function baseRequest(
method: RequestMethod,
path: string,
params: RequestParams = {},
): Promise<Response> {
// Internal requests, unauthenticated requests, and GET requests bypass the rate limiting proxy.
// All other requests go through the proxy, and are rate limited.
let base = params.isInternal || !params.token || method === "GET" ? INTERNAL_API_BASE : API_BASE;
base += params.isInternal ? "/internal" : "/v2";
const url = `${base}${path}`;
const resp = await fetch(url, {
method,
body: params.body ? JSON.stringify(params.body) : undefined,
headers: {
...params.headers,
...(params.token ? { Authorization: params.token } : {}),
...(params.body ? { "Content-Type": "application/json" } : {}),
},
});
if (resp.headers.get("Content-Type")?.indexOf("application/json") === -1) {
// If we don't get a JSON response, the server almost certainly encountered an internal error it couldn't recover from
// (that, or the reverse proxy, which should also be treated as a 500 error)
throw {
status: 500,
code: ErrorCode.InternalServerError,
message: "Internal server error",
} as ApiError;
}
if (resp.status < 200 || resp.status >= 400) throw (await resp.json()) as ApiError;
return resp;
}
export async function fastRequest(method: RequestMethod, path: string, params: RequestParams = {}) {
await baseRequest(method, path, params);
}
export default async function serverRequest<T>(
method: RequestMethod,
path: string,
params: RequestParams = {},
) {
const resp = await baseRequest(method, path, params);
return (await resp.json()) as T;
}
export const getToken = (req: Request) => getCookie(req, tokenCookieName);
export function getCookie(req: Request, cookieName: string): string | undefined {
const header = req.headers.get("Cookie");
if (!header) return undefined;
const cookie = parseCookie(header);
return cookieName in cookie ? cookie[cookieName] : undefined;
}
const YEAR = 365 * 86400;
export const writeCookie = (cookieName: string, value: string, maxAge: number | undefined = YEAR) =>
serializeCookie(cookieName, value, {
maxAge,
path: "/",
sameSite: "lax",
httpOnly: true,
secure: true,
});

View file

@ -1,23 +0,0 @@
import { UserSettings } from "./api/user";
import { getCookie } from "./request.server";
export default function getLocalSettings(req: Request): UserSettings {
const settings = { dark_mode: null } as UserSettings;
const theme = getCookie(req, "pronounscc-theme");
switch (theme) {
case "auto":
settings.dark_mode = null;
break;
case "light":
settings.dark_mode = false;
break;
case "dark":
settings.dark_mode = true;
break;
default:
break;
}
return settings;
}

View file

@ -1,6 +0,0 @@
import { DateTime } from "luxon";
export const defaultAvatarUrl = "https://pronouns.cc/default/512.webp";
export const tokenCookieName = "__Host-pronounscc-token";
export const idTimestamp = (id: string) =>
DateTime.fromMillis(parseInt(id, 10) / (1 << 22) + 1_640_995_200_000);

View file

@ -1,159 +0,0 @@
import {
json,
Links,
Meta as MetaComponent,
Outlet,
Scripts,
ScrollRestoration,
useLoaderData,
useRouteError,
useRouteLoaderData,
} from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/node";
import { useChangeLanguage } from "remix-i18next/react";
import { useTranslation } from "react-i18next";
import serverRequest, { getToken, writeCookie } from "./lib/request.server";
import Meta from "./lib/api/meta";
import Navbar from "./components/nav/Navbar";
import { User, UserSettings } from "./lib/api/user";
import { ApiError, ErrorCode } from "./lib/api/error";
import "./app.scss";
import getLocalSettings from "./lib/settings.server";
import { LANGUAGE } from "~/env.server";
import { errorCodeDesc } from "./components/ErrorAlert";
import { Container } from "react-bootstrap";
import { ReactNode } from "react";
import BaseNavbar from "~/components/nav/BaseNavbar";
import { tokenCookieName } from "~/lib/utils";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const meta = await serverRequest<Meta>("GET", "/meta");
const token = getToken(request);
let setCookie = "";
let meUser: User | undefined;
let settings = getLocalSettings(request);
if (token) {
try {
meUser = await serverRequest<User>("GET", "/users/@me", { token });
settings = await serverRequest<UserSettings>("GET", "/users/@me/settings", { token });
} catch (e) {
// If we get an unauthorized error, clear the token, as it's not valid anymore.
if ((e as ApiError).code === ErrorCode.AuthenticationRequired) {
setCookie = writeCookie(tokenCookieName, token, 0);
}
}
}
return json(
{ meta, meUser, settings, locale: LANGUAGE },
{
headers: { "Set-Cookie": setCookie },
},
);
};
export function Layout({ children }: { children: ReactNode }) {
const { locale } = useRouteLoaderData<typeof loader>("root") || {
meta: {
users: {
total: 0,
active_month: 0,
active_week: 0,
active_day: 0,
},
members: 0,
version: "",
hash: "",
},
};
const { i18n } = useTranslation();
i18n.language = locale || "en";
useChangeLanguage(locale || "en");
return (
<html lang={locale || "en"} dir={i18n.dir()}>
<head>
<meta charSet="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<MetaComponent />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export function ErrorBoundary() {
const data = useRouteLoaderData<typeof loader>("root");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const error: any = useRouteError();
const { t } = useTranslation();
const errorElem =
"code" in error && "message" in error ? (
<ApiErrorElem error={error as ApiError} />
) : (
<>{t("error.errors.generic-error")}</>
);
return (
<html lang="en">
<head>
<title>{t("error.title")}</title>
<MetaComponent />
<Links />
</head>
<body>
{data?.meUser && data?.meta ? (
<Navbar meta={data.meta} user={data.meUser} />
) : (
<BaseNavbar />
)}
<Container>{errorElem}</Container>
<Scripts />
</body>
</html>
);
}
function ApiErrorElem({ error }: { error: ApiError }) {
const { t } = useTranslation();
const errorDesc = errorCodeDesc(t, error.code);
return (
<>
<h1>{t("error.heading")}</h1>
<p>{errorDesc}</p>
<details>
<summary>{t("error.more-info")}</summary>
<pre>
<code>{JSON.stringify(error, null, " ")}</code>
</pre>
</details>
</>
);
}
export default function App() {
const { meta, meUser } = useLoaderData<typeof loader>();
return (
<>
<Navbar meta={meta} user={meUser} />
<Container>
<Outlet />
</Container>
</>
);
}

View file

@ -1,73 +0,0 @@
import {
defaultPreferences,
mergePreferences,
PartialMember,
PartialUser,
Pronoun,
} from "~/lib/api/user";
import { Link } from "@remix-run/react";
import { defaultAvatarUrl } from "~/lib/utils";
import { useTranslation } from "react-i18next";
import { OverlayTrigger, Tooltip } from "react-bootstrap";
import { Lock } from "react-bootstrap-icons";
import AvatarImage from "~/components/profile/AvatarImage";
export default function MemberCard({ user, member }: { user: PartialUser; member: PartialMember }) {
const { t } = useTranslation();
const mergedPrefs = mergePreferences(user.custom_preferences);
const pronouns: Pronoun[] = [];
for (const pronoun of member.pronouns) {
const pref =
pronoun.status in mergedPrefs ? mergedPrefs[pronoun.status] : defaultPreferences.missing;
if (pref.favourite) pronouns.push(pronoun);
}
const displayedPronouns = pronouns
.map((pronoun) => {
if (pronoun.display_text) {
return pronoun.display_text;
} else {
const split = pronoun.value.split("/");
if (split.length === 5) return split.splice(0, 2).join("/");
return pronoun.value;
}
})
.join(", ");
return (
<div className="col">
<Link to={`/@${user.username}/${member.name}`}>
<AvatarImage
src={member.avatar_url || defaultAvatarUrl}
width={200}
alt={t("user.member-avatar-alt", { name: member.name })}
lazyLoad
/>
</Link>
<p className="m-2">
<Link to={`/@${user.username}/${member.name}`} className="text-reset fs-5 text-break">
{member.display_name ?? member.name}
{member.unlisted === true && (
<>
<OverlayTrigger
key={member.id}
placement="top"
overlay={
<Tooltip id={member.id} aria-hidden={true}>
{t("user.member-hidden")}
</Tooltip>
}
>
<span className="d-inline-block">
<Lock aria-hidden={true} style={{ pointerEvents: "none" }} />
</span>
</OverlayTrigger>
</>
)}
</Link>
{displayedPronouns && <>{displayedPronouns}</>}
</p>
</div>
);
}

View file

@ -1,128 +0,0 @@
import { json, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { Link, redirect, useLoaderData, useRouteLoaderData } from "@remix-run/react";
import { UserWithMembers } from "~/lib/api/user";
import serverRequest from "~/lib/request.server";
import { loader as rootLoader } from "~/root";
import { Alert, Button, Pagination } from "react-bootstrap";
import { Trans, useTranslation } from "react-i18next";
import { PersonPlusFill } from "react-bootstrap-icons";
import MemberCard from "~/routes/$username/MemberCard";
import { ReactNode } from "react";
import BaseProfile from "~/components/profile/BaseProfile";
export const meta: MetaFunction<typeof loader> = ({ data }) => {
const { user } = data!;
return [{ title: `@${user.username} • pronouns.cc` }];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const url = new URL(request.url);
let memberPage = parseInt(url.searchParams.get("page") ?? "0", 10);
let username = params.username!;
if (!username.startsWith("@")) throw redirect(`/@${username}`);
username = username.substring("@".length);
const user = await serverRequest<UserWithMembers>("GET", `/users/${username}`);
const pageCount = Math.ceil(user.members.length / 20);
let members = user.members.slice(memberPage * 20, (memberPage + 1) * 20);
if (members.length === 0) {
members = user.members.slice(0, 20);
memberPage = 0;
}
return json({ user, members, currentPage: memberPage, pageCount });
};
export default function UserPage() {
const { t } = useTranslation();
const { user, members, currentPage, pageCount } = useLoaderData<typeof loader>();
const { meUser } = useRouteLoaderData<typeof rootLoader>("root") || { meUser: undefined };
const isMeUser = meUser && meUser.id === user.id;
const paginationItems: ReactNode[] = [];
for (let i = 0; i < pageCount; i++) {
paginationItems.push(
<Pagination.Item key={i} as={Link} to={`/@${user.username}?page=${i}`}>
{i + 1}
</Pagination.Item>,
);
}
const pagination = (
<Pagination className="justify-content-center">
<Pagination.Prev
as={Link}
// @ts-expect-error using as=Link causes an error here, even though it runs completely fine
to={`/@${user.username}?page=${currentPage - 1}`}
disabled={currentPage === 0}
/>
{paginationItems}
<Pagination.Next
as={Link}
// @ts-expect-error using as=Link causes an error here, even though it runs completely fine
to={`/@${user.username}?page=${currentPage + 1}`}
disabled={currentPage === pageCount - 1}
/>
</Pagination>
);
return (
<>
{isMeUser && (
<Alert variant="secondary">
<Trans t={t} i18nKey="user.own-profile-alert">
You are currently viewing your <strong>public</strong> profile.
<br />
<Link to="/settings/profile">Edit your profile</Link>
</Trans>
</Alert>
)}
<BaseProfile
name={user.username}
userI18nKeys={true}
profile={user}
customPreferences={user.custom_preferences}
/>
{(members.length > 0 || isMeUser) && (
<>
<hr />
<h2>
{user.member_title || t("user.heading.members")}{" "}
{isMeUser && (
// @ts-expect-error using as=Link causes an error here, even though it runs completely fine
<Button as={Link} to="/settings/new-member" variant="success">
<PersonPlusFill /> {t("user.create-member-button")}
</Button>
)}
</h2>
{pageCount > 1 && pagination}
<div className="grid">
{members.length === 0 ? (
<div>
<Trans t={t} i18nKey="user.no-members-blurb">
You don&apos;t have any members yet.
<br />
Members are sub-profiles that can have their own avatar, names, pronouns, and
preferred terms.
<br />
You can create a new member with the &quot;Create member&quot; button above.{" "}
<span className="text-muted">(only you can see this)</span>
</Trans>
</div>
) : (
<div className="row row-cols-2 row-cols-md-3 row-cols-lg-4 row-cols-xl-5 text-center">
{members.map((member, i) => (
<MemberCard user={user} member={member} key={i} />
))}
</div>
)}
</div>
{pageCount > 1 && pagination}
</>
)}
</>
);
}

View file

@ -1,64 +0,0 @@
import { json, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { Link, redirect, useLoaderData, useRouteLoaderData } from "@remix-run/react";
import serverRequest from "~/lib/request.server";
import { Member } from "~/lib/api/member";
import BaseProfile from "~/components/profile/BaseProfile";
import { loader as rootLoader } from "~/root";
import { Alert, Button } from "react-bootstrap";
import { Trans, useTranslation } from "react-i18next";
import { ArrowLeft } from "react-bootstrap-icons";
export const meta: MetaFunction<typeof loader> = ({ data }) => {
const { member } = data!;
return [
{ title: `${member.display_name ?? member.name} • @${member.user.username} • pronouns.cc` },
];
};
export const loader = async ({ params }: LoaderFunctionArgs) => {
let username = params.username!;
const memberName = params.member!;
if (!username.startsWith("@")) throw redirect(`/@${username}/${memberName}`);
username = username.substring("@".length);
const member = await serverRequest<Member>("GET", `/users/${username}/members/${memberName}`);
return json({ member });
};
export default function MemberPage() {
const { t } = useTranslation();
const { member } = useLoaderData<typeof loader>();
const { meUser } = useRouteLoaderData<typeof rootLoader>("root") || { meUser: undefined };
const isMeUser = meUser && meUser.id === member.user.id;
const memberName = member.name;
return (
<>
{isMeUser && (
<Alert variant="secondary">
<Trans t={t} i18nKey="member.own-profile-alert" values={{ memberName: member.name }}>
You are currently viewing the <strong>public</strong> profile of {{ memberName }}.
<br />
<Link to={`/settings/members/${member.id}`}>Edit profile</Link>
</Trans>
</Alert>
)}
<div className="m-3">
{/* @ts-expect-error using as=Link causes an error here, even though it runs completely fine */}
<Button variant="secondary" as={Link} to={`/@${member.user.username}`}>
<ArrowLeft />{" "}
{t("member.back", { name: member.user.display_name ?? member.user.username })}
</Button>
</div>
<BaseProfile
name={member.name}
fullName={`${member.name} (@${member.user.username})`}
userI18nKeys={false}
profile={member}
customPreferences={member.user.custom_preferences}
/>
</>
);
}

View file

@ -1,13 +0,0 @@
import type { MetaFunction } from "@remix-run/node";
export const meta: MetaFunction = () => {
return [{ title: "pronouns.cc" }];
};
export default function Index() {
return (
<div>
<h1>pronouns.cc</h1>
</div>
);
}

View file

@ -1,231 +0,0 @@
import {
ActionFunctionArgs,
json,
redirect,
LoaderFunctionArgs,
MetaFunction,
} from "@remix-run/node";
import { type ApiError, ErrorCode } from "~/lib/api/error";
import serverRequest, { getToken, writeCookie } from "~/lib/request.server";
import { AuthResponse, CallbackResponse } from "~/lib/api/auth";
import {
Form as RemixForm,
Link,
useActionData,
useLoaderData,
ShouldRevalidateFunction,
useNavigate,
} from "@remix-run/react";
import { Trans, useTranslation } from "react-i18next";
import { Form, Button } from "react-bootstrap";
import i18n from "~/i18next.server";
import { tokenCookieName } from "~/lib/utils";
import { useEffect } from "react";
import RegisterError from "~/components/RegisterError";
import { AuthMethod } from "~/lib/api/user";
import { errorCodeDesc } from "~/components/ErrorAlert";
export const meta: MetaFunction<typeof loader> = ({ data }) => {
return [{ title: `${data?.meta.title || "Log in"} • pronouns.cc` }];
};
export const shouldRevalidate: ShouldRevalidateFunction = ({ actionResult }) => {
return !actionResult;
};
export const loader = async ({ request }: LoaderFunctionArgs) => {
const t = await i18n.getFixedT(request);
const url = new URL(request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const token = getToken(request);
if (!code || !state)
throw { status: 400, code: ErrorCode.BadRequest, message: "Missing code or state" } as ApiError;
if (token) {
try {
const resp = await serverRequest<AuthMethod>("POST", "/auth/discord/add-account/callback", {
body: { code, state },
token,
isInternal: true,
});
return json({
isLinkRequest: true,
meta: { title: t("log-in.callback.title.discord-link") },
error: null,
hasAccount: false,
user: null,
ticket: null,
remoteUser: null,
newAuthMethod: resp,
});
} catch (e) {
return json({
isLinkRequest: true,
meta: { title: t("log-in.callback.title.discord-link") },
error: e as ApiError,
hasAccount: false,
user: null,
ticket: null,
remoteUser: null,
newAuthMethod: null,
});
}
}
const resp = await serverRequest<CallbackResponse>("POST", "/auth/discord/callback", {
body: { code, state },
isInternal: true,
});
if (resp.has_account) {
return json(
{
isLinkRequest: false,
meta: { title: t("log-in.callback.title.discord-success") },
error: null,
hasAccount: true,
user: resp.user!,
ticket: null,
remoteUser: null,
newAuthMethod: null,
},
{
headers: {
"Set-Cookie": writeCookie(tokenCookieName, resp.token!),
},
},
);
}
return json({
isLinkRequest: false,
meta: { title: t("log-in.callback.title.discord-register") },
error: null,
hasAccount: false,
user: null,
ticket: resp.ticket!,
remoteUser: resp.remote_username!,
newAuthMethod: null,
});
};
export const action = async ({ request }: ActionFunctionArgs) => {
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 json({
error: {
status: 403,
code: ErrorCode.BadRequest,
message: "Invalid username or ticket",
} as ApiError,
user: null,
});
try {
const resp = await serverRequest<AuthResponse>("POST", "/auth/discord/register", {
body: { username, ticket },
isInternal: true,
});
return redirect("/auth/welcome", {
headers: {
"Set-Cookie": writeCookie(tokenCookieName, resp.token),
},
status: 303,
});
} catch (e) {
JSON.stringify(e);
return json({ error: e as ApiError });
}
};
export default function DiscordCallbackPage() {
const { t } = useTranslation();
const data = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigate = useNavigate();
useEffect(() => {
setTimeout(() => {
if (data.hasAccount) {
navigate(`/@${data.user!.username}`);
}
}, 2000);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (data.isLinkRequest) {
if (data.error) {
return (
<>
<h1>{t("log-in.callback.link-error")}</h1>
<p>{errorCodeDesc(t, data.error.code)}</p>
</>
);
}
const authMethod = data.newAuthMethod!;
return (
<>
<h1>{t("log-in.callback.discord-link-success")}</h1>
<p>
{t("log-in.callback.discord-link-success-hint", {
username: authMethod.remote_username ?? authMethod.remote_id,
})}
</p>
</>
);
}
if (data.hasAccount) {
const username = data.user!.username;
return (
<>
<h1>{t("log-in.callback.success")}</h1>
<p>
<Trans
t={t}
i18nKey={"log-in.callback.success-link"}
values={{ username: data.user!.username }}
>
{/* @ts-expect-error react-i18next handles interpolation here */}
Welcome back, <Link to={`/@${data.user!.username}`}>@{{ username }}</Link>!
</Trans>
<br />
{t("log-in.callback.redirect-hint")}
</p>
</>
);
}
return (
<RemixForm method="POST">
<Form as="div">
{actionData?.error && <RegisterError error={actionData.error} />}
<Form.Group className="mb-3" controlId="remote-username">
<Form.Label>{t("log-in.callback.remote-username.discord")}</Form.Label>
<Form.Control type="text" readOnly={true} value={data.remoteUser!} />
</Form.Group>
<Form.Group className="mb-3" controlId="username">
<Form.Label>{t("log-in.callback.username")}</Form.Label>
<Form.Control name="username" type="text" required />
</Form.Group>
<input type="hidden" name="ticket" value={data.ticket!} />
<Button variant="primary" type="submit">
{t("log-in.callback.sign-up-button")}
</Button>
</Form>
</RemixForm>
);
}

View file

@ -1,163 +0,0 @@
import {
ActionFunctionArgs,
json,
LoaderFunctionArgs,
MetaFunction,
redirect,
} from "@remix-run/node";
import i18n from "~/i18next.server";
import { type ApiError, ErrorCode } from "~/lib/api/error";
import serverRequest, { writeCookie } from "~/lib/request.server";
import { AuthResponse, CallbackResponse } from "~/lib/api/auth";
import { tokenCookieName } from "~/lib/utils";
import {
Link,
ShouldRevalidateFunction,
useActionData,
useLoaderData,
useNavigate,
} from "@remix-run/react";
import { Trans, useTranslation } from "react-i18next";
import { useEffect } from "react";
import { Form as RemixForm } from "@remix-run/react/dist/components";
import { Button, Form } from "react-bootstrap";
import RegisterError from "~/components/RegisterError";
export const meta: MetaFunction<typeof loader> = ({ data }) => {
return [{ title: `${data?.meta.title || "Log in"} • pronouns.cc` }];
};
export const shouldRevalidate: ShouldRevalidateFunction = ({ actionResult }) => {
return !actionResult;
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const t = await i18n.getFixedT(request);
const url = new URL(request.url);
const code = url.searchParams.get("code");
if (!code) throw { status: 400, code: ErrorCode.BadRequest, message: "Missing code" } as ApiError;
const resp = await serverRequest<CallbackResponse>("POST", "/auth/fediverse/callback", {
body: { code, instance: params.instance! },
isInternal: true,
});
if (resp.has_account) {
return json(
{
meta: { title: t("log-in.callback.title.fediverse-success") },
hasAccount: true,
user: resp.user!,
ticket: null,
remoteUser: null,
},
{
headers: {
"Set-Cookie": writeCookie(tokenCookieName, resp.token!),
},
},
);
}
return json({
meta: { title: t("log-in.callback.title.fediverse-register") },
hasAccount: false,
user: null,
instance: params.instance!,
ticket: resp.ticket!,
remoteUser: resp.remote_username!,
});
};
export const action = async ({ request }: ActionFunctionArgs) => {
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 json({
error: {
status: 403,
code: ErrorCode.BadRequest,
message: "Invalid username or ticket",
} as ApiError,
user: null,
});
try {
const resp = await serverRequest<AuthResponse>("POST", "/auth/fediverse/register", {
body: { username, ticket },
isInternal: true,
});
return redirect("/auth/welcome", {
headers: {
"Set-Cookie": writeCookie(tokenCookieName, resp.token),
},
status: 303,
});
} catch (e) {
JSON.stringify(e);
return json({ error: e as ApiError });
}
};
export default function FediverseCallbackPage() {
const { t } = useTranslation();
const data = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigate = useNavigate();
useEffect(() => {
setTimeout(() => {
if (data.hasAccount) {
navigate(`/@${data.user!.username}`);
}
}, 2000);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (data.hasAccount) {
const username = data.user!.username;
return (
<>
<h1>{t("log-in.callback.success")}</h1>
<p>
<Trans
t={t}
i18nKey={"log-in.callback.success-link"}
values={{ username: data.user!.username }}
>
{/* @ts-expect-error react-i18next handles interpolation here */}
Welcome back, <Link to={`/@${data.user!.username}`}>@{{ username }}</Link>!
</Trans>
<br />
{t("log-in.callback.redirect-hint")}
</p>
</>
);
}
return (
<RemixForm method="POST">
<Form as="div">
{actionData?.error && <RegisterError error={actionData.error} />}
<Form.Group className="mb-3" controlId="remote-username">
<Form.Label>{t("log-in.callback.remote-username.fediverse")}</Form.Label>
<Form.Control type="text" readOnly={true} value={data.remoteUser!} />
</Form.Group>
<Form.Group className="mb-3" controlId="username">
<Form.Label>{t("log-in.callback.username")}</Form.Label>
<Form.Control name="username" type="text" required />
</Form.Group>
<input type="hidden" name="ticket" value={data.ticket!} />
<Button variant="primary" type="submit">
{t("log-in.callback.sign-up-button")}
</Button>
</Form>
</RemixForm>
);
}

View file

@ -1,146 +0,0 @@
import {
MetaFunction,
json,
LoaderFunctionArgs,
redirect,
ActionFunctionArgs,
} from "@remix-run/node";
import {
Form as RemixForm,
ShouldRevalidateFunction,
useActionData,
useLoaderData,
} from "@remix-run/react";
import { Form, Button, ButtonGroup, ListGroup } from "react-bootstrap";
import { useTranslation } from "react-i18next";
import i18n from "~/i18next.server";
import serverRequest, { getToken, writeCookie } from "~/lib/request.server";
import { AuthResponse, AuthUrls } from "~/lib/api/auth";
import { ApiError, ErrorCode } from "~/lib/api/error";
import ErrorAlert from "~/components/ErrorAlert";
import { User } from "~/lib/api/user";
import { tokenCookieName } from "~/lib/utils";
export const meta: MetaFunction<typeof loader> = ({ data }) => {
return [{ title: `${data?.meta.title || "Log in"} • pronouns.cc` }];
};
export const shouldRevalidate: ShouldRevalidateFunction = ({ actionResult }) => {
return !actionResult;
};
export const loader = async ({ request }: LoaderFunctionArgs) => {
const t = await i18n.getFixedT(request);
const token = getToken(request);
if (token) {
try {
await serverRequest<User>("GET", "/users/@me", { token });
return redirect("/?err=already-logged-in", 303);
} catch (e) {
// ignore
}
}
const urls = await serverRequest<AuthUrls>("POST", "/auth/urls", { isInternal: true });
return json({
meta: { title: t("log-in.title") },
urls,
});
};
export const action = async ({ request }: ActionFunctionArgs) => {
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 serverRequest<AuthResponse>("POST", "/auth/email/login", {
body: { email, password },
isInternal: true,
});
return redirect("/", {
status: 303,
headers: {
"Set-Cookie": writeCookie(tokenCookieName, resp.token),
},
});
} catch (e) {
return json({ error: e as ApiError });
}
};
export default function LoginPage() {
const { t } = useTranslation();
const { urls } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
return (
<>
<div className="row">
{!urls.email_enabled && <div className="col-lg-3"></div>}
{urls.email_enabled && (
<div className="col col-md mb-4">
<h2>{t("log-in.form-title")}</h2>
{actionData?.error && <LoginError error={actionData.error} />}
<RemixForm action="/auth/log-in" method="POST">
<Form as="div">
<Form.Group className="mb-3" controlId="email">
<Form.Label>{t("log-in.email")}</Form.Label>
<Form.Control name="email" type="email" required />
</Form.Group>
<Form.Group className="mb-3" controlId="password">
<Form.Label>{t("log-in.password")}</Form.Label>
<Form.Control name="password" type="password" required />
</Form.Group>
<ButtonGroup>
<Button variant="primary" type="submit">
{t("log-in.log-in-button")}
</Button>
<Button as="a" href="/auth/register" variant="secondary">
{t("log-in.register-with-email")}
</Button>
</ButtonGroup>
</Form>
</RemixForm>
</div>
)}
<div className="col col-md">
<h2>{t("log-in.3rd-party.title")}</h2>
<p>{t("log-in.3rd-party.desc")}</p>
<ListGroup>
{urls.discord && (
<ListGroup.Item action href={urls.discord}>
{t("log-in.3rd-party.discord")}
</ListGroup.Item>
)}
{urls.google && (
<ListGroup.Item action href={urls.google}>
{t("log-in.3rd-party.google")}
</ListGroup.Item>
)}
{urls.tumblr && (
<ListGroup.Item action href={urls.tumblr}>
{t("log-in.3rd-party.tumblr")}
</ListGroup.Item>
)}
<ListGroup.Item action href="/auth/log-in/fediverse">
{t("log-in.3rd-party.fediverse")}
</ListGroup.Item>
</ListGroup>
</div>
{!urls.email_enabled && <div className="col-lg-3"></div>}
</div>
</>
);
}
function LoginError({ error }: { error: ApiError }) {
const { t } = useTranslation();
if (error.code !== ErrorCode.UserNotFound) return <ErrorAlert error={error} />;
return <>{t("log-in.invalid-credentials")}</>;
}

View file

@ -1,75 +0,0 @@
import {
LoaderFunctionArgs,
json,
MetaFunction,
ActionFunctionArgs,
redirect,
} from "@remix-run/node";
import i18n from "~/i18next.server";
import { useTranslation } from "react-i18next";
import { Form as RemixForm, useActionData } from "@remix-run/react";
import { Button, Form } from "react-bootstrap";
import serverRequest from "~/lib/request.server";
import { ApiError, ErrorCode } from "~/lib/api/error";
import ErrorAlert from "~/components/ErrorAlert";
export const meta: MetaFunction<typeof loader> = ({ data }) => {
return [{ title: `${data?.meta.title || "Log in with a Fediverse account"} • pronouns.cc` }];
};
export const loader = async ({ request }: LoaderFunctionArgs) => {
const t = await i18n.getFixedT(request);
return json({ meta: { title: t("log-in.fediverse.choose-title") } });
};
export const action = async ({ request }: ActionFunctionArgs) => {
const body = await request.formData();
const instance = body.get("instance") as string | null;
if (!instance)
return json({
error: {
status: 403,
code: ErrorCode.BadRequest,
message: "Invalid instance name",
} as ApiError,
});
try {
const resp = await serverRequest<{ url: string }>(
"GET",
`/auth/fediverse?instance=${encodeURIComponent(instance)}`,
{
isInternal: true,
},
);
return redirect(resp.url);
} catch (e) {
return json({ error: e as ApiError });
}
};
export default function AuthFediversePage() {
const { t } = useTranslation();
const data = useActionData<typeof action>();
return (
<>
<h2>{t("log-in.fediverse.choose-form-title")}</h2>
{data?.error && <ErrorAlert error={data.error} />}
<RemixForm method="POST">
<Form as="div">
<Form.Group className="mb-3" controlId="instance">
<Form.Label>{t("log-in.fediverse-instance-label")}</Form.Label>
<Form.Control name="instance" type="text" />
</Form.Group>
<Button variant="primary" type="submit">
{t("log-in.fediverse-log-in-button")}
</Button>
</Form>
</RemixForm>
</>
);
}

View file

@ -1,12 +0,0 @@
import { ActionFunction } from "@remix-run/node";
import { writeCookie } from "~/lib/request.server";
import { tokenCookieName } from "~/lib/utils";
export const action: ActionFunction = async () => {
return new Response(null, {
headers: {
"Set-Cookie": writeCookie(tokenCookieName, "token", 0),
},
status: 204,
});
};

View file

@ -1,52 +0,0 @@
import { LoaderFunctionArgs, redirect, json, MetaFunction } from "@remix-run/node";
import i18n from "~/i18next.server";
import serverRequest, { getToken } from "~/lib/request.server";
import { User } from "~/lib/api/user";
import { useTranslation } from "react-i18next";
import { Link, useLoaderData } from "@remix-run/react";
import { Button } from "react-bootstrap";
export const meta: MetaFunction<typeof loader> = ({ data }) => {
return [{ title: `${data?.meta.title || "Welcome"} • pronouns.cc` }];
};
export const loader = async ({ request }: LoaderFunctionArgs) => {
const t = await i18n.getFixedT(request);
const token = getToken(request);
let user: User;
if (token) {
try {
user = await serverRequest<User>("GET", "/users/@me", { token });
} catch (e) {
return redirect("/auth/log-in");
}
} else {
return redirect("/auth/log-in");
}
return json({ meta: { title: t("welcome.title") }, user });
};
export default function WelcomePage() {
const { t } = useTranslation();
const { user } = useLoaderData<typeof loader>();
return (
<div>
<h1>{t("welcome.header")}</h1>
<p>{t("welcome.blurb")}</p>
<h2>{t("welcome.customize-profile")}</h2>
<p>{t("welcome.customize-profile-blurb")}</p>
<h2>{t("welcome.create-members")}</h2>
<p>{t("welcome.create-members-blurb")}</p>
<h2>{t("welcome.custom-preferences")}</h2>
<p>{t("welcome.custom-preferences-blurb")}</p>
<Link to={`/@${user.username}`}>
<Button as="span" variant="primary">
{t("welcome.profile-button")}
</Button>
</Link>
</div>
);
}

View file

@ -1,38 +0,0 @@
import { ActionFunction } from "@remix-run/node";
import { UserSettings } from "~/lib/api/user";
import serverRequest, { getToken, writeCookie } from "~/lib/request.server";
// Handles theme switching
// Remix itself handles redirecting back to the original page after the setting is set
//
// Note: this function is currently unused. Bootstrap only lets us switch themes with either prefers-color-scheme
// *or* a programmatic switch using data-bs-theme, not both.
// If the Sec-CH-Prefers-Color-Scheme header (https://caniuse.com/mdn-http_headers_sec-ch-prefers-color-scheme)
// is added to Firefox and Safari, the dark mode setting should be reworked to use it instead.
// As it stands, using prefers-color-scheme is the only way
// to respect the operating system's dark mode setting without using JavaScript.
export const action: ActionFunction = async ({ request }) => {
const body = await request.formData();
const theme = (body.get("theme") as string | null) || "auto";
const token = getToken(request);
if (token) {
await serverRequest<UserSettings>("PATCH", "/users/@me/settings", {
token,
body: {
dark_mode: theme === "auto" ? null : theme === "dark",
},
});
return new Response(null, {
status: 204,
});
}
return new Response(null, {
headers: {
"Set-Cookie": writeCookie("pronounscc-theme", theme),
},
status: 204,
});
};

View file

@ -1,149 +0,0 @@
import { Button, Form, InputGroup, Table } from "react-bootstrap";
import { useTranslation } from "react-i18next";
import {
Form as RemixForm,
Link,
Outlet,
useActionData,
useRouteLoaderData,
} from "@remix-run/react";
import { loader as settingsLoader } from "../settings/route";
import { loader as rootLoader } from "../../root";
import { DateTime } from "luxon";
import { defaultAvatarUrl, idTimestamp } from "~/lib/utils";
import { ExclamationTriangleFill, InfoCircleFill } from "react-bootstrap-icons";
import AvatarImage from "~/components/profile/AvatarImage";
import { ActionFunctionArgs, json } from "@remix-run/node";
import { type ApiError, ErrorCode, firstErrorFor } from "~/lib/api/error";
import serverRequest, { getToken } from "~/lib/request.server";
import { MeUser } from "~/lib/api/user";
import ErrorAlert from "~/components/ErrorAlert";
export const action = async ({ request }: ActionFunctionArgs) => {
const data = await request.formData();
const username = data.get("username") as string | null;
const token = getToken(request);
if (!username) {
return json({
error: {
status: 403,
code: ErrorCode.BadRequest,
message: "Invalid username",
} as ApiError,
user: null,
});
}
try {
const resp = await serverRequest<MeUser>("PATCH", "/users/@me", { body: { username }, token });
return json({ user: resp, error: null });
} catch (e) {
return json({ error: e as ApiError, user: null });
}
};
export default function SettingsIndex() {
const { user } = useRouteLoaderData<typeof settingsLoader>("routes/settings")!;
const actionData = useActionData<typeof action>();
const { meta } = useRouteLoaderData<typeof rootLoader>("root")!;
const { t } = useTranslation();
const createdAt = idTimestamp(user.id);
return (
<>
<Outlet />
<div className="row">
<div className="col-md">
<RemixForm method="POST">
<Form as="div">
<Form.Group className="mb-3" controlId="username">
<Form.Label>{t("settings.general.username")}</Form.Label>
<InputGroup className="m-1 w-75">
<Form.Control defaultValue={user.username} name="username" type="text" required />
<Button variant="secondary" type="submit">
{t("settings.general.change-username")}
</Button>
</InputGroup>
</Form.Group>
</Form>
</RemixForm>
<p className="text-muted text-has-newline">
<InfoCircleFill /> {t("settings.general.username-change-hint")}
</p>
{actionData?.error && <UsernameUpdateError error={actionData.error} />}
</div>
<div className="col-md text-center">
<AvatarImage
src={user.avatar_url || defaultAvatarUrl}
width={200}
alt={t("user.avatar-alt", { username: user.username })}
/>
</div>
</div>
<div>
<h4>{t("settings.general.log-out-everywhere")}</h4>
<p>{t("settings.general.log-out-everywhere-hint")}</p>
{/* @ts-expect-error as=Link */}
<Button as={Link} variant="danger" to="/settings/force-log-out">
{t("settings.general.force-log-out-button")}
</Button>
</div>
<h4 className="mt-2">{t("settings.general.table-header")}</h4>
<Table striped bordered hover>
<tbody>
<tr>
<th scope="row">{t("settings.general.id")}</th>
<td>
<code>{user.id}</code>
</td>
</tr>
<tr>
<th scope="row">{t("settings.general.created")}</th>
<td>{createdAt.toLocaleString(DateTime.DATETIME_MED)}</td>
</tr>
<tr>
<th scope="row">{t("settings.general.member-count")}</th>
<td>
{user.members.length}/{meta.limits.member_count}
</td>
</tr>
<tr>
<th scope="row">{t("settings.general.member-list-hidden")}</th>
<td>{user.member_list_hidden ? t("yes") : t("no")}</td>
</tr>
<tr>
<th scope="row">{t("settings.general.custom-preferences")}</th>
<td>
{Object.keys(user.custom_preferences).length}/{meta.limits.custom_preferences}
</td>
</tr>
<tr>
<th scope="row">{t("settings.general.role")}</th>
<td>
<code>{user.role}</code>
</td>
</tr>
</tbody>
</Table>
</>
);
}
function UsernameUpdateError({ error }: { error: ApiError }) {
const { t } = useTranslation();
const usernameError = firstErrorFor(error, "username");
if (!usernameError) {
return <ErrorAlert error={error} />;
}
return (
<p className="text-danger-emphasis text-has-newline">
<ExclamationTriangleFill />{" "}
{t("settings.general.username-update-error", { message: usernameError.message })}
</p>
);
}

View file

@ -1,166 +0,0 @@
import i18n from "~/i18next.server";
import { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { Link, useLoaderData, useRouteLoaderData } from "@remix-run/react";
import { Button, ListGroup } from "react-bootstrap";
import { loader as settingsLoader } from "~/routes/settings/route";
import { useTranslation } from "react-i18next";
import { AuthMethod, MeUser } from "~/lib/api/user";
import serverRequest from "~/lib/request.server";
import { AuthUrls } from "~/lib/api/auth";
export const meta: MetaFunction<typeof loader> = ({ data }) => {
return [{ title: `${data?.meta.title || "Authentication"} • pronouns.cc` }];
};
export const loader = async ({ request }: LoaderFunctionArgs) => {
const t = await i18n.getFixedT(request);
const urls = await serverRequest<AuthUrls>("POST", "/auth/urls", { isInternal: true });
return { urls, meta: { title: t("settings.auth.title") } };
};
export default function AuthSettings() {
const { urls } = useLoaderData<typeof loader>();
const { user } = useRouteLoaderData<typeof settingsLoader>("routes/settings")!;
return (
<div className="px-md-5">
{urls.email_enabled && <EmailSettings user={user} />}
{urls.discord && <DiscordSettings user={user} />}
<FediverseSettings user={user} />
</div>
);
}
function EmailSettings({ user }: { user: MeUser }) {
const { t } = useTranslation();
const oneAuthMethod = user.auth_methods.length === 1;
const emails = user.auth_methods.filter((m) => m.type === "EMAIL");
return (
<>
<h3>{t("settings.auth.email-addresses")}</h3>
{emails.length > 0 && (
<>
<ListGroup className="pt-2 pb-3">
{emails.map((e) => (
<EmailRow email={e} key={e.id} disabled={oneAuthMethod} />
))}
</ListGroup>
</>
)}
{emails.length < 3 && (
<p>
{/* @ts-expect-error as=Link */}
<Button variant="primary" as={Link} to="/settings/auth/add-email">
{emails.length === 0
? t("settings.auth.form.add-first-email")
: t("settings.auth.form.add-extra-email")}
</Button>
</p>
)}
</>
);
}
function EmailRow({ email, disabled }: { email: AuthMethod; disabled: boolean }) {
const { t } = useTranslation();
return (
<ListGroup.Item>
<div className="row">
<div className="col">{email.remote_id}</div>
{!disabled && (
<div className="col text-end">
<Link to={`/settings/auth/remove-method/${email.id}`}>
{t("settings.auth.remove-auth-method")}
</Link>
</div>
)}
</div>
</ListGroup.Item>
);
}
function DiscordSettings({ user }: { user: MeUser }) {
const { t } = useTranslation();
const oneAuthMethod = user.auth_methods.length === 1;
const discordAccounts = user.auth_methods.filter((m) => m.type === "DISCORD");
return (
<>
<h3>{t("settings.auth.discord-accounts")}</h3>
{discordAccounts.length > 0 && (
<>
<ListGroup className="pt-2 pb-3">
{discordAccounts.map((a) => (
<NonEmailRow account={a} key={a.id} disabled={oneAuthMethod} />
))}
</ListGroup>
</>
)}
{discordAccounts.length < 3 && (
<p>
{/* @ts-expect-error as=Link */}
<Button variant="primary" as={Link} to="/settings/auth/add-discord-account">
{discordAccounts.length === 0
? t("settings.auth.form.add-first-discord-account")
: t("settings.auth.form.add-extra-discord-account")}
</Button>
</p>
)}
</>
);
}
function FediverseSettings({ user }: { user: MeUser }) {
const { t } = useTranslation();
const oneAuthMethod = user.auth_methods.length === 1;
const fediAccounts = user.auth_methods.filter((m) => m.type === "FEDIVERSE");
return (
<>
<h3>{t("settings.auth.fediverse-accounts")}</h3>
{fediAccounts.length > 0 && (
<>
<ListGroup className="pt-2 pb-3">
{fediAccounts.map((a) => (
<NonEmailRow account={a} key={a.id} disabled={oneAuthMethod} />
))}
</ListGroup>
</>
)}
{fediAccounts.length < 3 && (
<p>
{/* @ts-expect-error as=Link */}
<Button variant="primary" as={Link} to="/settings/auth/add-fediverse-account">
{fediAccounts.length === 0
? t("settings.auth.form.add-first-fediverse-account")
: t("settings.auth.form.add-extra-fediverse-account")}
</Button>
</p>
)}
</>
);
}
function NonEmailRow({ account, disabled }: { account: AuthMethod; disabled: boolean }) {
const { t } = useTranslation();
return (
<ListGroup.Item>
<div className="row">
<div className="col">
{account.remote_username} {account.type !== "FEDIVERSE" && <>({account.remote_id})</>}
</div>
{!disabled && (
<div className="col text-end">
<Link to={`/settings/auth/remove-method/${account.id}`}>
{t("settings.auth.remove-auth-method")}
</Link>
</div>
)}
</div>
</ListGroup.Item>
);
}

View file

@ -1,26 +0,0 @@
import { LoaderFunctionArgs, redirect, json } from "@remix-run/node";
import serverRequest, { getToken } from "~/lib/request.server";
import { ApiError } from "~/lib/api/error";
import { useLoaderData } from "@remix-run/react";
import ErrorAlert from "~/components/ErrorAlert";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const token = getToken(request);
try {
const { url } = await serverRequest<{ url: string }>("GET", "/auth/discord/add-account", {
isInternal: true,
token,
});
return redirect(url, 303);
} catch (e) {
return json({ error: e as ApiError });
}
};
export default function AddDiscordAccountPage() {
const { error } = useLoaderData<typeof loader>();
return <ErrorAlert error={error} />;
}

View file

@ -1,105 +0,0 @@
import { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import i18n from "~/i18next.server";
import { json, useActionData, useNavigate, useRouteLoaderData } from "@remix-run/react";
import { loader as settingsLoader } from "~/routes/settings/route";
import { useTranslation } from "react-i18next";
import { useEffect } from "react";
import { Button, Card, Form } from "react-bootstrap";
import { Form as RemixForm } from "@remix-run/react/dist/components";
import { ApiError, ErrorCode } from "~/lib/api/error";
import { fastRequest, getToken } from "~/lib/request.server";
import ErrorAlert from "~/components/ErrorAlert";
export const meta: MetaFunction<typeof loader> = ({ data }) => {
return [{ title: `${data?.meta.title || "Authentication"} • pronouns.cc` }];
};
export const loader = async ({ request }: LoaderFunctionArgs) => {
const t = await i18n.getFixedT(request);
return { meta: { title: t("settings.auth.title") } };
};
export const action = async ({ request }: ActionFunctionArgs) => {
const token = getToken(request)!;
const body = await request.formData();
const email = body.get("email") as string | null;
const password = body.get("password-1") as string | null;
const password2 = body.get("password-2") as string | null;
if (!email || !password || !password2) {
return json({
error: {
status: 400,
code: ErrorCode.BadRequest,
message: "One or more required fields are missing.",
} as ApiError,
ok: false,
});
}
if (password !== password2) {
return json({
error: {
status: 400,
code: ErrorCode.BadRequest,
message: "Passwords do not match.",
} as ApiError,
ok: false,
});
}
await fastRequest("POST", "/auth/email/add", {
body: { email, password },
token,
isInternal: true,
});
return json({ error: null, ok: true });
};
export default function AddEmailPage() {
const { t } = useTranslation();
const { user } = useRouteLoaderData<typeof settingsLoader>("routes/settings")!;
const actionData = useActionData<typeof action>();
const navigate = useNavigate();
const emails = user.auth_methods.filter((m) => m.type === "EMAIL");
useEffect(() => {
if (emails.length >= 3) {
navigate("/settings/auth");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<RemixForm method="POST">
<Card body>
<Card.Title>
{emails.length === 0
? t("settings.auth.form.add-first-email")
: t("settings.auth.form.add-extra-email")}
</Card.Title>
{emails.length === 0 && !actionData?.ok && <p>{t("settings.auth.no-email")}</p>}
{actionData?.ok && <p>{t("settings.auth.new-email-pending")}</p>}
{actionData?.error && <ErrorAlert error={actionData.error} />}
<Form as="div">
<Form.Group className="mb-3" controlId="email-address">
<Form.Label>{t("settings.auth.form.email-address")}</Form.Label>
<Form.Control type="email" name="email" required disabled={actionData?.ok} />
</Form.Group>
<Form.Group className="mb-3" controlId="password-1">
<Form.Label>{t("settings.auth.form.password-1")}</Form.Label>
<Form.Control type="password" name="password-1" required disabled={actionData?.ok} />
</Form.Group>
<Form.Group className="mb-3" controlId="password-2">
<Form.Label>{t("settings.auth.form.password-2")}</Form.Label>
<Form.Control type="password" name="password-2" required disabled={actionData?.ok} />
</Form.Group>
<Button variant="primary" type="submit">
{t("settings.auth.form.add-email-button")}
</Button>
</Form>
</Card>
</RemixForm>
);
}

View file

@ -1,38 +0,0 @@
import { LoaderFunctionArgs, json } from "@remix-run/node";
import { baseRequest } from "~/lib/request.server";
import { useTranslation } from "react-i18next";
import { useEffect } from "react";
import { useNavigate } from "@remix-run/react";
export const loader = async ({ params }: LoaderFunctionArgs) => {
const state = params.code!;
const resp = await baseRequest("POST", "/auth/email/callback", {
body: { state },
isInternal: true,
});
if (resp.status !== 204) {
// TODO: handle non-204 status (this indicates that the email was not linked to an account)
}
return json({ ok: true });
};
export default function ConfirmEmailPage() {
const { t } = useTranslation();
const navigate = useNavigate();
useEffect(() => {
setTimeout(() => {
navigate("/settings/auth");
}, 2000);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<h3>{t("settings.auth.email-link-success")}</h3>
<p>{t("settings.auth.redirect-to-auth-hint")}</p>
</>
);
}

View file

@ -1,73 +0,0 @@
import { ActionFunctionArgs, json, LoaderFunctionArgs, redirect } from "@remix-run/node";
import i18n from "~/i18next.server";
import serverRequest, { fastRequest, getToken } from "~/lib/request.server";
import { AuthMethod } from "~/lib/api/user";
import { useTranslation } from "react-i18next";
import { useLoaderData, Form } from "@remix-run/react";
import { Button } from "react-bootstrap";
export const action = async ({ request }: ActionFunctionArgs) => {
const data = await request.formData();
const token = getToken(request);
const id = data.get("remove-id") as string;
await fastRequest("DELETE", `/auth/methods/${id}`, { token, isInternal: true });
return redirect("/settings/auth", 303);
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const t = await i18n.getFixedT(request);
const token = getToken(request);
const method = await serverRequest<AuthMethod>("GET", `/auth/methods/${params.id}`, {
token,
isInternal: true,
});
return json({ method, meta: { title: t("settings.auth.remove-auth-method-title") } });
};
export default function RemoveAuthMethodPage() {
const { t } = useTranslation();
const { method } = useLoaderData<typeof loader>();
let methodName;
switch (method.type) {
case "EMAIL":
methodName = "email";
break;
case "DISCORD":
methodName = "Discord";
break;
case "FEDIVERSE":
methodName = "Fediverse";
break;
case "GOOGLE":
methodName = "Google";
break;
case "TUMBLR":
methodName = "Tumblr";
break;
}
return (
<>
<h3>{t("settings.auth.remove-auth-method-title")}</h3>
<p>
{t("settings.auth.remove-auth-method-hint", {
username: method.remote_username || method.remote_id,
methodName,
})}
</p>
<p>
<Form method="POST">
<input type="hidden" name="remove-id" value={method.id} />
<Button type="submit" color="primary">
{t("settings.auth.remove-auth-method")}
</Button>
</Form>
</p>
</>
);
}

View file

@ -1,48 +0,0 @@
import { ActionFunction, redirect } from "@remix-run/node";
import { fastRequest, getToken, writeCookie } from "~/lib/request.server";
import { tokenCookieName } from "~/lib/utils";
import { Button, Form } from "react-bootstrap";
import { useTranslation } from "react-i18next";
import { Form as RemixForm, Link } from "@remix-run/react";
export const action: ActionFunction = async ({ request }) => {
const token = getToken(request);
if (!token)
return redirect("/", {
status: 303,
headers: { "Set-Cookie": writeCookie(tokenCookieName, "token", 0) },
});
await fastRequest("POST", "/auth/force-log-out", { token, isInternal: true });
return redirect("/", {
status: 303,
headers: { "Set-Cookie": writeCookie(tokenCookieName, "token", 0) },
});
};
export const loader = () => {
return null;
};
export default function ForceLogoutPage() {
const { t } = useTranslation();
return (
<>
<h4>{t("settings.general.log-out-everywhere")}</h4>
<p className="text-has-newline">{t("settings.general.log-out-everywhere-confirm")}</p>
<RemixForm method="POST">
<Form as="div">
<Button type="submit" variant="danger">
{t("yes")}
</Button>
{/* @ts-expect-error as=Link */}
<Button variant="link" as={Link} to="/settings">
{t("no")}
</Button>
</Form>
</RemixForm>
</>
);
}

View file

@ -1,70 +0,0 @@
import { LoaderFunctionArgs, json, redirect, MetaFunction } from "@remix-run/node";
import i18n from "~/i18next.server";
import serverRequest, { getToken } from "~/lib/request.server";
import { MeUser } from "~/lib/api/user";
import { Link, Outlet, useLocation } from "@remix-run/react";
import { Nav } from "react-bootstrap";
import { useTranslation } from "react-i18next";
export const meta: MetaFunction<typeof loader> = ({ data }) => {
return [{ title: `${data?.meta.title || "Settings"} • pronouns.cc` }];
};
export const loader = async ({ request }: LoaderFunctionArgs) => {
const t = await i18n.getFixedT(request);
const token = getToken(request);
if (token) {
try {
const user = await serverRequest<MeUser>("GET", "/users/@me", { token });
return json({ user, meta: { title: t("settings.title") } });
} catch (e) {
return redirect("/auth/log-in");
}
}
return redirect("/auth/log-in");
};
export default function SettingsLayout() {
const { t } = useTranslation();
const { pathname } = useLocation();
const isActive = (matches: string[] | string, startsWith: boolean = false) =>
startsWith
? typeof matches === "string"
? pathname.startsWith(matches)
: matches.some((m) => pathname.startsWith(m))
: typeof matches === "string"
? matches === pathname
: matches.includes(pathname);
return (
<>
<Nav variant="pills" justify fill className="flex-column flex-md-row">
<Nav.Link
active={isActive(["/settings", "/settings/force-log-out"])}
as={Link}
to="/settings"
>
{t("settings.nav.general-information")}
</Nav.Link>
<Nav.Link active={isActive("/settings/profile", true)} as={Link} to="/settings/profile">
{t("settings.nav.profile")}
</Nav.Link>
<Nav.Link active={isActive("/settings/members", true)} as={Link} to="/settings/members">
{t("settings.nav.members")}
</Nav.Link>
<Nav.Link active={isActive("/settings/auth", true)} as={Link} to="/settings/auth">
{t("settings.nav.authentication")}
</Nav.Link>
<Nav.Link active={isActive("/settings/export")} as={Link} to="/settings/export">
{t("settings.nav.export")}
</Nav.Link>
</Nav>
<div className="my-3">
<Outlet />
</div>
</>
);
}

View file

@ -0,0 +1,33 @@
import prettier from "eslint-config-prettier";
import js from "@eslint/js";
import svelte from "eslint-plugin-svelte";
import globals from "globals";
import ts from "typescript-eslint";
export default ts.config(
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs["flat/recommended"],
prettier,
...svelte.configs["flat/prettier"],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
},
},
{
files: ["**/*.svelte"],
languageOptions: {
parserOptions: {
parser: ts.parser,
},
},
},
{
ignores: ["build/", ".svelte-kit/", "dist/"],
},
);

View file

@ -1,3 +0,0 @@
export default {
locales: ["en"],
};

View file

@ -1,74 +1,47 @@
{ {
"name": "foxnouns-fe", "name": "foxnouns.frontend",
"private": true, "version": "0.0.1",
"sideEffects": false,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "remix vite:build", "dev": "vite dev",
"dev": "node ./server.js", "build": "vite build",
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", "preview": "vite preview",
"start": "cross-env NODE_ENV=production node ./server.js", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"typecheck": "tsc", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier -w .", "format": "prettier --write .",
"extract-translations": "i18next 'app/**/*.tsx' -o 'public/locales/$LOCALE.json'" "lint": "prettier --check . && eslint ."
},
"dependencies": {
"@remix-run/express": "^2.11.2",
"@remix-run/node": "^2.11.2",
"@remix-run/react": "^2.11.2",
"@remix-run/serve": "^2.11.2",
"bootstrap": "^5.3.3",
"change-case": "^5.4.4",
"classnames": "^2.5.1",
"compression": "^1.7.4",
"cookie": "^0.6.0",
"cross-env": "^7.0.3",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"i18next": "^23.15.1",
"i18next-browser-languagedetector": "^8.0.0",
"i18next-fs-backend": "^2.3.2",
"i18next-http-backend": "^2.6.1",
"isbot": "^4.1.0",
"luxon": "^3.5.0",
"markdown-it": "^14.1.0",
"morgan": "^1.10.0",
"react": "^18.2.0",
"react-bootstrap": "^2.10.4",
"react-bootstrap-icons": "^1.11.4",
"react-dom": "^18.2.0",
"react-i18next": "^15.0.1",
"remix-i18next": "^6.3.0",
"sanitize-html": "^2.13.0"
}, },
"devDependencies": { "devDependencies": {
"@fontsource/firago": "^5.0.11", "@sveltejs/adapter-node": "^5.2.9",
"@remix-run/dev": "^2.11.2", "@sveltejs/kit": "^2.0.0",
"@types/compression": "^1.7.5", "@sveltejs/vite-plugin-svelte": "^4.0.0",
"@types/cookie": "^0.6.0", "@sveltestrap/sveltestrap": "^6.2.7",
"@types/express": "^4.17.21", "@types/eslint": "^9.6.0",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@types/morgan": "^1.9.9",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@types/sanitize-html": "^2.13.0", "@types/sanitize-html": "^2.13.0",
"@typescript-eslint/eslint-plugin": "^6.7.4", "bootstrap": "^5.3.3",
"@typescript-eslint/parser": "^6.7.4", "eslint": "^9.7.0",
"eslint": "^8.38.0", "eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^3.6.1", "eslint-plugin-svelte": "^2.36.0",
"eslint-plugin-import": "^2.28.1", "globals": "^15.0.0",
"eslint-plugin-jsx-a11y": "^6.7.1", "prettier": "^3.3.2",
"eslint-plugin-react": "^7.33.2", "prettier-plugin-svelte": "^3.2.6",
"eslint-plugin-react-hooks": "^4.6.0", "sass": "^1.81.0",
"i18next-parser": "^9.0.2", "svelte": "^5.0.0",
"prettier": "^3.3.3", "svelte-check": "^4.0.0",
"sass": "1.77.6", "sveltekit-i18n": "^2.4.2",
"typescript": "^5.1.6", "typescript": "^5.0.0",
"vite": "^5.1.0", "typescript-eslint": "^8.0.0",
"vite-tsconfig-paths": "^4.2.1" "vite": "^5.0.3"
}, },
"engines": { "packageManager": "pnpm@9.12.3+sha512.cce0f9de9c5a7c95bef944169cc5dfe8741abfb145078c0d508b868056848a87c81e626246cb60967cbd7fd29a6c062ef73ff840d96b3c86c40ac92cf4a813ee",
"node": ">=20.0.0" "dependencies": {
"@fontsource/firago": "^5.1.0",
"bootstrap-icons": "^1.11.3",
"luxon": "^3.5.0",
"markdown-it": "^14.1.0",
"sanitize-html": "^2.13.1",
"tslog": "^4.9.3"
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -1,2 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 47.5 47.5" style="enable-background:new 0 0 47.5 47.5;" xml:space="preserve" version="1.1" id="svg2"><metadata id="metadata8"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/></cc:Work></rdf:RDF></metadata><defs id="defs6"><clipPath id="clipPath16" clipPathUnits="userSpaceOnUse"><path id="path18" d="M 0,38 38,38 38,0 0,0 0,38 Z"/></clipPath></defs><g transform="matrix(1.25,0,0,-1.25,0,47.5)" id="g10"><g id="g12"><g clip-path="url(#clipPath16)" id="g14"><g transform="translate(35.3467,20.1069)" id="g20"><path id="path22" style="fill:#aa8ed6;fill-opacity:1;fill-rule:nonzero;stroke:none" d="m 0,0 -8.899,3.294 -3.323,10.891 c -0.128,0.42 -0.516,0.708 -0.956,0.708 -0.439,0 -0.828,-0.288 -0.956,-0.708 L -17.456,3.294 -26.356,0 c -0.393,-0.146 -0.653,-0.52 -0.653,-0.938 0,-0.418 0.26,-0.793 0.653,-0.937 l 8.896,-3.293 3.323,-11.223 c 0.126,-0.425 0.516,-0.716 0.959,-0.716 0.443,0 0.833,0.291 0.959,0.716 l 3.324,11.223 8.896,3.293 c 0.392,0.144 0.652,0.519 0.652,0.937 C 0.653,-0.52 0.393,-0.146 0,0"/></g><g transform="translate(15.3472,9.1064)" id="g24"><path id="path26" style="fill:#fcab40;fill-opacity:1;fill-rule:nonzero;stroke:none" d="m 0,0 -2.313,0.856 -0.9,3.3 c -0.119,0.436 -0.514,0.738 -0.965,0.738 -0.451,0 -0.846,-0.302 -0.965,-0.738 l -0.9,-3.3 L -8.356,0 c -0.393,-0.145 -0.653,-0.52 -0.653,-0.937 0,-0.418 0.26,-0.793 0.653,-0.938 l 2.301,-0.853 0.907,-3.622 c 0.111,-0.444 0.511,-0.756 0.97,-0.756 0.458,0 0.858,0.312 0.97,0.756 L -2.301,-2.728 0,-1.875 c 0.393,0.145 0.653,0.52 0.653,0.938 C 0.653,-0.52 0.393,-0.145 0,0"/></g><g transform="translate(11.0093,30.769)" id="g28"><path id="path30" style="fill:#5dadec;fill-opacity:1;fill-rule:nonzero;stroke:none" d="M 0,0 -2.365,0.875 -3.24,3.24 c -0.146,0.393 -0.52,0.653 -0.938,0.653 -0.419,0 -0.793,-0.26 -0.938,-0.653 L -5.992,0.875 -8.356,0 c -0.393,-0.146 -0.653,-0.52 -0.653,-0.938 0,-0.418 0.26,-0.792 0.653,-0.938 l 2.364,-0.875 0.876,-2.365 c 0.145,-0.393 0.519,-0.653 0.938,-0.653 0.418,0 0.792,0.26 0.938,0.653 L -2.365,-2.751 0,-1.876 c 0.393,0.146 0.653,0.52 0.653,0.938 C 0.653,-0.52 0.393,-0.146 0,0"/></g></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -1,163 +0,0 @@
{
"error": {
"heading": "An error occurred",
"validation": {
"too-long": "Value is too long, maximum length is {{maxLength}}, current length is {{actualLength}}.",
"too-short": "Value is too short, minimum length is {{minLength}}, current length is {{actualLength}}.",
"disallowed-value": "The value <1>{{actualValue}}</1> is not allowed here. Allowed values are: <4>{{allowedValues}}</4>",
"generic": "The value <1>{{actualValue}}</1> is not allowed here. Reason: {{reason}}",
"generic-no-value": "The value you entered is not allowed here. Reason: {{reason}}"
},
"errors": {
"authentication-error": "There was an error validating your credentials.",
"authentication-required": "You need to log in.",
"bad-request": "Server rejected your input, please check anything for errors.",
"forbidden": "You are not allowed to perform that action.",
"generic-error": "An unknown error occurred.",
"internal-server-error": "Server experienced an internal error, please try again later.",
"member-not-found": "Member not found, please check your spelling and try again.",
"user-not-found": "User 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."
},
"title": "An error occurred",
"more-info": "Click here for a more detailed error"
},
"navbar": {
"view-profile": "View profile",
"settings": "Settings",
"log-out": "Log out",
"log-in": "Log in or sign up"
},
"user": {
"avatar-alt": "Avatar for @{{username}}",
"heading": {
"names": "Names",
"pronouns": "Pronouns",
"members": "Members"
},
"member-avatar-alt": "Avatar for {{name}}",
"member-hidden": "This member is unlisted, and not shown in your public member list.",
"own-profile-alert": "You are currently viewing your <1>public</1> profile.<3></3><4>Edit your profile</4>",
"create-member-button": "Create member",
"no-members-blurb": "You don't have any members yet.<1></1>Members are sub-profiles that can have their own avatar, names, pronouns, and preferred terms.<3></3>You can create a new member with the \"Create member\" button above. <6>(only you can see this)</6>"
},
"member": {
"avatar-alt": "Avatar for {{name}}",
"own-profile-alert": "You are currently viewing the <1>public</1> profile of {{memberName}}.<5></5><6>Edit profile</6>",
"back": "Back to {{name}}"
},
"log-in": {
"callback": {
"invalid-ticket": "Invalid ticket (it might have been too long since you logged in), please <2>try again</2>.",
"invalid-username": "Invalid username",
"username-taken": "That username is already taken, please try something else.",
"title": {
"discord-link": "Link a new Discord account",
"discord-success": "Log in with Discord",
"discord-register": "Register with Discord",
"fediverse-success": "Log in with a Fediverse account",
"fediverse-register": "Register with a Fediverse account"
},
"link-error": "Could not link account",
"discord-link-success": "Linked a new Discord account!",
"discord-link-success-hint": "Successfully linked the Discord account {{username}} with your pronouns.cc account. You can now close this page.",
"success": "Successfully logged in!",
"success-link": "Welcome back, <1>@{{username}}</1>!",
"redirect-hint": "If you're not redirected to your profile in a few seconds, press the link above.",
"remote-username": {
"discord": "Your Discord username",
"fediverse": "Your Fediverse account"
},
"username": "Username",
"sign-up-button": "Sign up"
},
"fediverse": {
"choose-title": "Log in with a Fediverse account",
"choose-form-title": "Choose a Fediverse instance"
},
"fediverse-instance-label": "Your Fediverse instance",
"fediverse-log-in-button": "Log in",
"title": "Log in",
"form-title": "Log in with email",
"email": "Email address",
"password": "Password",
"log-in-button": "Log in",
"register-with-email": "Register with email",
"3rd-party": {
"title": "Log in with another service",
"desc": "If you prefer, you can also log in with one of these services:",
"discord": "Log in with Discord",
"google": "Log in with Google",
"tumblr": "Log in with Tumblr",
"fediverse": "Log in with the Fediverse"
},
"invalid-credentials": "Invalid email address or password, please check your spelling and try again."
},
"welcome": {
"title": "Welcome",
"header": "Welcome to pronouns.cc!",
"blurb": "{welcome.blurb}",
"customize-profile": "Customize your profile",
"customize-profile-blurb": "{welcome.customize-profile-blurb}",
"create-members": "Create members",
"create-members-blurb": "{welcome.create-members-blurb}",
"custom-preferences": "Customize your preferences",
"custom-preferences-blurb": "{welcome.custom-preferences-blurb}",
"profile-button": "Go to your profile"
},
"settings": {
"general": {
"username": "Username",
"change-username": "Change username",
"username-change-hint": "Changing your username will make any existing links to your or your members' profiles invalid.\nYour username must be unique, be at most 40 characters long, and only contain letters from the basic English alphabet, dashes, underscores, and periods. Your username is used as part of your profile link, you can set a separate display name.",
"log-out-everywhere": "Log out everywhere",
"log-out-everywhere-hint": "If you think one of your tokens might have been compromised, you can log out on all devices by clicking this button.",
"force-log-out-button": "Force log out",
"table-header": "General account information",
"id": "Your user ID",
"created": "Account created at",
"member-count": "Members",
"member-list-hidden": "Member list hidden?",
"custom-preferences": "Custom preferences",
"role": "Account role",
"username-update-error": "Could not update your username as the new username is invalid:\n{{message}}",
"log-out-everywhere-confirm": "Are you sure you want to log out everywhere?\nPlease double check your authentication methods before doing so, as it might lock you out of your account."
},
"auth": {
"title": "Authentication",
"form": {
"add-first-email": "Set an email address",
"add-extra-email": "Add another email address",
"email-address": "Email address",
"password-1": "Password",
"password-2": "Confirm password",
"add-email-button": "Add email address",
"add-first-discord-account": "Link a Discord account",
"add-extra-discord-account": "Link another Discord account",
"add-first-fediverse-account": "Link a Fediverse account",
"add-extra-fediverse-account": "Link another Fediverse account"
},
"no-email": "You haven't linked any email addresses yet. You can add one using this form.",
"new-email-pending": "Email address added! Click the link in your inbox to confirm.",
"email-link-success": "Email successfully linked",
"redirect-to-auth-hint": "You will be redirected back to the authentication page in a few seconds.",
"remove-auth-method-title": "Remove authentication method",
"remove-auth-method-hint": "Are you sure you want to remove {{username}} ({{methodName}}) from your account? You will no longer be able to log in using it.",
"remove-auth-method": "Remove",
"email-addresses": "Email addresses",
"discord-accounts": "Linked Discord accounts",
"fediverse-accounts": "Linked Fediverse accounts"
},
"title": "Settings",
"nav": {
"general-information": "General information",
"profile": "Base profile",
"members": "Members",
"authentication": "Authentication",
"export": "Export your data"
}
},
"yes": "Yes",
"no": "No"
}

View file

@ -1,51 +0,0 @@
import { env } from "node:process";
import { createRequestHandler } from "@remix-run/express";
import compression from "compression";
import express from "express";
import morgan from "morgan";
const viteDevServer =
env.NODE_ENV === "production"
? undefined
: await import("vite").then((vite) =>
vite.createServer({
server: { middlewareMode: true },
}),
);
const remixHandler = createRequestHandler({
build: viteDevServer
? () => viteDevServer.ssrLoadModule("virtual:remix/server-build")
: await import("./build/server/index.js"),
});
const app = express();
app.use(compression());
// http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header
app.disable("x-powered-by");
// handle asset requests
if (viteDevServer) {
app.use(viteDevServer.middlewares);
} else {
// Vite fingerprints its assets so we can cache forever.
app.use("/assets", express.static("build/client/assets", { immutable: true, maxAge: "1y" }));
}
// Only cache locales for a minute, as they can change without the filename changing
// TODO: figure out how to change the filenames on update?
app.use("/locales", express.static("build/client/locales", { maxAge: "1m" }));
// Everything else (like favicon.ico) is cached for an hour. You may want to be
// more aggressive with this caching.
app.use(express.static("build/client", { maxAge: "1d" }));
app.use(morgan("tiny"));
// handle SSR requests
app.all("*", remixHandler);
const port = env.PORT || 3000;
app.listen(port, () => console.log(`Express server listening at http://localhost:${port}`));

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

@ -18,17 +18,25 @@
) )
); );
@import "bootstrap-icons/font/bootstrap-icons.css";
@import "@fontsource/firago/400.css"; @import "@fontsource/firago/400.css";
@import "@fontsource/firago/400-italic.css"; @import "@fontsource/firago/400-italic.css";
@import "@fontsource/firago/700.css"; @import "@fontsource/firago/700.css";
@import "@fontsource/firago/700-italic.css";
.pride-flag {
height: 1.5rem;
max-width: 200px;
border-radius: 3px;
}
// This is necessary for line breaks in translation strings to show up. Don't ask me why // This is necessary for line breaks in translation strings to show up. Don't ask me why
.text-has-newline { .text-has-newline {
white-space: pre-line; white-space: pre-line;
} }
// Add breakpoint-dependent w-{size} utilities
// Source: https://stackoverflow.com/questions/47760132/any-way-to-get-breakpoint-specific-width-classes
@each $breakpoint in map-keys(bootstrap.$grid-breakpoints) {
@each $size, $length in (25: 25%, 50: 50%, 75: 75%, 100: 100%) {
@include bootstrap.media-breakpoint-up($breakpoint) {
.w-#{$breakpoint}-#{$size} {
width: $length !important;
}
}
}
}

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

@ -1,5 +1,27 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ export default class ApiError {
export type 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; status: number;
message: string; message: string;
code: ErrorCode; code: ErrorCode;
@ -17,7 +39,9 @@ export enum ErrorCode {
UserNotFound = "USER_NOT_FOUND", UserNotFound = "USER_NOT_FOUND",
MemberNotFound = "MEMBER_NOT_FOUND", MemberNotFound = "MEMBER_NOT_FOUND",
AccountAlreadyLinked = "ACCOUNT_ALREADY_LINKED", AccountAlreadyLinked = "ACCOUNT_ALREADY_LINKED",
LastAuthMetod = "LAST_AUTH_METHOD", LastAuthMethod = "LAST_AUTH_METHOD",
// This code isn't actually returned by the API
Non204Response = "(non 204 response)",
} }
export type ValidationError = { export type ValidationError = {
@ -34,25 +58,9 @@ export type ValidationError = {
* @param error The error object to traverse. * @param error The error object to traverse.
* @param key The JSON key to find. * @param key The JSON key to find.
*/ */
export const firstErrorFor = (error: ApiError, key: string): ValidationError | undefined => { export const firstErrorFor = (error: RawApiError, key: string): ValidationError | undefined => {
if (!error.errors) return undefined; if (!error.errors) return undefined;
const field = error.errors.find((e) => e.key == key); const field = error.errors.find((e) => e.key == key);
if (!field?.errors) return undefined; if (!field?.errors) return undefined;
return field.errors.length != 0 ? field.errors[0] : undefined; return field.errors.length != 0 ? field.errors[0] : undefined;
}; };
export enum ValidationErrorType {
LengthError = 0,
DisallowedValueError = 1,
GenericValidationError = 2,
}
export const validationErrorType = (error: ValidationError) => {
if (error.min_length && error.max_length && error.actual_length) {
return ValidationErrorType.LengthError;
}
if (error.allowed_values && error.actual_value) {
return ValidationErrorType.DisallowedValueError;
}
return ValidationErrorType.GenericValidationError;
};

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

@ -1,4 +1,4 @@
import { User } from "~/lib/api/user"; import type { User } from "./user";
export type AuthResponse = { export type AuthResponse = {
user: User; user: User;

View file

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

View file

@ -1,4 +1,4 @@
import { Field, PartialMember, PartialUser, PrideFlag } from "~/lib/api/user"; import type { Field, PartialMember, PartialUser, PrideFlag } from "./user";
export type Member = PartialMember & { export type Member = PartialMember & {
fields: Field[]; fields: Field[];

View file

@ -1,4 +1,5 @@
export default interface Meta { export type Meta = {
repository: string;
version: string; version: string;
hash: string; hash: string;
users: { users: {
@ -9,7 +10,7 @@ export default interface Meta {
}; };
members: number; members: number;
limits: Limits; limits: Limits;
} };
export type Limits = { export type Limits = {
member_count: number; member_count: number;

View file

@ -1,8 +1,8 @@
export type PartialUser = { export type PartialUser = {
id: string; id: string;
username: string; username: string;
display_name?: string | null; display_name: string | null;
avatar_url?: string | null; avatar_url: string | null;
custom_preferences: Record<string, CustomPreference>; custom_preferences: Record<string, CustomPreference>;
}; };
@ -39,7 +39,7 @@ export type UserSettings = {
export type PartialMember = { export type PartialMember = {
id: string; id: string;
name: string; name: string;
display_name: string | null; display_name: string;
bio: string | null; bio: string | null;
avatar_url: string | null; avatar_url: string | null;
names: FieldEntry[]; names: FieldEntry[];
@ -87,7 +87,9 @@ export enum PreferenceSize {
Small = "SMALL", Small = "SMALL",
} }
export function mergePreferences(prefs: Record<string, CustomPreference>) { export function mergePreferences(
prefs: Record<string, CustomPreference>,
): Record<string, CustomPreference> {
return Object.assign({}, defaultPreferences, prefs); return Object.assign({}, defaultPreferences, prefs);
} }

View file

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

View file

@ -0,0 +1,34 @@
<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 = { showHeader?: boolean; headerElem?: string; error: RawApiError };
let { showHeader, headerElem, error }: Props = $props();
</script>
{#if showHeader !== false}
<svelte:element this={headerElem ?? "h4"}>
{#if error.code === ErrorCode.BadRequest}
{$t("error.bad-request-header")}
{:else}
{$t("error.generic-header")}
{/if}
</svelte:element>
{/if}
<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,91 @@
{
"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",
"settings": "Settings"
},
"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"
},
"settings": {
"general-information-tab": "General information",
"your-profile-tab": "Your profile",
"members-tab": "Members",
"authentication-tab": "Authentication",
"export-tab": "Export your data",
"change-username-button": "Change username",
"username-change-hint": "Changing your username will make any existing links to your or your members' profiles invalid.\nYour username must be unique, be at most 40 characters long, and only contain letters from the basic English alphabet, dashes, underscores, and periods. Your username is used as part of your profile link, you can set a separate display name.",
"username-update-error": "Could not update your username as the new username is invalid:\n{{message}}",
"change-avatar-link": "Change your avatar here",
"new-username": "New username",
"table-role": "Role",
"table-custom-preferences": "Custom preferences",
"table-member-list-hidden": "Member list hidden?",
"table-member-count": "Member count",
"table-created-at": "Account created at",
"table-id": "Your ID",
"table-title": "Account information",
"force-log-out-title": "Log out everywhere",
"force-log-out-button": "Force log out",
"force-log-out-hint": "If you think one of your tokens might have been compromised, you can log out on all devices by clicking this button.",
"log-out-title": "Log out",
"log-out-hint": "Use this button to log out on this device only.",
"log-out-button": "Log out"
},
"yes": "Yes",
"no": "No"
}

View file

@ -0,0 +1,16 @@
// place files you want to import through the `$lib` alias in this folder.
import type { Cookies } from "@sveltejs/kit";
import { DateTime } from "luxon";
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";
export const idTimestamp = (id: string) =>
DateTime.fromMillis(parseInt(id, 10) / (1 << 22) + 1_640_995_200_000);

View file

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

View file

@ -13,10 +13,6 @@ const unsafeMd = new MarkdownIt({
linkify: true, linkify: true,
}); });
export function renderMarkdown(src: string | null) { export const renderMarkdown = (src: string | null) => (src ? sanitize(md.render(src)) : null);
return src ? sanitize(md.render(src)) : null;
}
export function renderUnsafeMarkdown(src: string) { export const renderUnsafeMarkdown = (src: string) => sanitize(unsafeMd.render(src));
return 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, MeUser } from "$api/models";
import log from "$lib/log";
import type { LayoutServerLoad } from "./$types";
export const load = (async ({ fetch, cookies }) => {
let meUser: MeUser | null = null;
if (cookies.get(TOKEN_COOKIE_NAME)) {
try {
meUser = await apiRequest<MeUser>("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>

Some files were not shown because too many files have changed in this diff Show more