mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-02 08:07:40 -05:00
Custom URL url redirects and replacements
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -42,7 +42,7 @@ export function updateProfile(
|
||||
| "inGameName"
|
||||
>
|
||||
) {
|
||||
updateProfileStm.run(args);
|
||||
return updateProfileStm.get(args) as User;
|
||||
}
|
||||
|
||||
const updateByDiscordIdStm = sql.prepare(updateByDiscordIdSql);
|
||||
|
||||
@@ -8,4 +8,4 @@ set
|
||||
"motionSens" = @motionSens,
|
||||
"inGameName" = @inGameName
|
||||
where
|
||||
"id" = @id
|
||||
"id" = @id returning *
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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" />{" "}
|
||||
|
||||
@@ -176,7 +176,7 @@ function CalendarPeek() {
|
||||
player
|
||||
) : (
|
||||
<Link
|
||||
to={userPage(player.discordId)}
|
||||
to={userPage(player)}
|
||||
className="stack horizontal xs items-center"
|
||||
>
|
||||
{discordFullName(player)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
})}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />{" "}
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user