switch to another frontend framework wheeeeeeeeeeee

This commit is contained in:
sam 2024-09-05 22:29:12 +02:00
parent fa3c1ccaa7
commit c4adf6918c
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
58 changed files with 6246 additions and 1703 deletions

View file

@ -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

View 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,
},
},
],
};

View file

@ -1,10 +1,5 @@
.DS_Store
node_modules node_modules
/.cache
/build /build
/.svelte-kit
/package
.env .env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

View file

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

View file

@ -1,4 +0,0 @@
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

View file

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

View file

@ -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 ```shellscript
# 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
npm run dev 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 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.

View 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";

File diff suppressed because one or more lines are too long

View 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>
);
}

View 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>,
);
});

View 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);
});
}

View file

@ -0,0 +1,3 @@
import { env } from "node:process";
export const API_BASE = env.API_BASE || "https://pronouns.localhost/api";

View 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;
};

View file

@ -7,3 +7,7 @@ export type User = {
avatar_url: string | null; avatar_url: string | null;
links: string[]; links: string[];
}; };
export type UserSettings = {
dark_mode: boolean | null;
};

View 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,
});

View 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;
}

View 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 />
</>
);
}

View 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}
</>
);
}

View 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>
);
}

View 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,
});
};

View file

@ -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/']
}
];

View file

@ -1,40 +1,58 @@
{ {
"name": "foxnouns.frontend", "name": "foxnouns-fe",
"version": "0.0.1",
"private": true, "private": true,
"sideEffects": false,
"type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "build": "remix vite:build",
"build": "vite build", "dev": "node ./server.js",
"preview": "vite preview", "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "start": "cross-env NODE_ENV=production node ./server.js",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "typecheck": "tsc",
"lint": "prettier --check . && eslint .", "format": "prettier -w ."
"format": "prettier --write ." },
"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": { "devDependencies": {
"@fontsource/firago": "^5.0.11", "@fontsource/firago": "^5.0.11",
"@sveltejs/adapter-node": "^5.0.1", "@remix-run/dev": "^2.11.2",
"@sveltejs/kit": "^2.0.0", "@types/compression": "^1.7.5",
"@sveltejs/vite-plugin-svelte": "^3.0.0", "@types/cookie": "^0.6.0",
"@sveltestrap/sveltestrap": "^6.2.7", "@types/express": "^4.17.21",
"@tabler/icons-svelte": "^3.5.0", "@types/morgan": "^1.9.9",
"@types/eslint": "^8.56.7", "@types/react": "^18.2.20",
"bootstrap-icons": "^1.11.3", "@types/react-dom": "^18.2.7",
"bulma": "^1.0.1", "@typescript-eslint/eslint-plugin": "^6.7.4",
"eslint": "^9.0.0", "@typescript-eslint/parser": "^6.7.4",
"eslint-config-prettier": "^9.1.0", "eslint": "^8.38.0",
"eslint-plugin-svelte": "^2.36.0", "eslint-import-resolver-typescript": "^3.6.1",
"globals": "^15.0.0", "eslint-plugin-import": "^2.28.1",
"prettier": "^3.1.1", "eslint-plugin-jsx-a11y": "^6.7.1",
"prettier-plugin-svelte": "^3.1.2", "eslint-plugin-react": "^7.33.2",
"sass": "^1.77.4", "eslint-plugin-react-hooks": "^4.6.0",
"svelte": "^4.2.7", "prettier": "^3.3.3",
"svelte-check": "^3.6.0", "sass": "^1.78.0",
"tslib": "^2.4.1", "typescript": "^5.1.6",
"typescript": "^5.0.0", "vite": "^5.1.0",
"typescript-eslint": "^8.0.0-alpha.20", "vite-tsconfig-paths": "^4.2.1"
"vite": "^5.0.3"
}, },
"type": "module", "engines": {
"dependencies": {} "node": ">=20.0.0"
}
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View 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}`),
);

View file

@ -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 {};

View file

@ -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>

View file

@ -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";

View file

@ -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>

View file

@ -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);
}

View file

@ -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;
};

View file

@ -1 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.

View file

@ -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>

View file

@ -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

View file

@ -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} />&nbsp;Light theme
{:else}
<IconMoon size={20} />&nbsp;Dark theme
{/if}
</button>
</div>
</div>
</nav>

View file

@ -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();
}

View file

@ -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);

View file

@ -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}

View file

@ -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 };
}

View file

@ -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 />

View file

@ -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>

View file

@ -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" });
}
};

View file

@ -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>

View file

@ -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 };
};

View file

@ -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>

View file

@ -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 };
},
};

View file

@ -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

View file

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

Before

Width:  |  Height:  |  Size: 2.4 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.9 KiB

View file

@ -1,5 +0,0 @@
User-agent: *
Disallow: /@*
Disallow: /auth
Disallow: /settings
Disallow: /edit

View file

@ -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;

View file

@ -1,19 +1,32 @@
{ {
"extends": "./.svelte-kit/tsconfig.json", "include": [
"**/*.ts",
"**/*.tsx",
"**/.server/**/*.ts",
"**/.server/**/*.tsx",
"**/.client/**/*.ts",
"**/.client/**/*.tsx"
],
"compilerOptions": { "compilerOptions": {
"allowJs": true, "lib": ["DOM", "DOM.Iterable", "ES2022"],
"checkJs": true, "types": ["@remix-run/node", "vite/client"],
"isolatedModules": true,
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"skipLibCheck": true, "target": "ES2022",
"sourceMap": true,
"strict": true, "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
} }

View file

@ -1,6 +1,24 @@
import { sveltekit } from '@sveltejs/kit/vite'; import { vitePlugin as remix } from "@remix-run/dev";
import { defineConfig } from 'vite'; import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({ 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