switch to another frontend framework wheeeeeeeeeeee
This commit is contained in:
parent
fa3c1ccaa7
commit
c4adf6918c
58 changed files with 6246 additions and 1703 deletions
|
@ -1,4 +0,0 @@
|
|||
# The API base that the server itself should call, this should not be behind a reverse proxy.
|
||||
PRIVATE_API_BASE=http://localhost:5000/api
|
||||
# The API base that clients should call, behind a reverse proxy.
|
||||
PUBLIC_API_BASE=https://pronouns.cc/api
|
84
Foxnouns.Frontend/.eslintrc.cjs
Normal file
84
Foxnouns.Frontend/.eslintrc.cjs
Normal file
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* 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,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
9
Foxnouns.Frontend/.gitignore
vendored
9
Foxnouns.Frontend/.gitignore
vendored
|
@ -1,10 +1,5 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
|
||||
/.cache
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
engine-strict=true
|
|
@ -1,4 +0,0 @@
|
|||
# Ignore files for PNPM, NPM and YARN
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
|
@ -1,6 +1,3 @@
|
|||
{
|
||||
"useTabs": true,
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
"useTabs": true
|
||||
}
|
||||
|
|
|
@ -1,38 +1,40 @@
|
|||
# create-svelte
|
||||
# Welcome to Remix!
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/main/packages/create-svelte).
|
||||
- 📖 [Remix docs](https://remix.run/docs)
|
||||
|
||||
## Creating a project
|
||||
## Development
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
Run the dev server:
|
||||
|
||||
```bash
|
||||
# create a new project in the current directory
|
||||
npm create svelte@latest
|
||||
|
||||
# create a new project in my-app
|
||||
npm create svelte@latest my-app
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```bash
|
||||
```shellscript
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
## Deployment
|
||||
|
||||
To create a production version of your app:
|
||||
First, build your app for production:
|
||||
|
||||
```bash
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
Then run the app in production mode:
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.
|
||||
```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.
|
||||
|
|
21
Foxnouns.Frontend/app/app.scss
Normal file
21
Foxnouns.Frontend/app/app.scss
Normal file
|
@ -0,0 +1,21 @@
|
|||
$font-family-sans-serif:
|
||||
"FiraGO",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
"Noto Sans",
|
||||
"Liberation Sans",
|
||||
Arial,
|
||||
sans-serif,
|
||||
"Apple Color Emoji",
|
||||
"Segoe UI Emoji",
|
||||
"Segoe UI Symbol",
|
||||
"Noto Color Emoji" !default;
|
||||
|
||||
@import "bootstrap/scss/bootstrap";
|
||||
|
||||
@import "@fontsource/firago/400.css";
|
||||
@import "@fontsource/firago/400-italic.css";
|
||||
@import "@fontsource/firago/700.css";
|
41
Foxnouns.Frontend/app/components/nav/Logo.tsx
Normal file
41
Foxnouns.Frontend/app/components/nav/Logo.tsx
Normal file
File diff suppressed because one or more lines are too long
87
Foxnouns.Frontend/app/components/nav/Navbar.tsx
Normal file
87
Foxnouns.Frontend/app/components/nav/Navbar.tsx
Normal file
|
@ -0,0 +1,87 @@
|
|||
import { Link, useFetcher } from "@remix-run/react";
|
||||
import Meta from "~/lib/api/meta";
|
||||
import { User, UserSettings } from "~/lib/api/user";
|
||||
import Logo from "./Logo";
|
||||
|
||||
import Nav from "react-bootstrap/Nav";
|
||||
import Navbar from "react-bootstrap/Navbar";
|
||||
import NavDropdown from "react-bootstrap/NavDropdown";
|
||||
import {
|
||||
BrightnessHigh,
|
||||
BrightnessHighFill,
|
||||
MoonFill,
|
||||
} from "react-bootstrap-icons";
|
||||
|
||||
export default function MainNavbar({
|
||||
user,
|
||||
settings,
|
||||
}: {
|
||||
meta: Meta;
|
||||
user?: User;
|
||||
settings: UserSettings;
|
||||
}) {
|
||||
const fetcher = useFetcher();
|
||||
|
||||
const userMenu = user ? (
|
||||
<NavDropdown title={<>@{user.username}</>} align="end">
|
||||
<NavDropdown.Item as={Link} to={`/@${user.username}`}>
|
||||
View profile
|
||||
</NavDropdown.Item>
|
||||
<NavDropdown.Item as={Link} to="/settings">
|
||||
Settings
|
||||
</NavDropdown.Item>
|
||||
<NavDropdown.Divider />
|
||||
<NavDropdown.Item as={Link} to="/auth/logout">
|
||||
Log out
|
||||
</NavDropdown.Item>
|
||||
</NavDropdown>
|
||||
) : (
|
||||
<Nav.Link to="/auth/login" as={Link}>
|
||||
Log in or sign up
|
||||
</Nav.Link>
|
||||
);
|
||||
|
||||
const ThemeIcon =
|
||||
settings.dark_mode === null
|
||||
? BrightnessHigh
|
||||
: settings.dark_mode
|
||||
? MoonFill
|
||||
: BrightnessHighFill;
|
||||
|
||||
return (
|
||||
<Navbar expand="lg" className="mb-4 mx-2">
|
||||
<Navbar.Brand to="/" as={Link}>
|
||||
<Logo />
|
||||
</Navbar.Brand>
|
||||
<Navbar.Toggle aria-controls="main-navbar" />
|
||||
<Navbar.Collapse id="main-navbar">
|
||||
<Nav className="ms-auto">{userMenu}</Nav>
|
||||
</Navbar.Collapse>
|
||||
<fetcher.Form method="POST" action="/dark-mode">
|
||||
<NavDropdown
|
||||
title={
|
||||
<>
|
||||
<ThemeIcon /> Theme
|
||||
</>
|
||||
}
|
||||
align="end"
|
||||
>
|
||||
<NavDropdown.Item as="button" name="theme" value="auto" type="submit">
|
||||
Automatic
|
||||
</NavDropdown.Item>
|
||||
<NavDropdown.Item as="button" name="theme" value="dark" type="submit">
|
||||
Dark mode
|
||||
</NavDropdown.Item>
|
||||
<NavDropdown.Item
|
||||
as="button"
|
||||
name="theme"
|
||||
value="light"
|
||||
type="submit"
|
||||
>
|
||||
Light mode
|
||||
</NavDropdown.Item>
|
||||
</NavDropdown>
|
||||
</fetcher.Form>
|
||||
</Navbar>
|
||||
);
|
||||
}
|
18
Foxnouns.Frontend/app/entry.client.tsx
Normal file
18
Foxnouns.Frontend/app/entry.client.tsx
Normal file
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* By default, Remix will handle hydrating your app on the client for you.
|
||||
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
|
||||
* For more information, see https://remix.run/file-conventions/entry.client
|
||||
*/
|
||||
|
||||
import { RemixBrowser } from "@remix-run/react";
|
||||
import { startTransition, StrictMode } from "react";
|
||||
import { hydrateRoot } from "react-dom/client";
|
||||
|
||||
startTransition(() => {
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
<RemixBrowser />
|
||||
</StrictMode>,
|
||||
);
|
||||
});
|
140
Foxnouns.Frontend/app/entry.server.tsx
Normal file
140
Foxnouns.Frontend/app/entry.server.tsx
Normal file
|
@ -0,0 +1,140 @@
|
|||
/**
|
||||
* By default, Remix will handle generating the HTTP Response for you.
|
||||
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
|
||||
* For more information, see https://remix.run/file-conventions/entry.server
|
||||
*/
|
||||
|
||||
import { PassThrough } from "node:stream";
|
||||
|
||||
import type { AppLoadContext, EntryContext } from "@remix-run/node";
|
||||
import { createReadableStreamFromReadable } from "@remix-run/node";
|
||||
import { RemixServer } from "@remix-run/react";
|
||||
import { isbot } from "isbot";
|
||||
import { renderToPipeableStream } from "react-dom/server";
|
||||
|
||||
const ABORT_DELAY = 5_000;
|
||||
|
||||
export default function handleRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext,
|
||||
// This is ignored so we can keep it in the template for visibility. Feel
|
||||
// free to delete this parameter in your app if you're not using it!
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
loadContext: AppLoadContext,
|
||||
) {
|
||||
return isbot(request.headers.get("user-agent") || "")
|
||||
? handleBotRequest(
|
||||
request,
|
||||
responseStatusCode,
|
||||
responseHeaders,
|
||||
remixContext,
|
||||
)
|
||||
: handleBrowserRequest(
|
||||
request,
|
||||
responseStatusCode,
|
||||
responseHeaders,
|
||||
remixContext,
|
||||
);
|
||||
}
|
||||
|
||||
function handleBotRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext,
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false;
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<RemixServer
|
||||
context={remixContext}
|
||||
url={request.url}
|
||||
abortDelay={ABORT_DELAY}
|
||||
/>,
|
||||
{
|
||||
onAllReady() {
|
||||
shellRendered = true;
|
||||
const body = new PassThrough();
|
||||
const stream = createReadableStreamFromReadable(body);
|
||||
|
||||
responseHeaders.set("Content-Type", "text/html");
|
||||
|
||||
resolve(
|
||||
new Response(stream, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
}),
|
||||
);
|
||||
|
||||
pipe(body);
|
||||
},
|
||||
onShellError(error: unknown) {
|
||||
reject(error);
|
||||
},
|
||||
onError(error: unknown) {
|
||||
responseStatusCode = 500;
|
||||
// Log streaming rendering errors from inside the shell. Don't log
|
||||
// errors encountered during initial shell rendering since they'll
|
||||
// reject and get logged in handleDocumentRequest.
|
||||
if (shellRendered) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
setTimeout(abort, ABORT_DELAY);
|
||||
});
|
||||
}
|
||||
|
||||
function handleBrowserRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext,
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false;
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<RemixServer
|
||||
context={remixContext}
|
||||
url={request.url}
|
||||
abortDelay={ABORT_DELAY}
|
||||
/>,
|
||||
{
|
||||
onShellReady() {
|
||||
shellRendered = true;
|
||||
const body = new PassThrough();
|
||||
const stream = createReadableStreamFromReadable(body);
|
||||
|
||||
responseHeaders.set("Content-Type", "text/html");
|
||||
|
||||
resolve(
|
||||
new Response(stream, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
}),
|
||||
);
|
||||
|
||||
pipe(body);
|
||||
},
|
||||
onShellError(error: unknown) {
|
||||
reject(error);
|
||||
},
|
||||
onError(error: unknown) {
|
||||
responseStatusCode = 500;
|
||||
// Log streaming rendering errors from inside the shell. Don't log
|
||||
// errors encountered during initial shell rendering since they'll
|
||||
// reject and get logged in handleDocumentRequest.
|
||||
if (shellRendered) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
setTimeout(abort, ABORT_DELAY);
|
||||
});
|
||||
}
|
3
Foxnouns.Frontend/app/env.server.ts
Normal file
3
Foxnouns.Frontend/app/env.server.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import { env } from "node:process";
|
||||
|
||||
export const API_BASE = env.API_BASE || "https://pronouns.localhost/api";
|
28
Foxnouns.Frontend/app/lib/api/error.ts
Normal file
28
Foxnouns.Frontend/app/lib/api/error.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
export type ApiError = {
|
||||
status: number;
|
||||
message: string;
|
||||
code: ErrorCode;
|
||||
errors?: ValidationError[];
|
||||
};
|
||||
|
||||
export enum ErrorCode {
|
||||
InternalServerError = "INTERNAL_SERVER_ERROR",
|
||||
Forbidden = "FORBIDDEN",
|
||||
BadRequest = "BAD_REQUEST",
|
||||
AuthenticationError = "AUTHENTICATION_ERROR",
|
||||
AuthenticationRequired = "AUTHENTICATION_REQUIRED",
|
||||
MissingScopes = "MISSING_SCOPES",
|
||||
GenericApiError = "GENERIC_API_ERROR",
|
||||
UserNotFound = "USER_NOT_FOUND",
|
||||
MemberNotFound = "MEMBER_NOT_FOUND",
|
||||
}
|
||||
|
||||
export type ValidationError = {
|
||||
message: string;
|
||||
min_length?: number;
|
||||
max_length?: number;
|
||||
actual_length?: number;
|
||||
allowed_values?: any[];
|
||||
actual_value?: any;
|
||||
};
|
|
@ -7,3 +7,7 @@ export type User = {
|
|||
avatar_url: string | null;
|
||||
links: string[];
|
||||
};
|
||||
|
||||
export type UserSettings = {
|
||||
dark_mode: boolean | null;
|
||||
};
|
66
Foxnouns.Frontend/app/lib/request.server.ts
Normal file
66
Foxnouns.Frontend/app/lib/request.server.ts
Normal file
|
@ -0,0 +1,66 @@
|
|||
import { parse as parseCookie, serialize as serializeCookie } from "cookie";
|
||||
import { API_BASE } from "~/env.server";
|
||||
import { ApiError, ErrorCode } from "./api/error";
|
||||
|
||||
export type RequestParams = {
|
||||
token?: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
body?: any;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
export default async function serverRequest<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
params: RequestParams = {},
|
||||
) {
|
||||
const url = `${API_BASE}/v2${path}`;
|
||||
const resp = await fetch(url, {
|
||||
method,
|
||||
body: params.body ? JSON.stringify(params.body) : undefined,
|
||||
headers: {
|
||||
...params.headers,
|
||||
...(params.token ? { Authorization: params.token } : {}),
|
||||
"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 (await resp.json()) as T;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
23
Foxnouns.Frontend/app/lib/settings.server.ts
Normal file
23
Foxnouns.Frontend/app/lib/settings.server.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
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;
|
||||
}
|
85
Foxnouns.Frontend/app/root.tsx
Normal file
85
Foxnouns.Frontend/app/root.tsx
Normal file
|
@ -0,0 +1,85 @@
|
|||
import {
|
||||
json,
|
||||
Links,
|
||||
Meta as MetaComponent,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
useLoaderData,
|
||||
} from "@remix-run/react";
|
||||
import { LoaderFunction } from "@remix-run/node";
|
||||
import SSRProvider from "react-bootstrap/SSRProvider";
|
||||
|
||||
import serverRequest, { getCookie, 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";
|
||||
|
||||
export const loader: LoaderFunction = async ({ request }) => {
|
||||
const meta = await serverRequest<Meta>("GET", "/meta");
|
||||
|
||||
const token = getCookie(request, "pronounscc-token");
|
||||
let setCookie = "";
|
||||
|
||||
let meUser: User | undefined;
|
||||
let settings = getLocalSettings(request);
|
||||
if (token) {
|
||||
try {
|
||||
const user = await serverRequest<User>("GET", "/users/@me", { token });
|
||||
meUser = user;
|
||||
|
||||
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("pronounscc-token", token, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return json(
|
||||
{ meta, meUser, settings },
|
||||
{
|
||||
headers: { "Set-Cookie": setCookie },
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
const { settings } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<html lang="en" data-bs-theme={settings.dark_mode ? "dark" : "light"}>
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<MetaComponent />
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
<SSRProvider>{children}</SSRProvider>
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { meta, meUser, settings } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar meta={meta} user={meUser} settings={settings} />
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
}
|
30
Foxnouns.Frontend/app/routes/$username/route.tsx
Normal file
30
Foxnouns.Frontend/app/routes/$username/route.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { json, LoaderFunction, MetaFunction } from "@remix-run/node";
|
||||
import { redirect, useLoaderData } from "@remix-run/react";
|
||||
import { User } from "~/lib/api/user";
|
||||
import serverRequest from "~/lib/request.server";
|
||||
|
||||
export const meta: MetaFunction<typeof loader> = ({ data }) => {
|
||||
const { user } = data!;
|
||||
|
||||
return [{ title: `@${user.username} - pronouns.cc` }];
|
||||
};
|
||||
|
||||
export const loader: LoaderFunction = async ({ params }) => {
|
||||
let username = params.username!;
|
||||
if (!username.startsWith("@")) throw redirect(`/@${username}`);
|
||||
username = username.substring("@".length);
|
||||
|
||||
const user = await serverRequest<User>("GET", `/users/${username}`);
|
||||
|
||||
return json({ user });
|
||||
};
|
||||
|
||||
export default function UserPage() {
|
||||
const { user } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<>
|
||||
hello! this is the user page for @{user.username}. their ID is {user.id}
|
||||
</>
|
||||
);
|
||||
}
|
45
Foxnouns.Frontend/app/routes/_index.tsx
Normal file
45
Foxnouns.Frontend/app/routes/_index.tsx
Normal file
|
@ -0,0 +1,45 @@
|
|||
import type { MetaFunction } from "@remix-run/node";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [{ title: "pronouns.cc" }];
|
||||
};
|
||||
|
||||
export default function Index() {
|
||||
return (
|
||||
<div className="font-sans p-4">
|
||||
<h1 className="text-3xl">Welcome to Remix</h1>
|
||||
<ul className="list-disc mt-4 pl-6 space-y-2">
|
||||
<li>
|
||||
<a
|
||||
className="text-blue-700 underline visited:text-purple-900"
|
||||
target="_blank"
|
||||
href="https://remix.run/start/quickstart"
|
||||
rel="noreferrer"
|
||||
>
|
||||
5m Quick Start
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
className="text-blue-700 underline visited:text-purple-900"
|
||||
target="_blank"
|
||||
href="https://remix.run/start/tutorial"
|
||||
rel="noreferrer"
|
||||
>
|
||||
30m Tutorial
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
className="text-blue-700 underline visited:text-purple-900"
|
||||
target="_blank"
|
||||
href="https://remix.run/docs"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Remix Docs
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
31
Foxnouns.Frontend/app/routes/dark-mode/route.tsx
Normal file
31
Foxnouns.Frontend/app/routes/dark-mode/route.tsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
import { ActionFunction } from "@remix-run/node";
|
||||
import { UserSettings } from "~/lib/api/user";
|
||||
import serverRequest, { getCookie, writeCookie } from "~/lib/request.server";
|
||||
|
||||
// Handles theme switching
|
||||
// Remix itself handles redirecting back to the original page after the setting is set
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const body = await request.formData();
|
||||
const theme = (body.get("theme") as string | null) || "auto";
|
||||
|
||||
const token = getCookie(request, "pronounscc-token");
|
||||
if (token) {
|
||||
await serverRequest<UserSettings>("PATCH", "/users/@me/settings", {
|
||||
token,
|
||||
body: {
|
||||
dark_mode: theme === "auto" ? null : theme === "dark" ? true : false,
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Set-Cookie": writeCookie("pronounscc-theme", theme),
|
||||
},
|
||||
status: 204,
|
||||
});
|
||||
};
|
|
@ -1,33 +0,0 @@
|
|||
import js from '@eslint/js';
|
||||
import ts from 'typescript-eslint';
|
||||
import svelte from 'eslint-plugin-svelte';
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import globals from 'globals';
|
||||
|
||||
/** @type {import('eslint').Linter.FlatConfig[]} */
|
||||
export default [
|
||||
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,40 +1,58 @@
|
|||
{
|
||||
"name": "foxnouns.frontend",
|
||||
"version": "0.0.1",
|
||||
"name": "foxnouns-fe",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write ."
|
||||
"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 ."
|
||||
},
|
||||
"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",
|
||||
"classnames": "^2.5.1",
|
||||
"compression": "^1.7.4",
|
||||
"cookie": "^0.6.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"express": "^4.19.2",
|
||||
"isbot": "^4.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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fontsource/firago": "^5.0.11",
|
||||
"@sveltejs/adapter-node": "^5.0.1",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
||||
"@sveltestrap/sveltestrap": "^6.2.7",
|
||||
"@tabler/icons-svelte": "^3.5.0",
|
||||
"@types/eslint": "^8.56.7",
|
||||
"bootstrap-icons": "^1.11.3",
|
||||
"bulma": "^1.0.1",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.36.0",
|
||||
"globals": "^15.0.0",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
"sass": "^1.77.4",
|
||||
"svelte": "^4.2.7",
|
||||
"svelte-check": "^3.6.0",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.0.0-alpha.20",
|
||||
"vite": "^5.0.3"
|
||||
"@remix-run/dev": "^2.11.2",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cookie": "^0.6.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/react": "^18.2.20",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@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",
|
||||
"prettier": "^3.3.3",
|
||||
"sass": "^1.78.0",
|
||||
"typescript": "^5.1.6",
|
||||
"vite": "^5.1.0",
|
||||
"vite-tsconfig-paths": "^4.2.1"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {}
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
|
|
BIN
Foxnouns.Frontend/public/favicon.ico
Normal file
BIN
Foxnouns.Frontend/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 17 KiB |
52
Foxnouns.Frontend/server.js
Normal file
52
Foxnouns.Frontend/server.js
Normal file
|
@ -0,0 +1,52 @@
|
|||
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" }),
|
||||
);
|
||||
}
|
||||
|
||||
// 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: "1h" }));
|
||||
|
||||
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}`),
|
||||
);
|
16
Foxnouns.Frontend/src/app.d.ts
vendored
16
Foxnouns.Frontend/src/app.d.ts
vendored
|
@ -1,16 +0,0 @@
|
|||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
interface Locals {
|
||||
token?: string;
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
|
@ -1,19 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<script>
|
||||
let theme = localStorage.getItem("pronounscc-theme");
|
||||
if (!theme)
|
||||
theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
|
@ -1,8 +0,0 @@
|
|||
@use "bulma/sass" with (
|
||||
$family-primary: "FiraGO"
|
||||
);
|
||||
|
||||
@import "@fontsource/firago/400.css";
|
||||
@import "@fontsource/firago/400-italic.css";
|
||||
@import "@fontsource/firago/700.css";
|
||||
@import "bootstrap-icons/font/bootstrap-icons.css";
|
|
@ -1,73 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Internal error occurred</title>
|
||||
<style>
|
||||
body {
|
||||
font-size: 1.2em;
|
||||
font-family: sans-serif;
|
||||
margin: 40px auto;
|
||||
max-width: 650px;
|
||||
|
||||
background-color: #ffffff;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
a:link,
|
||||
a:visited {
|
||||
color: #0d6efd;
|
||||
}
|
||||
|
||||
.logo {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.info {
|
||||
color: rgba(33, 37, 41, 0.75);
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #212529;
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
a:link,
|
||||
a:visited {
|
||||
color: #6ea8fe;
|
||||
}
|
||||
|
||||
.info {
|
||||
color: rgba(173, 181, 189, 0.75);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div>
|
||||
<p class="logo">
|
||||
<img src="/logo.svg" alt="pronouns.cc logo" width="50%" />
|
||||
</p>
|
||||
<h1>Internal error occurred</h1>
|
||||
<p>An internal error has occurred. Don't worry, it's (probably) not your fault.</p>
|
||||
<p>
|
||||
If this is the first time this is happening, try reloading the page. Otherwise, check the
|
||||
<a href="https://status.pronouns.cc/" target="_blank">status page</a> for updates.
|
||||
</p>
|
||||
</div>
|
||||
<p class="info">
|
||||
<strong>Status:</strong> %sveltekit.status%<br />
|
||||
<strong>Error message:</strong> %sveltekit.error.message%
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
|
@ -1,15 +0,0 @@
|
|||
import { PRIVATE_API_BASE } from "$env/static/private";
|
||||
import { PUBLIC_API_BASE } from "$env/static/public";
|
||||
|
||||
export async function handle({ event, resolve }) {
|
||||
event.locals.token = event.cookies.get("pronounscc-token");
|
||||
return await resolve(event);
|
||||
}
|
||||
|
||||
export function handleFetch({ event, request, fetch }) {
|
||||
if (request.url.startsWith(PUBLIC_API_BASE))
|
||||
request = new Request(request.url.replace(PUBLIC_API_BASE, PRIVATE_API_BASE), request);
|
||||
if (event.locals.token) request.headers.set("Authorization", event.locals.token);
|
||||
|
||||
return fetch(request);
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
import type { User } from "./user";
|
||||
|
||||
export type CallbackRequest = {
|
||||
code: string;
|
||||
state: string;
|
||||
};
|
||||
|
||||
export type CallbackResponse = {
|
||||
has_account: boolean;
|
||||
ticket: string;
|
||||
remote_username: string | null;
|
||||
};
|
||||
|
||||
export type AuthResponse = {
|
||||
user: User;
|
||||
token: string;
|
||||
expires_at: string;
|
||||
};
|
|
@ -1 +0,0 @@
|
|||
// place files you want to import through the `$lib` alias in this folder.
|
|
@ -1,11 +0,0 @@
|
|||
<script lang="ts">
|
||||
let isOpen = false;
|
||||
export let right = false;
|
||||
</script>
|
||||
|
||||
<div class={"navbar-item has-dropdown" + (isOpen ? " is-active" : "") + (right ? " is-right" : "")}>
|
||||
<button class="navbar-link" on:click={() => (isOpen = !isOpen)}><slot name="label" /></button>
|
||||
<div class="navbar-dropdown">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
|
@ -1,10 +0,0 @@
|
|||
<script lang="ts">
|
||||
export let divider = false;
|
||||
export let href: string | undefined = undefined;
|
||||
</script>
|
||||
|
||||
{#if divider}
|
||||
<hr class="navbar-divider" />
|
||||
{:else}
|
||||
<a {href} class="navbar-item"><slot /></a>
|
||||
{/if}
|
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 7.9 KiB |
|
@ -1,66 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { IconSun, IconMoon } from "@tabler/icons-svelte";
|
||||
import { onMount } from "svelte";
|
||||
import Dropdown from "./Dropdown.svelte";
|
||||
import DropdownItem from "./DropdownItem.svelte";
|
||||
import Logo from "./Logo.svelte";
|
||||
import type { User } from "$lib/api/user";
|
||||
import { themeStore } from "$lib/store";
|
||||
|
||||
export let user: User | undefined;
|
||||
let navIsOpen = false;
|
||||
|
||||
onMount(() => {
|
||||
let theme = localStorage.getItem("pronounscc-theme");
|
||||
if (!theme)
|
||||
theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
themeStore.set(theme);
|
||||
});
|
||||
|
||||
const toggleTheme = () => {
|
||||
themeStore.set($themeStore === "dark" ? "light" : "dark");
|
||||
document.documentElement.setAttribute("data-theme", $themeStore);
|
||||
localStorage.setItem("pronounscc-theme", $themeStore);
|
||||
};
|
||||
</script>
|
||||
|
||||
<nav class="navbar" aria-label="Main navigation">
|
||||
<div class="navbar-brand">
|
||||
<a href="/" class="navbar-item">
|
||||
<Logo />
|
||||
</a>
|
||||
<button
|
||||
class={"navbar-burger" + (navIsOpen ? " is-active" : "")}
|
||||
aria-label="menu"
|
||||
aria-expanded="false"
|
||||
on:click={() => (navIsOpen = !navIsOpen)}
|
||||
>
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class={"navbar-menu" + (navIsOpen ? " is-active" : "")}>
|
||||
<div class="navbar-end">
|
||||
{#if user}
|
||||
<Dropdown>
|
||||
<span slot="label">@{user.username}</span>
|
||||
<DropdownItem href="/@{user.username}">View profile</DropdownItem>
|
||||
<DropdownItem href="/settings">Settings</DropdownItem>
|
||||
<DropdownItem divider />
|
||||
<DropdownItem href="/auth/logout">Log out</DropdownItem>
|
||||
</Dropdown>
|
||||
{:else}
|
||||
<a href="/auth/login" class="navbar-item">Log in or sign up</a>
|
||||
{/if}
|
||||
<button class="navbar-item" on:click={() => toggleTheme()}>
|
||||
{#if $themeStore === "dark"}
|
||||
<IconSun size={20} /> Light theme
|
||||
{:else}
|
||||
<IconMoon size={20} /> Dark theme
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
|
@ -1,72 +0,0 @@
|
|||
import { PUBLIC_API_BASE } from "$env/static/public";
|
||||
|
||||
export type RequestParams = {
|
||||
token?: string;
|
||||
body?: any;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch a path from the API and parse the response.
|
||||
* To make sure the request is authenticated in load functions,
|
||||
* pass `fetch` from the request object into opts.
|
||||
*
|
||||
* @param fetchFn A function like `fetch`, such as from the `load` function
|
||||
* @param method The HTTP method, i.e. GET, POST, PATCH
|
||||
* @param path The path to request, minus the leading `/api/v2`
|
||||
* @param params Extra options for this request
|
||||
* @returns T
|
||||
* @throws APIError
|
||||
*/
|
||||
export default async function request<T>(
|
||||
fetchFn: typeof fetch,
|
||||
method: string,
|
||||
path: string,
|
||||
params: RequestParams = {},
|
||||
) {
|
||||
const url = `${PUBLIC_API_BASE}/v2${path}`;
|
||||
const resp = await fetchFn(url, {
|
||||
method,
|
||||
body: params.body ? JSON.stringify(params.body) : undefined,
|
||||
headers: {
|
||||
...params.headers,
|
||||
...(params.token ? { Authorization: params.token } : {}),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (resp.status < 200 || resp.status >= 400) throw await resp.json();
|
||||
return (await resp.json()) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a path from the API and discard the response.
|
||||
* To make sure the request is authenticated in load functions,
|
||||
* pass `fetch` from the request object into opts.
|
||||
*
|
||||
* @param fetchFn A function like `fetch`, such as from the `load` function
|
||||
* @param method The HTTP method, i.e. GET, POST, PATCH
|
||||
* @param path The path to request, minus the leading `/api/v2`
|
||||
* @param params Extra options for this request
|
||||
* @returns T
|
||||
* @throws APIError
|
||||
*/
|
||||
export async function fastRequest(
|
||||
fetchFn: typeof fetch,
|
||||
method: string,
|
||||
path: string,
|
||||
params: RequestParams = {},
|
||||
): Promise<void> {
|
||||
const url = `${PUBLIC_API_BASE}/v2${path}`;
|
||||
const resp = await fetchFn(url, {
|
||||
method,
|
||||
body: params.body ? JSON.stringify(params.body) : undefined,
|
||||
headers: {
|
||||
...params.headers,
|
||||
...(params.token ? { Authorization: params.token } : {}),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (resp.status < 200 || resp.status >= 400) throw await resp.json();
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
import { writable } from "svelte/store";
|
||||
import { browser } from "$app/environment";
|
||||
|
||||
const defaultThemeValue = "light";
|
||||
const initialThemeValue = browser
|
||||
? window.localStorage.getItem("pronouns-theme") ?? defaultThemeValue
|
||||
: defaultThemeValue;
|
||||
|
||||
export const themeStore = writable<string>(initialThemeValue);
|
|
@ -1,14 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { page } from "$app/stores";
|
||||
import neofox from "./neofox_confused_2048.png";
|
||||
</script>
|
||||
|
||||
{#if $page.status === 404}
|
||||
<div class="has-text-centered">
|
||||
<img src={neofox} alt="A very confused-looking fox" width="25%" />
|
||||
<h1 class="title">Not found</h1>
|
||||
<p>Our foxes can't find the page you're looking for, sorry!</p>
|
||||
</div>
|
||||
{:else}
|
||||
div.has-text-centered
|
||||
{/if}
|
|
@ -1,13 +0,0 @@
|
|||
import type Meta from "$lib/api/meta";
|
||||
import type { User } from "$lib/api/user";
|
||||
import request from "$lib/request";
|
||||
|
||||
export async function load({ fetch, locals }) {
|
||||
const meta = await request<Meta>(fetch, "GET", "/meta");
|
||||
let user: User | undefined;
|
||||
try {
|
||||
user = await request<User>(fetch, "GET", "/users/@me");
|
||||
} catch {}
|
||||
|
||||
return { meta, currentUser: user, token: locals.token };
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { LayoutData } from "./$types";
|
||||
import "../app.scss";
|
||||
import Navbar from "$lib/nav/Navbar.svelte";
|
||||
|
||||
export let data: LayoutData;
|
||||
</script>
|
||||
|
||||
<Navbar user={data.currentUser} />
|
||||
<slot />
|
|
@ -1,24 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { PageData } from "./$types";
|
||||
export let data: PageData;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>pronouns.cc</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1 class="title">Welcome to SvelteKit</h1>
|
||||
<p>Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation</p>
|
||||
|
||||
<p>
|
||||
are you logged in? {data.currentUser !== undefined}
|
||||
{#if data.currentUser}
|
||||
<br />hello, {data.currentUser.username}!
|
||||
<br />your ID: {data.currentUser.id}
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>stats:</strong>
|
||||
{data.meta.users.total} users, {data.meta.members} members
|
||||
</p>
|
|
@ -1,12 +0,0 @@
|
|||
import { error } from "@sveltejs/kit";
|
||||
import type { User } from "$lib/api/user";
|
||||
import request from "$lib/request";
|
||||
|
||||
export const load = async ({ params, fetch }) => {
|
||||
try {
|
||||
const user = await request<User>(fetch, "GET", `/users/${params.username}`);
|
||||
return { user };
|
||||
} catch {
|
||||
error(404, { message: "User not found" });
|
||||
}
|
||||
};
|
|
@ -1,10 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { PageData } from "./$types";
|
||||
export let data: PageData;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>@{data.user.username} • pronouns.cc</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1>this is the user page for @{data.user.username}</h1>
|
|
@ -1,12 +0,0 @@
|
|||
import request from "$lib/request.js";
|
||||
|
||||
type UrlsResponse = {
|
||||
discord: string | null;
|
||||
google: string | null;
|
||||
tumblr: string | null;
|
||||
};
|
||||
|
||||
export const load = async ({ fetch }) => {
|
||||
const urls = await request<UrlsResponse>(fetch, "POST", "/auth/urls");
|
||||
return { urls };
|
||||
};
|
|
@ -1,53 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { PageData } from "./$types";
|
||||
export let data: PageData;
|
||||
$: hasUrls = !!(data.urls.discord || data.urls.google || data.urls.tumblr);
|
||||
</script>
|
||||
|
||||
<div class="container mt-6">
|
||||
<div class="fixed-grid has-1-cols has-2-cols-desktop">
|
||||
<div class="grid">
|
||||
<div class="cell">
|
||||
<p class="title">Log in with email address</p>
|
||||
<form method="POST" action="?/login">
|
||||
<div class="field">
|
||||
<label for="email" class="label">Email address</label>
|
||||
<div class="control">
|
||||
<input type="email" id="email" class="input" placeholder="Email address" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password" class="label">Password</label>
|
||||
<div class="control">
|
||||
<input type="password" id="password" class="input" placeholder="Password" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<button class="button is-primary">Log in</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<a href="/auth/signup" class="button">Sign up</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{#if hasUrls}
|
||||
<div class="cell">
|
||||
<p class="title">Log in with third-party provider</p>
|
||||
<ul>
|
||||
{#if data.urls.discord}
|
||||
<li><a href={data.urls.discord}>Log in with Discord</a></li>
|
||||
{/if}
|
||||
{#if data.urls.google}
|
||||
<li><a href={data.urls.google}>Log in with Google</a></li>
|
||||
{/if}
|
||||
{#if data.urls.tumblr}
|
||||
<li><a href={data.urls.tumblr}>Log in with Tumblr</a></li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -1,51 +0,0 @@
|
|||
import request from "$lib/request";
|
||||
import type { AuthResponse, CallbackResponse } from "$lib/api/auth";
|
||||
|
||||
export const load = async ({ fetch, url, cookies, parent }) => {
|
||||
const data = await parent();
|
||||
if (data.user) {
|
||||
return { loggedIn: true, token: data.token, user: data.user };
|
||||
}
|
||||
|
||||
const resp = await request<AuthResponse | CallbackResponse>(
|
||||
fetch,
|
||||
"POST",
|
||||
"/auth/discord/callback",
|
||||
{
|
||||
body: {
|
||||
code: url.searchParams.get("code"),
|
||||
state: url.searchParams.get("state"),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if ("token" in resp) {
|
||||
const authResp = resp as AuthResponse;
|
||||
cookies.set("pronounscc-token", authResp.token, { path: "/" });
|
||||
return { loggedIn: true, token: authResp.token, user: authResp.user };
|
||||
}
|
||||
|
||||
const callbackResp = resp as CallbackResponse;
|
||||
return {
|
||||
loggedIn: false,
|
||||
hasAccount: callbackResp.has_account,
|
||||
ticket: resp.ticket,
|
||||
remoteUsername: resp.remote_username,
|
||||
};
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
register: async ({ cookies, request: req, fetch, locals }) => {
|
||||
const data = await req.formData();
|
||||
const username = data.get("username");
|
||||
const ticket = data.get("ticket");
|
||||
|
||||
const resp = await request<AuthResponse>(fetch, "POST", "/auth/discord/register", {
|
||||
body: { username, ticket },
|
||||
});
|
||||
cookies.set("pronounscc-token", resp.token, { path: "/" });
|
||||
locals.token = resp.token;
|
||||
|
||||
return { token: resp.token, user: resp.user };
|
||||
},
|
||||
};
|
|
@ -1,79 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { enhance } from "$app/forms";
|
||||
import type { PageData, ActionData } from "./$types";
|
||||
export let data: PageData;
|
||||
|
||||
export let form: ActionData;
|
||||
|
||||
onMount(async () => {
|
||||
if (data.user) {
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
await goto(`/@${data.user.username}`);
|
||||
}
|
||||
});
|
||||
|
||||
const redirectOnForm = async (action: ActionData) => {
|
||||
if (form?.user) {
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
await goto(`/@${form.user.username}`);
|
||||
}
|
||||
};
|
||||
|
||||
$: redirectOnForm(form);
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
{#if form?.user}
|
||||
<h1 class="title">Successfully created account!</h1>
|
||||
<p>Welcome, <strong>@{form.user.username}</strong>!</p>
|
||||
<p>
|
||||
You should automatically be redirected to your profile in a few seconds. If you're not
|
||||
redirected, please press the link above.
|
||||
</p>
|
||||
{:else if data.loggedIn}
|
||||
<h1 class="title">Successfully logged in!</h1>
|
||||
<p>You are now logged in as <a href="/@{data.user?.username}">@{data.user?.username}</a>.</p>
|
||||
<p>
|
||||
You should automatically be redirected to your profile in a few seconds. If you're not
|
||||
redirected, please press the link above.
|
||||
</p>
|
||||
{:else}
|
||||
<h1 class="title">Finish signing up with a Discord account</h1>
|
||||
<form method="POST" action="?/register" use:enhance>
|
||||
<div class="field">
|
||||
<label for="remote_username" class="label">Discord username</label>
|
||||
<div class="control">
|
||||
<input
|
||||
type="text"
|
||||
name="remote_username"
|
||||
id="remote_username"
|
||||
class="input"
|
||||
value={data.remoteUsername}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="username" class="label">Username</label>
|
||||
<div class="control">
|
||||
<input type="text" name="username" id="username" class="input" placeholder="Username" />
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="ticket"
|
||||
id="ticket"
|
||||
class="hidden"
|
||||
style="display: hidden;"
|
||||
value={data.ticket}
|
||||
/>
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<button class="button is-primary">Sign up</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
Binary file not shown.
Before Width: | Height: | Size: 143 KiB |
Binary file not shown.
Before Width: | Height: | Size: 43 KiB |
|
@ -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 |
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 7.9 KiB |
|
@ -1,5 +0,0 @@
|
|||
User-agent: *
|
||||
Disallow: /@*
|
||||
Disallow: /auth
|
||||
Disallow: /settings
|
||||
Disallow: /edit
|
|
@ -1,24 +0,0 @@
|
|||
import adapter from "@sveltejs/adapter-node";
|
||||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
|
||||
kit: {
|
||||
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
|
||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
|
||||
adapter: adapter(),
|
||||
env: {
|
||||
privatePrefix: "PRIVATE_",
|
||||
},
|
||||
csrf: {
|
||||
checkOrigin: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
|
@ -1,19 +1,32 @@
|
|||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"**/.server/**/*.ts",
|
||||
"**/.server/**/*.tsx",
|
||||
"**/.client/**/*.ts",
|
||||
"**/.client/**/*.tsx"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"types": ["@remix-run/node", "vite/client"],
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"jsx": "react-jsx",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"target": "ES2022",
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["./app/*"]
|
||||
},
|
||||
|
||||
// Vite takes care of building everything, not tsc.
|
||||
"noEmit": true
|
||||
}
|
||||
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
|
||||
// except $lib which is handled by https://kit.svelte.dev/docs/configuration#files
|
||||
//
|
||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||
}
|
||||
|
|
|
@ -1,6 +1,24 @@
|
|||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import { vitePlugin as remix } from "@remix-run/dev";
|
||||
import { defineConfig } from "vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()]
|
||||
plugins: [
|
||||
remix({
|
||||
future: {
|
||||
v3_fetcherPersist: true,
|
||||
v3_relativeSplatPath: true,
|
||||
v3_throwAbortReason: true,
|
||||
},
|
||||
}),
|
||||
tsconfigPaths(),
|
||||
],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:5000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue