switch to another frontend framework wheeeeeeeeeeee
This commit is contained in:
parent
fa3c1ccaa7
commit
c4adf6918c
58 changed files with 6246 additions and 1703 deletions
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;
|
||||
};
|
11
Foxnouns.Frontend/app/lib/api/meta.ts
Normal file
11
Foxnouns.Frontend/app/lib/api/meta.ts
Normal 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;
|
||||
}
|
13
Foxnouns.Frontend/app/lib/api/user.ts
Normal file
13
Foxnouns.Frontend/app/lib/api/user.ts
Normal 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;
|
||||
};
|
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,
|
||||
});
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue