Custom URL url redirects and replacements

This commit is contained in:
Kalle
2022-10-02 00:25:13 +03:00
parent 34c8e45684
commit ff857f3369
19 changed files with 100 additions and 40 deletions

View File

@@ -20,7 +20,7 @@ module.exports = {
rules: {
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
"no-constant-condition": ["error", { checkLoops: false }],
"no-console": ["error", { allow: ["warn", "error"] }],
"no-console": ["warn", { allow: ["warn", "error"] }],
"no-warning-comments": ["warn", { terms: ["xxx"] }],
"no-var": 0,
"@typescript-eslint/no-unsafe-return": 0,

View File

@@ -88,9 +88,7 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
<div className="build__date-author-row">
{owner && (
<>
<Link to={userBuildsPage(owner.discordId)}>
{discordFullName(owner)}
</Link>
<Link to={userBuildsPage(owner)}>{discordFullName(owner)}</Link>
<div>•</div>
</>
)}

View File

@@ -90,9 +90,7 @@ export function Footer({
<ul className="layout__footer__patron-list">
{patrons.map((patron) => (
<li key={patron.id}>
<Link to={userPage(patron.discordId)}>
{discordFullName(patron)}
</Link>
<Link to={userPage(patron)}>{discordFullName(patron)}</Link>
</li>
))}
</ul>

View File

@@ -26,7 +26,7 @@ export function UserItem() {
}
>
<div className="layout__user-popover">
<Link to={userPage(user.discordId)}>
<Link to={userPage(user)}>
<Button
className="w-full"
tiny

View File

@@ -42,7 +42,7 @@ export function updateProfile(
| "inGameName"
>
) {
updateProfileStm.run(args);
return updateProfileStm.get(args) as User;
}
const updateByDiscordIdStm = sql.prepare(updateByDiscordIdSql);

View File

@@ -8,4 +8,4 @@ set
"motionSens" = @motionSens,
"inGameName" = @inGameName
where
"id" = @id
"id" = @id returning *

View File

@@ -64,7 +64,7 @@ export interface RootLoaderData {
patrons: FindAllPatrons;
user?: Pick<
UserWithPlusTier,
"id" | "discordId" | "discordAvatar" | "plusTier"
"id" | "discordId" | "discordAvatar" | "plusTier" | "customUrl"
>;
}
@@ -84,6 +84,7 @@ export const loader: LoaderFunction = async ({ request }) => {
discordId: user.discordId,
id: user.id,
plusTier: user.plusTier,
customUrl: user.customUrl,
}
: undefined,
},

View File

@@ -46,7 +46,7 @@ export default function BuildsLayoutPage() {
)}
</nav>
{user && (
<LinkButton to={userNewBuildPage(user.discordId)} tiny>
<LinkButton to={userNewBuildPage(user)} tiny>
{t("builds:addBuild")}
</LinkButton>
)}

View File

@@ -203,7 +203,7 @@ function Results() {
player
) : (
<Link
to={userPage(player.discordId)}
to={userPage(player)}
className="stack horizontal xs items-center"
>
<Avatar user={player} size="xxs" />{" "}

View File

@@ -176,7 +176,7 @@ function CalendarPeek() {
player
) : (
<Link
to={userPage(player.discordId)}
to={userPage(player)}
className="stack horizontal xs items-center"
>
{discordFullName(player)}

View File

@@ -368,7 +368,7 @@ function SuggestedUser({
<h2>
<Link
className="all-unset"
to={userPage(suggested.suggestedUser.discordId)}
to={userPage(suggested.suggestedUser)}
data-cy="suggested-user-name"
>
{suggested.suggestedUser.discordName}

View File

@@ -123,7 +123,7 @@ function Results({
</h4>
{tiersResults[status].map((user) => (
<Link
to={userPage(user.discordId)}
to={userPage(user)}
className={clsx("plus-history__user-status", {
failed: status === "failed",
})}

View File

@@ -1,3 +1,4 @@
import * as React from "react";
import type {
LinksFunction,
LoaderArgs,
@@ -5,7 +6,7 @@ import type {
SerializeFrom,
} from "@remix-run/node";
import { json } from "@remix-run/node";
import { Outlet, useLoaderData } from "@remix-run/react";
import { Outlet, useLoaderData, useLocation } from "@remix-run/react";
import { countries } from "countries-list";
import { useTranslation } from "react-i18next";
import { z } from "zod";
@@ -17,6 +18,14 @@ import { translatedCountry } from "~/utils/i18n.server";
import { notFoundIfFalsy } from "~/utils/remix";
import { discordFullName, makeTitle } from "~/utils/strings";
import styles from "~/styles/u.css";
import invariant from "tiny-invariant";
import {
isCustomUrl,
userBuildsPage,
userEditProfilePage,
userPage,
userResultsPage,
} from "~/utils/urls";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
@@ -57,6 +66,10 @@ export const loader = async ({ request, params }: LoaderArgs) => {
twitter: user.twitter,
youtubeId: user.youtubeId,
bio: user.bio,
customUrl: user.customUrl,
motionSens: user.motionSens,
stickSens: user.stickSens,
inGameName: user.inGameName,
country:
countryObj && user.country
? {
@@ -82,24 +95,26 @@ export default function UserPageLayout() {
const isOwnPage = data.id === user?.id;
useReplaceWithCustomUrl();
return (
<>
<SubNav>
<SubNavLink to="" data-cy="profile-page-link">
<SubNavLink to={userPage(data)} data-cy="profile-page-link">
{t("header.profile")}
</SubNavLink>
{isOwnPage && (
<SubNavLink to="edit" data-cy="edit-page-link">
<SubNavLink to={userEditProfilePage(data)} data-cy="edit-page-link">
{t("actions.edit")}
</SubNavLink>
)}
{data.results.length > 0 && (
<SubNavLink to="results" data-cy="results-page-link">
<SubNavLink to={userResultsPage(data)} data-cy="results-page-link">
{t("results")}
</SubNavLink>
)}
{(isOwnPage || data.buildsCount > 0) && (
<SubNavLink to="builds" data-cy="builds-page-link">
<SubNavLink to={userBuildsPage(data)} data-cy="builds-page-link">
{t("pages.builds")} ({data.buildsCount})
</SubNavLink>
)}
@@ -108,3 +123,30 @@ export default function UserPageLayout() {
</>
);
}
function useReplaceWithCustomUrl() {
const data = useLoaderData<typeof loader>();
const location = useLocation();
React.useEffect(() => {
if (!data.customUrl) {
return;
}
const identifier = location.pathname.replace("/u/", "").split("/")[0];
invariant(identifier);
if (isCustomUrl(identifier)) {
return;
}
window.history.replaceState(
null,
"",
location.pathname
.split("/")
.map((part) => (part === identifier ? data.customUrl : part))
.join("/")
);
}, [location, data.customUrl]);
}

View File

@@ -11,6 +11,7 @@ import { db } from "~/db";
import { getUser, requireUser, useUser } from "~/modules/auth";
import { atOrError } from "~/utils/arrays";
import { notFoundIfFalsy, parseRequestFormData } from "~/utils/remix";
import { userNewBuildPage } from "~/utils/urls";
import { actualNumber, id } from "~/utils/zod";
import { type UserPageLoaderData, userParamsSchema } from "../../u.$identifier";
@@ -68,7 +69,11 @@ export default function UserBuildsPage() {
<Main className="stack lg">
{data.builds.length < BUILD.MAX_COUNT && isOwnPage && (
<div className="stack items-end">
<LinkButton to="new" tiny data-cy="new-build-button">
<LinkButton
to={userNewBuildPage(parentPageData)}
tiny
data-cy="new-build-button"
>
{t("addBuild")}
</LinkButton>
</div>

View File

@@ -151,7 +151,7 @@ export const action: ActionFunction = async ({ request }) => {
db.builds.create(commonArgs);
}
return redirect(userBuildsPage(user.discordId));
return redirect(userBuildsPage(user));
};
export const handle = {

View File

@@ -1,4 +1,5 @@
import {
redirect,
type ActionFunction,
type LinksFunction,
type LoaderArgs,
@@ -26,6 +27,7 @@ import { i18next } from "~/modules/i18n";
import styles from "~/styles/u-edit.css";
import { translatedCountry } from "~/utils/i18n.server";
import { parseRequestFormData } from "~/utils/remix";
import { isCustomUrl, userPage } from "~/utils/urls";
import { falsyToNull, undefinedToNull } from "~/utils/zod";
import { type UserPageLoaderData } from "../u.$identifier";
@@ -53,9 +55,8 @@ const userEditActionSchema = z.object({
z
.string()
.max(USER.CUSTOM_URL_MAX_LENGTH)
.nullable()
.refine(
(val) => val === null || Number.isNaN(Number(val)),
(val) => val === null || isCustomUrl(val),
// xxx: translate
"Name in the custom URL can't only contain numbers"
)
@@ -66,6 +67,7 @@ const userEditActionSchema = z.object({
"Custom URL can't contain special characters"
)
.transform((val) => val?.toLowerCase())
.nullable()
),
stickSens: z.preprocess(
undefinedToNull,
@@ -103,7 +105,7 @@ export const action: ActionFunction = async ({ request }) => {
});
const user = await requireUser(request);
db.users.updateProfile({
const editedUser = db.users.updateProfile({
...data,
inGameName:
inGameNameText && inGameNameDiscriminator
@@ -112,7 +114,7 @@ export const action: ActionFunction = async ({ request }) => {
id: user.id,
});
return null;
return redirect(userPage(editedUser));
};
export const loader = async ({ request }: LoaderArgs) => {
@@ -166,7 +168,6 @@ function CustomUrlInput({
}) {
// xxx: same width as textarea?
// xxx: translate
// xxx: url from constant?
return (
<div className="stack items-start">
<Label htmlFor="customUrl">Custom URL</Label>

View File

@@ -58,7 +58,7 @@ export default function UserResultsPage() {
player
) : (
<Link
to={userPage(player.discordId)}
to={userPage(player)}
className="stack horizontal xs items-center"
>
<Avatar user={player} size="xxs" />{" "}

View File

@@ -1,5 +1,5 @@
import slugify from "slugify";
import type { Badge, GearType } from "~/db/types";
import type { Badge, GearType, User } from "~/db/types";
import type { ModeShort, weaponCategories } from "~/modules/in-game-lists";
import type {
Ability,
@@ -36,11 +36,22 @@ export const COMMON_PREVIEW_IMAGE = "/img/layout/common-preview.png";
export const ERROR_GIRL_IMAGE_PATH = `/img/layout/error-girl`;
export const LOGO_PATH = `/img/layout/logo`;
export const userPage = (discordId: string) => `/u/${discordId}`;
export const userBuildsPage = (discordId: string) =>
`${userPage(discordId)}/builds`;
export const userNewBuildPage = (discordId: string) =>
`/u/${discordId}/builds/new`;
interface UserLinkArgs {
discordId: User["discordId"];
customUrl?: User["customUrl"];
}
export const userPage = (user: UserLinkArgs) =>
`/u/${user.customUrl ?? user.discordId}`;
export const userEditProfilePage = (user: UserLinkArgs) =>
`${userPage(user)}/edit`;
export const userBuildsPage = (user: UserLinkArgs) =>
`${userPage(user)}/builds`;
export const userResultsPage = (user: UserLinkArgs) =>
`${userPage(user)}/results`;
export const userNewBuildPage = (user: UserLinkArgs) =>
`${userBuildsPage(user)}/new`;
export const impersonateUrl = (idToLogInAs: number) =>
`/auth/impersonate?id=${idToLogInAs}`;
export const badgePage = (badgeId: number) => `${BADGES_PAGE}/${badgeId}`;
@@ -101,3 +112,7 @@ export const mySlugify = (name: string) => {
strict: true,
});
};
export const isCustomUrl = (value: string) => {
return Number.isNaN(Number(value));
};

View File

@@ -9,7 +9,7 @@ describe("user builds tab", () => {
});
it("views builds of other user", () => {
cy.visit(userBuildsPage(ADMIN_DISCORD_ID));
cy.visit(userBuildsPage({ discordId: ADMIN_DISCORD_ID }));
cy.getCy("build-card").its("length").should("eq", 50);
@@ -18,7 +18,7 @@ describe("user builds tab", () => {
it("operates ability selector", () => {
cy.auth(1);
cy.visit(userBuildsPage(ADMIN_DISCORD_ID));
cy.visit(userBuildsPage({ discordId: ADMIN_DISCORD_ID }));
cy.getCy("new-build-button").click();
cy.getCy("UNKNOWN-ability").its("length").should("eq", 12);
@@ -39,7 +39,7 @@ describe("user builds tab", () => {
it("adds a new build", () => {
cy.auth(1);
cy.visit(userBuildsPage(ADMIN_DISCORD_ID));
cy.visit(userBuildsPage({ discordId: ADMIN_DISCORD_ID }));
cy.getCy("new-build-button").click();
cy.getCy("weapon-combobox-input").type("Luna Blaster{enter}");
@@ -62,7 +62,7 @@ describe("user builds tab", () => {
it("edits build", () => {
cy.auth(1);
cy.visit(userBuildsPage(ADMIN_DISCORD_ID));
cy.visit(userBuildsPage({ discordId: ADMIN_DISCORD_ID }));
cy.getCy("edit-build-button").first().click();
@@ -77,7 +77,7 @@ describe("user builds tab", () => {
it("deletes build", () => {
cy.auth(1);
cy.visit(userBuildsPage(ADMIN_DISCORD_ID));
cy.visit(userBuildsPage({ discordId: ADMIN_DISCORD_ID }));
cy.contains("Builds (50)");
cy.getCy("delete-build-button").first().click();