Compare commits
3 commits
c8cd483d20
...
c179669799
Author | SHA1 | Date | |
---|---|---|---|
c179669799 | |||
0c78cd25b0 | |||
0d47f1fb01 |
119 changed files with 4649 additions and 10781 deletions
|
@ -3,7 +3,7 @@
|
|||
"tasks": [
|
||||
{
|
||||
"name": "run-prettier",
|
||||
"command": "yarn",
|
||||
"command": "pnpm",
|
||||
"args": ["format"],
|
||||
"pathMode": "absolute"
|
||||
},
|
||||
|
|
|
@ -11,6 +11,7 @@ using NodaTime;
|
|||
using Prometheus;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using Serilog.Sinks.SystemConsole.Themes;
|
||||
using FediverseAuthService = Foxnouns.Backend.Services.Auth.FediverseAuthService;
|
||||
using IClock = NodaTime.IClock;
|
||||
|
||||
|
@ -38,7 +39,7 @@ public static class WebApplicationExtensions
|
|||
.MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning)
|
||||
.WriteTo.Console();
|
||||
.WriteTo.Console(theme: AnsiConsoleTheme.Sixteen);
|
||||
|
||||
if (config.Logging.SeqLogUrl != null)
|
||||
{
|
||||
|
|
5
Foxnouns.Frontend/.env.example
Normal file
5
Foxnouns.Frontend/.env.example
Normal 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
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
18
Foxnouns.Frontend/.gitignore
vendored
18
Foxnouns.Frontend/.gitignore
vendored
|
@ -1,5 +1,21 @@
|
|||
node_modules
|
||||
|
||||
/.cache
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
|
1
Foxnouns.Frontend/.npmrc
Normal file
1
Foxnouns.Frontend/.npmrc
Normal file
|
@ -0,0 +1 @@
|
|||
engine-strict=true
|
4
Foxnouns.Frontend/.prettierignore
Normal file
4
Foxnouns.Frontend/.prettierignore
Normal file
|
@ -0,0 +1,4 @@
|
|||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
|
@ -1,4 +1,13 @@
|
|||
{
|
||||
"useTabs": true,
|
||||
"printWidth": 100
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
8
Foxnouns.Frontend/.vscode/settings.json
vendored
Normal file
8
Foxnouns.Frontend/.vscode/settings.json
vendored
Normal 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
|
||||
}
|
|
@ -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"]
|
|
@ -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
|
||||
npm run dev
|
||||
```bash
|
||||
# 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
|
||||
```
|
||||
|
||||
Then run the app in production mode:
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
```sh
|
||||
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.
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
|
|
|
@ -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");
|
||||
};
|
|
@ -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} />;
|
||||
}
|
|
@ -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} />;
|
||||
}
|
|
@ -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
|
@ -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>;
|
||||
}
|
|
@ -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"}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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}</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
|
@ -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";
|
|
@ -1,5 +0,0 @@
|
|||
export default {
|
||||
supportedLngs: ["en", "en-XX"],
|
||||
fallbackLng: "en",
|
||||
defaultNS: "common",
|
||||
};
|
|
@ -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;
|
|
@ -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,
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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'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 "Create member" 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}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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")}</>;
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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} />;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
33
Foxnouns.Frontend/eslint.config.js
Normal file
33
Foxnouns.Frontend/eslint.config.js
Normal 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/"],
|
||||
},
|
||||
);
|
|
@ -1,3 +0,0 @@
|
|||
export default {
|
||||
locales: ["en"],
|
||||
};
|
|
@ -1,74 +1,47 @@
|
|||
{
|
||||
"name": "foxnouns-fe",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"name": "foxnouns.frontend",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "remix vite:build",
|
||||
"dev": "node ./server.js",
|
||||
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
|
||||
"start": "cross-env NODE_ENV=production node ./server.js",
|
||||
"typecheck": "tsc",
|
||||
"format": "prettier -w .",
|
||||
"extract-translations": "i18next 'app/**/*.tsx' -o 'public/locales/$LOCALE.json'"
|
||||
},
|
||||
"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"
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"format": "prettier --write .",
|
||||
"lint": "prettier --check . && eslint ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fontsource/firago": "^5.0.11",
|
||||
"@remix-run/dev": "^2.11.2",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cookie": "^0.6.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@sveltejs/adapter-node": "^5.2.9",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@sveltestrap/sveltestrap": "^6.2.7",
|
||||
"@types/eslint": "^9.6.0",
|
||||
"@types/luxon": "^3.4.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",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.4",
|
||||
"@typescript-eslint/parser": "^6.7.4",
|
||||
"eslint": "^8.38.0",
|
||||
"eslint-import-resolver-typescript": "^3.6.1",
|
||||
"eslint-plugin-import": "^2.28.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.7.1",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"i18next-parser": "^9.0.2",
|
||||
"prettier": "^3.3.3",
|
||||
"sass": "1.77.6",
|
||||
"typescript": "^5.1.6",
|
||||
"vite": "^5.1.0",
|
||||
"vite-tsconfig-paths": "^4.2.1"
|
||||
"bootstrap": "^5.3.3",
|
||||
"eslint": "^9.7.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.36.0",
|
||||
"globals": "^15.0.0",
|
||||
"prettier": "^3.3.2",
|
||||
"prettier-plugin-svelte": "^3.2.6",
|
||||
"sass": "^1.81.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"sveltekit-i18n": "^2.4.2",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"vite": "^5.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"packageManager": "pnpm@9.12.3+sha512.cce0f9de9c5a7c95bef944169cc5dfe8741abfb145078c0d508b868056848a87c81e626246cb60967cbd7fd29a6c062ef73ff840d96b3c86c40ac92cf4a813ee",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
2637
Foxnouns.Frontend/pnpm-lock.yaml
Normal file
2637
Foxnouns.Frontend/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
|
@ -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 |
|
@ -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"
|
||||
}
|
|
@ -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
13
Foxnouns.Frontend/src/app.d.ts
vendored
Normal 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 {};
|
12
Foxnouns.Frontend/src/app.html
Normal file
12
Foxnouns.Frontend/src/app.html
Normal 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>
|
|
@ -18,17 +18,25 @@
|
|||
)
|
||||
);
|
||||
|
||||
@import "bootstrap-icons/font/bootstrap-icons.css";
|
||||
@import "@fontsource/firago/400.css";
|
||||
@import "@fontsource/firago/400-italic.css";
|
||||
@import "@fontsource/firago/700.css";
|
||||
|
||||
.pride-flag {
|
||||
height: 1.5rem;
|
||||
max-width: 200px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
@import "@fontsource/firago/700-italic.css";
|
||||
|
||||
// This is necessary for line breaks in translation strings to show up. Don't ask me why
|
||||
.text-has-newline {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
13
Foxnouns.Frontend/src/hooks.server.ts
Normal file
13
Foxnouns.Frontend/src/hooks.server.ts
Normal 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);
|
||||
};
|
|
@ -1,5 +1,27 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
export type ApiError = {
|
||||
export default class ApiError {
|
||||
raw?: RawApiError;
|
||||
code: ErrorCode;
|
||||
|
||||
constructor(err?: RawApiError, code?: ErrorCode) {
|
||||
this.raw = err;
|
||||
this.code = err?.code || code || ErrorCode.InternalServerError;
|
||||
}
|
||||
|
||||
get obj(): RawApiError {
|
||||
return this.toObject();
|
||||
}
|
||||
|
||||
toObject(): RawApiError {
|
||||
return {
|
||||
status: this.raw?.status || 500,
|
||||
code: this.code,
|
||||
message: this.raw?.message || "Internal server error",
|
||||
errors: this.raw?.errors,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type RawApiError = {
|
||||
status: number;
|
||||
message: string;
|
||||
code: ErrorCode;
|
||||
|
@ -17,7 +39,9 @@ export enum ErrorCode {
|
|||
UserNotFound = "USER_NOT_FOUND",
|
||||
MemberNotFound = "MEMBER_NOT_FOUND",
|
||||
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 = {
|
||||
|
@ -34,25 +58,9 @@ export type ValidationError = {
|
|||
* @param error The error object to traverse.
|
||||
* @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;
|
||||
const field = error.errors.find((e) => e.key == key);
|
||||
if (!field?.errors) return 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;
|
||||
};
|
92
Foxnouns.Frontend/src/lib/api/index.ts
Normal file
92
Foxnouns.Frontend/src/lib/api/index.ts
Normal file
|
@ -0,0 +1,92 @@
|
|||
import { PUBLIC_API_BASE } from "$env/static/public";
|
||||
import type { Cookies } from "@sveltejs/kit";
|
||||
import ApiError, { ErrorCode } from "./error";
|
||||
import { TOKEN_COOKIE_NAME } from "$lib";
|
||||
import log from "$lib/log";
|
||||
|
||||
export type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
|
||||
export type RequestArgs = {
|
||||
token?: string;
|
||||
isInternal?: boolean;
|
||||
body?: any;
|
||||
fetch?: typeof fetch;
|
||||
cookies?: Cookies;
|
||||
};
|
||||
|
||||
/**
|
||||
* Makes a raw request to the API.
|
||||
* @param method The HTTP method for this request
|
||||
* @param path The path for this request, without the /api/v2 prefix, starting with a slash.
|
||||
* @param args Optional arguments to the request function.
|
||||
* @returns A Promise object.
|
||||
*/
|
||||
export async function baseRequest(
|
||||
method: Method,
|
||||
path: string,
|
||||
args: RequestArgs = {},
|
||||
): Promise<Response> {
|
||||
const token = args.token ?? args.cookies?.get(TOKEN_COOKIE_NAME);
|
||||
|
||||
const fetchFn = args.fetch ?? fetch;
|
||||
const url = `${PUBLIC_API_BASE}/${args.isInternal ? "internal" : "v2"}${path}`;
|
||||
|
||||
log.debug("Sending request to %s %s", method, url);
|
||||
|
||||
const headers = {
|
||||
...(args.body ? { "Content-Type": "application/json; charset=utf-8" } : {}),
|
||||
...(token ? { Authorization: token } : {}),
|
||||
};
|
||||
|
||||
return await fetchFn(url, {
|
||||
method,
|
||||
headers,
|
||||
body: args.body ? JSON.stringify(args.body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a request to the API and parses the returned object.
|
||||
* @param method The HTTP method for this request
|
||||
* @param path The path for this request, without the /api/v2 prefix, starting with a slash.
|
||||
* @param args Optional arguments to the request function.
|
||||
* @returns The response deserialized as `T`.
|
||||
*/
|
||||
export async function apiRequest<T>(
|
||||
method: Method,
|
||||
path: string,
|
||||
args: RequestArgs = {},
|
||||
): Promise<T> {
|
||||
const resp = await baseRequest(method, path, args);
|
||||
|
||||
if (resp.status < 200 || resp.status > 299) {
|
||||
const err = await resp.json();
|
||||
if ("code" in err) throw new ApiError(err);
|
||||
else throw new ApiError();
|
||||
}
|
||||
return (await resp.json()) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a request without reading the body (unless the API returns an error).
|
||||
* @param method The HTTP method for this request
|
||||
* @param path The path for this request, without the /api/v2 prefix, starting with a slash.
|
||||
* @param args Optional arguments to the request function.
|
||||
* @param enforce204 Whether to throw an error on a non-204 status code.
|
||||
*/
|
||||
export async function fastRequest(
|
||||
method: Method,
|
||||
path: string,
|
||||
args: RequestArgs = {},
|
||||
enforce204: boolean = false,
|
||||
): Promise<void> {
|
||||
const resp = await baseRequest(method, path, args);
|
||||
|
||||
if (resp.status < 200 || resp.status > 299) {
|
||||
const err = await resp.json();
|
||||
if ("code" in err) throw new ApiError(err);
|
||||
else throw new ApiError();
|
||||
}
|
||||
|
||||
if (enforce204 && resp.status !== 204) throw new ApiError(undefined, ErrorCode.Non204Response);
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { User } from "~/lib/api/user";
|
||||
import type { User } from "./user";
|
||||
|
||||
export type AuthResponse = {
|
||||
user: User;
|
4
Foxnouns.Frontend/src/lib/api/models/index.ts
Normal file
4
Foxnouns.Frontend/src/lib/api/models/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export * from "./meta";
|
||||
export * from "./user";
|
||||
export * from "./member";
|
||||
export * from "./auth";
|
|
@ -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 & {
|
||||
fields: Field[];
|
|
@ -1,4 +1,5 @@
|
|||
export default interface Meta {
|
||||
export type Meta = {
|
||||
repository: string;
|
||||
version: string;
|
||||
hash: string;
|
||||
users: {
|
||||
|
@ -9,7 +10,7 @@ export default interface Meta {
|
|||
};
|
||||
members: number;
|
||||
limits: Limits;
|
||||
}
|
||||
};
|
||||
|
||||
export type Limits = {
|
||||
member_count: number;
|
|
@ -1,8 +1,8 @@
|
|||
export type PartialUser = {
|
||||
id: string;
|
||||
username: string;
|
||||
display_name?: string | null;
|
||||
avatar_url?: string | null;
|
||||
display_name: string | null;
|
||||
avatar_url: string | null;
|
||||
custom_preferences: Record<string, CustomPreference>;
|
||||
};
|
||||
|
||||
|
@ -39,7 +39,7 @@ export type UserSettings = {
|
|||
export type PartialMember = {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string | null;
|
||||
display_name: string;
|
||||
bio: string | null;
|
||||
avatar_url: string | null;
|
||||
names: FieldEntry[];
|
||||
|
@ -87,7 +87,9 @@ export enum PreferenceSize {
|
|||
Small = "SMALL",
|
||||
}
|
||||
|
||||
export function mergePreferences(prefs: Record<string, CustomPreference>) {
|
||||
export function mergePreferences(
|
||||
prefs: Record<string, CustomPreference>,
|
||||
): Record<string, CustomPreference> {
|
||||
return Object.assign({}, defaultPreferences, prefs);
|
||||
}
|
||||
|
14
Foxnouns.Frontend/src/lib/components/Avatar.svelte
Normal file
14
Foxnouns.Frontend/src/lib/components/Avatar.svelte
Normal file
|
@ -0,0 +1,14 @@
|
|||
<script lang="ts">
|
||||
import { DEFAULT_AVATAR } from "$lib";
|
||||
|
||||
type Props = { url: string | null; alt: string; lazyLoad?: boolean; 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"}
|
||||
/>
|
34
Foxnouns.Frontend/src/lib/components/Error.svelte
Normal file
34
Foxnouns.Frontend/src/lib/components/Error.svelte
Normal 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>
|
11
Foxnouns.Frontend/src/lib/components/ErrorAlert.svelte
Normal file
11
Foxnouns.Frontend/src/lib/components/ErrorAlert.svelte
Normal file
|
@ -0,0 +1,11 @@
|
|||
<script lang="ts">
|
||||
import type { RawApiError } from "$api/error";
|
||||
import Error from "./Error.svelte";
|
||||
|
||||
type Props = { error: RawApiError };
|
||||
let { error }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<Error {error} />
|
||||
</div>
|
34
Foxnouns.Frontend/src/lib/components/Logo.svelte
Normal file
34
Foxnouns.Frontend/src/lib/components/Logo.svelte
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 7.9 KiB |
69
Foxnouns.Frontend/src/lib/components/Navbar.svelte
Normal file
69
Foxnouns.Frontend/src/lib/components/Navbar.svelte
Normal file
|
@ -0,0 +1,69 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
Navbar,
|
||||
NavbarBrand,
|
||||
NavbarToggler,
|
||||
Collapse,
|
||||
Nav,
|
||||
NavLink,
|
||||
NavItem,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
import { page } from "$app/stores";
|
||||
import type { User, Meta } from "$api/models/index";
|
||||
import Logo from "$components/Logo.svelte";
|
||||
import { t } from "$lib/i18n";
|
||||
|
||||
type Props = { user: User | null; meta: Meta };
|
||||
let { user, meta }: Props = $props();
|
||||
|
||||
let isOpen = $state(true);
|
||||
const toggleMenu = () => (isOpen = !isOpen);
|
||||
</script>
|
||||
|
||||
<Navbar expand="lg" class="mb-4 mx-2">
|
||||
<NavbarBrand href="/">
|
||||
<Logo />
|
||||
{#if meta.version.endsWith(".dirty")}
|
||||
<strong id="beta-text" class="text-danger">dev</strong>
|
||||
{:else}
|
||||
<span id="beta-text">beta</span>
|
||||
{/if}
|
||||
</NavbarBrand>
|
||||
<NavbarToggler onclick={toggleMenu} aria-label="Toggle menu" />
|
||||
<Collapse {isOpen} navbar expand="lg">
|
||||
<Nav navbar class="ms-auto">
|
||||
{#if user}
|
||||
<NavItem>
|
||||
<NavLink
|
||||
href="/@{user.username}"
|
||||
active={$page.url.pathname.startsWith(`/@${user.username}`)}
|
||||
>
|
||||
@{user.username}
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
<NavItem>
|
||||
<NavLink href="/settings" active={$page.url.pathname.startsWith("/settings")}>
|
||||
{$t("nav.settings")}
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
{:else}
|
||||
<NavItem>
|
||||
<NavLink href="/auth/log-in" active={$page.url.pathname === "/auth/log-in"}>
|
||||
{$t("nav.log-in")}
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
{/if}
|
||||
</Nav>
|
||||
</Collapse>
|
||||
</Navbar>
|
||||
|
||||
<style>
|
||||
/* These exact values make it look almost identical to the SVG version, which is what we want */
|
||||
#beta-text {
|
||||
font-size: 0.7em;
|
||||
position: relative;
|
||||
font-style: italic;
|
||||
bottom: 12px;
|
||||
right: 3px;
|
||||
}
|
||||
</style>
|
17
Foxnouns.Frontend/src/lib/components/StatusIcon.svelte
Normal file
17
Foxnouns.Frontend/src/lib/components/StatusIcon.svelte
Normal file
|
@ -0,0 +1,17 @@
|
|||
<script lang="ts">
|
||||
import { Icon, Tooltip } from "@sveltestrap/sveltestrap";
|
||||
|
||||
import type { CustomPreference } from "$api/models/user";
|
||||
|
||||
type Props = { preference: CustomPreference };
|
||||
let { preference }: Props = $props();
|
||||
|
||||
// svelte-ignore non_reactive_update
|
||||
let elem: HTMLSpanElement;
|
||||
</script>
|
||||
|
||||
<span bind:this={elem} aria-hidden={true}>
|
||||
<Icon name={preference.icon} />
|
||||
</span>
|
||||
<span class="visually-hidden">{preference.tooltip}:</span>
|
||||
<Tooltip aria-hidden target={elem} placement="top">{preference.tooltip}</Tooltip>
|
|
@ -0,0 +1,16 @@
|
|||
<script lang="ts">
|
||||
import type { ValidationError } from "$api/error";
|
||||
import RequestValidationError from "./RequestValidationError.svelte";
|
||||
|
||||
type Props = { key: string; errors: ValidationError[] };
|
||||
let { key, errors }: Props = $props();
|
||||
</script>
|
||||
|
||||
<li>
|
||||
<code>{key}</code>:
|
||||
<ul>
|
||||
{#each errors as error}
|
||||
<RequestValidationError {error} />
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
|
@ -0,0 +1,41 @@
|
|||
<script lang="ts">
|
||||
import type { ValidationError } from "$api/error";
|
||||
import { t } from "$lib/i18n";
|
||||
|
||||
type Props = { error: ValidationError };
|
||||
let { error }: Props = $props();
|
||||
|
||||
let isLengthError = $derived(error.min_length && error.max_length && error.actual_length);
|
||||
let isDisallowedValueError = $derived(error.allowed_values && error.actual_value);
|
||||
</script>
|
||||
|
||||
{#if isLengthError}
|
||||
{#if error.actual_length! > error.max_length!}
|
||||
<li>
|
||||
{$t("error.validation-max-length-error", {
|
||||
max: error.max_length,
|
||||
actual: error.actual_length,
|
||||
})}
|
||||
</li>
|
||||
{:else}
|
||||
<li>
|
||||
{$t("error.validation-min-length-error", {
|
||||
min: error.min_length,
|
||||
actual: error.actual_length,
|
||||
})}
|
||||
</li>
|
||||
{/if}
|
||||
{:else if isDisallowedValueError}
|
||||
<li>
|
||||
{$t("error.validation-disallowed-value-1")}: <code>{error.actual_value}</code><br />
|
||||
{$t("error.validation-disallowed-value-2")}:
|
||||
<code>{error.allowed_values!.map((v) => v.toString()).join(", ")}</code>
|
||||
</li>
|
||||
{:else if error.actual_value}
|
||||
<li>
|
||||
{$t("error.validation-disallowed-value-1")}: <code>{error.actual_value}</code><br />
|
||||
{$t("error.validation-reason")}: {error.message}
|
||||
</li>
|
||||
{:else}
|
||||
<li>{$t("error.validation-generic")}: {error.message}</li>
|
||||
{/if}
|
|
@ -0,0 +1,16 @@
|
|||
<script lang="ts">
|
||||
import { t } from "$lib/i18n";
|
||||
|
||||
type Props = { memberName?: string; editLink: string };
|
||||
let { memberName, editLink }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="alert alert-secondary">
|
||||
{#if memberName}
|
||||
{$t("profile.edit-member-profile-notice", { memberName })}
|
||||
{:else}
|
||||
{$t("profile.edit-user-profile-notice")}
|
||||
{/if}
|
||||
<br />
|
||||
<a href={editLink}>{$t("profile.edit-profile-link")}</a>
|
||||
</div>
|
|
@ -0,0 +1,26 @@
|
|||
<script lang="ts">
|
||||
import type { CustomPreference, Member, User } from "$api/models";
|
||||
import ProfileField from "./field/ProfileField.svelte";
|
||||
import { t } from "$lib/i18n";
|
||||
|
||||
type Props = { profile: User | Member; allPreferences: Record<string, CustomPreference> };
|
||||
let { profile, allPreferences }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3">
|
||||
{#if profile.names.length > 0}
|
||||
<ProfileField name={$t("profile.names-header")} entries={profile.names} {allPreferences} />
|
||||
{/if}
|
||||
{#if profile.pronouns.length > 0}
|
||||
<ProfileField
|
||||
name={$t("profile.pronouns-header")}
|
||||
entries={profile.pronouns}
|
||||
{allPreferences}
|
||||
/>
|
||||
{/if}
|
||||
{#each profile.fields as field}
|
||||
{#if field.entries.length > 0}
|
||||
<ProfileField name={field.name} entries={field.entries} {allPreferences} />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
|
@ -0,0 +1,24 @@
|
|||
<script lang="ts">
|
||||
import type { PrideFlag } from "$api/models/user";
|
||||
import { Tooltip } from "@sveltestrap/sveltestrap";
|
||||
|
||||
type Props = { flag: PrideFlag };
|
||||
let { flag }: Props = $props();
|
||||
|
||||
// svelte-ignore non_reactive_update
|
||||
let elem: HTMLImageElement;
|
||||
</script>
|
||||
|
||||
<span class="mx-2 my-1">
|
||||
<Tooltip target={elem} aria-hidden placement="top">{flag.description ?? flag.name}</Tooltip>
|
||||
<img bind:this={elem} class="flag" src={flag.image_url} alt={flag.description ?? flag.name} />
|
||||
{flag.name}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.flag {
|
||||
height: 1.5rem;
|
||||
max-width: 200px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,69 @@
|
|||
<script lang="ts">
|
||||
import type { User, Member } from "$api/models";
|
||||
import { t } from "$lib/i18n";
|
||||
import { renderMarkdown } from "$lib/markdown";
|
||||
import ProfileLink from "./ProfileLink.svelte";
|
||||
import ProfileFlag from "./ProfileFlag.svelte";
|
||||
import Avatar from "$components/Avatar.svelte";
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
profile: User | Member;
|
||||
lazyLoadAvatar?: boolean;
|
||||
};
|
||||
|
||||
let { name, profile, lazyLoadAvatar }: Props = $props();
|
||||
|
||||
// renderMarkdown sanitizes the output HTML for us
|
||||
let bio = $derived(renderMarkdown(profile.bio));
|
||||
</script>
|
||||
|
||||
<div class="grid row-gap-3">
|
||||
<div class="row">
|
||||
<div class="col-md-4 text-center">
|
||||
<Avatar
|
||||
url={profile.avatar_url}
|
||||
alt={$t("avatar-tooltip", { name })}
|
||||
lazyLoad={lazyLoadAvatar}
|
||||
/>
|
||||
<!-- Flags show up below the avatar if the profile has a bio, otherwise they show up below the row entirely -->
|
||||
{#if profile.flags && profile.bio}
|
||||
<div class="d-flex flex-wrap m-4">
|
||||
{#each profile.flags as flag}
|
||||
<ProfileFlag {flag} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="col-md">
|
||||
{#if profile.display_name}
|
||||
<div>
|
||||
<h2>{profile.display_name}</h2>
|
||||
<p class="fs-5 text-body-secondary">{name}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<h2>{name}</h2>
|
||||
{/if}
|
||||
{#if bio}
|
||||
<hr />
|
||||
<p>{@html bio}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if profile.links.length > 0}
|
||||
<div class="col-md d-flex align-items-center">
|
||||
<ul class="list-unstyled">
|
||||
{#each profile.links as link}
|
||||
<ProfileLink {link} />
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if profile.flags && !profile.bio}
|
||||
<div class="d-flex flex-wrap m-4">
|
||||
{#each profile.flags as flag}
|
||||
<ProfileFlag {flag} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
|
@ -0,0 +1,33 @@
|
|||
<script lang="ts">
|
||||
import { Icon } from "@sveltestrap/sveltestrap";
|
||||
|
||||
type Props = { link: string };
|
||||
let { link }: Props = $props();
|
||||
|
||||
const prettifyLink = (raw: string) => {
|
||||
let out = raw;
|
||||
if (raw.startsWith("https://")) out = raw.substring("https://".length);
|
||||
else if (raw.startsWith("http://")) out = raw.substring("http://".length);
|
||||
|
||||
if (raw.endsWith("/")) out = raw.substring(0, raw.length - 1);
|
||||
|
||||
return out;
|
||||
};
|
||||
|
||||
let isLink = $derived(link.startsWith("http://") || link.startsWith("https://"));
|
||||
let displayLink = $derived(prettifyLink(link));
|
||||
</script>
|
||||
|
||||
{#if isLink}
|
||||
<a href={link} class="text-decoration-none" rel="me nofollow noreferrer" target="_blank">
|
||||
<li class="py-2 py-lg-0">
|
||||
<Icon name="globe" aria-hidden class="text-body" />
|
||||
<span class="text-decoration-underline">{displayLink}</span>
|
||||
</li>
|
||||
</a>
|
||||
{:else}
|
||||
<li class="py-2 py-lg-0">
|
||||
<Icon name="globe" aria-hidden />
|
||||
<span>{displayLink}</span>
|
||||
</li>
|
||||
{/if}
|
|
@ -0,0 +1,30 @@
|
|||
<script lang="ts">
|
||||
import type { CustomPreference, FieldEntry, Pronoun } from "$api/models";
|
||||
import ProfileFieldEntry from "./ProfileFieldEntry.svelte";
|
||||
import PronounLink from "./PronounLink.svelte";
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
entries: Array<FieldEntry | Pronoun>;
|
||||
allPreferences: Record<string, CustomPreference>;
|
||||
isCol?: boolean;
|
||||
};
|
||||
let { name, entries, allPreferences, isCol }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class:col={isCol === false}>
|
||||
<h3>{name}</h3>
|
||||
<ul class="list-unstyled fs-5">
|
||||
{#each entries as entry}
|
||||
<li>
|
||||
<ProfileFieldEntry status={entry.status} {allPreferences}>
|
||||
{#if "display_text" in entry}
|
||||
<PronounLink pronouns={entry} />
|
||||
{:else}
|
||||
{entry.value}
|
||||
{/if}
|
||||
</ProfileFieldEntry>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
|
@ -0,0 +1,28 @@
|
|||
<script lang="ts">
|
||||
import { defaultPreferences, PreferenceSize, type CustomPreference } from "$api/models";
|
||||
import StatusIcon from "$components/StatusIcon.svelte";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
type Props = {
|
||||
status: string;
|
||||
allPreferences: Record<string, CustomPreference>;
|
||||
children: Snippet;
|
||||
};
|
||||
let { status, allPreferences, children }: Props = $props();
|
||||
|
||||
let preference = $derived(
|
||||
status in allPreferences ? allPreferences[status] : defaultPreferences.missing,
|
||||
);
|
||||
|
||||
let elemType = $derived(preference.size === PreferenceSize.Large ? "strong" : "span");
|
||||
</script>
|
||||
|
||||
<svelte:element
|
||||
this={elemType}
|
||||
class:text-muted={preference.muted}
|
||||
class:fs-5={preference.size === PreferenceSize.Large}
|
||||
class:fs-6={preference.size === PreferenceSize.Small}
|
||||
>
|
||||
<StatusIcon {preference} />
|
||||
{@render children?.()}
|
||||
</svelte:element>
|
|
@ -0,0 +1,41 @@
|
|||
<script lang="ts">
|
||||
import type { Pronoun } from "$api/models/user";
|
||||
|
||||
type Props = { pronouns: Pronoun };
|
||||
let { pronouns }: Props = $props();
|
||||
|
||||
// TODO: this entire component is only made with English pronouns in mind.
|
||||
// It's gonna need a major rework to work with other languages.
|
||||
const updatePronouns = (pronouns: Pronoun) => {
|
||||
if (pronouns.display_text) {
|
||||
return pronouns.display_text;
|
||||
} else {
|
||||
const split = pronouns.value.split("/");
|
||||
if (split.length === 5) return split.splice(0, 2).join("/");
|
||||
return pronouns.value;
|
||||
}
|
||||
};
|
||||
|
||||
const linkPronouns = (pronouns: Pronoun) => {
|
||||
const linkBase = pronouns.value
|
||||
.split("/")
|
||||
.map((snippet) => encodeURIComponent(snippet))
|
||||
.join("/");
|
||||
|
||||
if (pronouns.display_text) {
|
||||
return `${linkBase},${encodeURIComponent(pronouns.display_text)}`;
|
||||
}
|
||||
return linkBase;
|
||||
};
|
||||
|
||||
let pronounText = $derived(updatePronouns(pronouns));
|
||||
|
||||
let link = $derived(linkPronouns(pronouns));
|
||||
let shouldLink = $derived(pronouns.value.split("/").length === 5);
|
||||
</script>
|
||||
|
||||
{#if shouldLink}
|
||||
<a class="text-reset" href="/pronouns/{link}">{pronounText}</a>
|
||||
{:else}
|
||||
{pronounText}
|
||||
{/if}
|
|
@ -0,0 +1,49 @@
|
|||
<script lang="ts">
|
||||
import type { CustomPreference, PartialMember } from "$api/models";
|
||||
import Avatar from "$components/Avatar.svelte";
|
||||
import { t } from "$lib/i18n";
|
||||
|
||||
type Props = {
|
||||
username: string;
|
||||
member: PartialMember;
|
||||
allPreferences: Record<string, CustomPreference>;
|
||||
};
|
||||
let { username, member, allPreferences }: Props = $props();
|
||||
|
||||
const getPronouns = (member: PartialMember) => {
|
||||
const filteredPronouns = member.pronouns.filter(
|
||||
(entry) => (allPreferences[entry.status] || { favourite: false }).favourite,
|
||||
);
|
||||
if (filteredPronouns.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return filteredPronouns
|
||||
.map((pronouns) => {
|
||||
if (pronouns.display_text) {
|
||||
return pronouns.display_text;
|
||||
} else {
|
||||
const split = pronouns.value.split("/");
|
||||
if (split.length === 5) return split.splice(0, 2).join("/");
|
||||
return pronouns.value;
|
||||
}
|
||||
})
|
||||
.join(", ");
|
||||
};
|
||||
|
||||
let pronouns = $derived(getPronouns(member));
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<a href="/@{username}/{member.name}">
|
||||
<Avatar url={member.avatar_url} lazyLoad alt={$t("avatar-tooltip", { name: member.name })} />
|
||||
</a>
|
||||
<p class="m-2">
|
||||
<a class="text-reset fs-5 text-break" href="/@{username}/{member.name}">
|
||||
{member.name}
|
||||
</a>
|
||||
{#if pronouns}
|
||||
<br />
|
||||
{pronouns}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
36
Foxnouns.Frontend/src/lib/errorCodes.svelte.ts
Normal file
36
Foxnouns.Frontend/src/lib/errorCodes.svelte.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import { ErrorCode } from "$api/error";
|
||||
import type { Modifier } from "sveltekit-i18n";
|
||||
|
||||
type TranslateFn = (key: string, payload?: any, props?: Modifier.Props<{}> | undefined) => any;
|
||||
|
||||
export default function errorDescription(t: TranslateFn, code: ErrorCode): string {
|
||||
switch (code) {
|
||||
case ErrorCode.InternalServerError:
|
||||
return t("error.internal-server-error");
|
||||
case ErrorCode.Forbidden:
|
||||
return t("error.forbidden");
|
||||
case ErrorCode.BadRequest:
|
||||
return t("error.bad-request");
|
||||
case ErrorCode.AuthenticationError:
|
||||
return t("error.authentication-error");
|
||||
case ErrorCode.AuthenticationRequired:
|
||||
return t("error.authentication-required");
|
||||
case ErrorCode.MissingScopes:
|
||||
// This error should never be returned by site tokens, so ask the user if they messed with their cookies
|
||||
return t("error.missing-scopes");
|
||||
case ErrorCode.GenericApiError:
|
||||
return t("error.generic-error");
|
||||
case ErrorCode.UserNotFound:
|
||||
return t("error.user-not-found");
|
||||
case ErrorCode.MemberNotFound:
|
||||
return t("error.member-not-found");
|
||||
case ErrorCode.AccountAlreadyLinked:
|
||||
return t("error.account-already-linked");
|
||||
case ErrorCode.LastAuthMethod:
|
||||
return t("error.last-auth-method");
|
||||
case ErrorCode.Non204Response:
|
||||
return t("error.generic-error");
|
||||
}
|
||||
|
||||
return t("error.generic-error");
|
||||
}
|
24
Foxnouns.Frontend/src/lib/i18n/index.ts
Normal file
24
Foxnouns.Frontend/src/lib/i18n/index.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { PUBLIC_LANGUAGE } from "$env/static/public";
|
||||
import i18n, { type Config } from "sveltekit-i18n";
|
||||
|
||||
const config: Config<any> = {
|
||||
initLocale: PUBLIC_LANGUAGE,
|
||||
fallbackLocale: "en",
|
||||
loaders: [
|
||||
{
|
||||
locale: "en",
|
||||
key: "",
|
||||
loader: async () => (await import("./locales/en.json")).default,
|
||||
},
|
||||
{
|
||||
locale: "en-PR",
|
||||
key: "",
|
||||
loader: async () => (await import("./locales/en-PR.json")).default,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const { t, locales, locale, translations, loadTranslations, setLocale } = new i18n(config);
|
||||
|
||||
loadTranslations(PUBLIC_LANGUAGE);
|
||||
setLocale(PUBLIC_LANGUAGE);
|
28
Foxnouns.Frontend/src/lib/i18n/locales/en-PR.json
Normal file
28
Foxnouns.Frontend/src/lib/i18n/locales/en-PR.json
Normal file
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"hello": "Ahoy, {{name}}!",
|
||||
"nav": {
|
||||
"log-in": "Report for duty",
|
||||
"settings": "Pref'rences"
|
||||
},
|
||||
"avatar-tooltip": "Mugshot for {{name}}",
|
||||
"profile": {
|
||||
"edit-member-profile-notice": "You be viewin' the public persona of {memberName}.",
|
||||
"edit-user-profile-notice": "You be viewin' yer public persona.",
|
||||
"edit-profile-link": "Edit persona",
|
||||
"names-header": "Names",
|
||||
"pronouns-header": "Pronouns",
|
||||
"default-members-header": "Members",
|
||||
"create-member-button": "Create member"
|
||||
},
|
||||
"title": {
|
||||
"log-in": "Report for duty",
|
||||
"welcome": "Ahoy"
|
||||
},
|
||||
"auth": {
|
||||
"log-in-form-title": "Use a message in a bottle",
|
||||
"log-in-form-email-label": "Address",
|
||||
"log-in-form-password-label": "Secret phrase",
|
||||
"register-with-email-button": "Sign up",
|
||||
"log-in-button": "Report for duty"
|
||||
}
|
||||
}
|
91
Foxnouns.Frontend/src/lib/i18n/locales/en.json
Normal file
91
Foxnouns.Frontend/src/lib/i18n/locales/en.json
Normal 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"
|
||||
}
|
16
Foxnouns.Frontend/src/lib/index.ts
Normal file
16
Foxnouns.Frontend/src/lib/index.ts
Normal 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);
|
4
Foxnouns.Frontend/src/lib/log.ts
Normal file
4
Foxnouns.Frontend/src/lib/log.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
import { Logger } from "tslog";
|
||||
|
||||
const log = new Logger();
|
||||
export default log;
|
|
@ -13,10 +13,6 @@ const unsafeMd = new MarkdownIt({
|
|||
linkify: true,
|
||||
});
|
||||
|
||||
export function renderMarkdown(src: string | null) {
|
||||
return src ? sanitize(md.render(src)) : null;
|
||||
}
|
||||
export const renderMarkdown = (src: string | null) => (src ? sanitize(md.render(src)) : null);
|
||||
|
||||
export function renderUnsafeMarkdown(src: string) {
|
||||
return sanitize(unsafeMd.render(src));
|
||||
}
|
||||
export const renderUnsafeMarkdown = (src: string) => sanitize(unsafeMd.render(src));
|
21
Foxnouns.Frontend/src/routes/+layout.server.ts
Normal file
21
Foxnouns.Frontend/src/routes/+layout.server.ts
Normal 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;
|
13
Foxnouns.Frontend/src/routes/+layout.svelte
Normal file
13
Foxnouns.Frontend/src/routes/+layout.svelte
Normal 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?.()}
|
21
Foxnouns.Frontend/src/routes/+page.svelte
Normal file
21
Foxnouns.Frontend/src/routes/+page.svelte
Normal 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>
|
20
Foxnouns.Frontend/src/routes/@[username]/+page.server.ts
Normal file
20
Foxnouns.Frontend/src/routes/@[username]/+page.server.ts
Normal 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 };
|
||||
};
|
60
Foxnouns.Frontend/src/routes/@[username]/+page.svelte
Normal file
60
Foxnouns.Frontend/src/routes/@[username]/+page.svelte
Normal 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
Loading…
Reference in a new issue