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

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

@ -0,0 +1,11 @@
export default interface Meta {
version: string;
hash: string;
users: {
total: number;
active_month: number;
active_week: number;
active_day: number;
};
members: number;
}

View file

@ -0,0 +1,13 @@
export type User = {
id: string;
username: string;
display_name: string | null;
bio: string | null;
member_title: string | null;
avatar_url: string | null;
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,
});
};