mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-30 07:12:36 -05:00
@@ -7,19 +7,25 @@ export function Input({
|
||||
maxLength,
|
||||
defaultValue,
|
||||
leftAddon,
|
||||
icon,
|
||||
pattern,
|
||||
list,
|
||||
"data-cy": dataCy,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
name: string;
|
||||
name?: string;
|
||||
className?: string;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
defaultValue?: string;
|
||||
leftAddon?: string;
|
||||
icon?: React.ReactNode;
|
||||
pattern?: string;
|
||||
list?: string;
|
||||
"data-cy"?: string;
|
||||
value?: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={clsx("input-container", className)}>
|
||||
@@ -32,7 +38,10 @@ export function Input({
|
||||
pattern={pattern}
|
||||
list={list}
|
||||
data-cy={dataCy}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
{icon}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,12 +45,12 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "articles",
|
||||
"url": "a",
|
||||
"name": "u",
|
||||
"url": "u",
|
||||
"prefetch": true,
|
||||
"filters": [
|
||||
"invert(30%) sepia(73%) saturate(4089%) hue-rotate(326deg) brightness(96%) contrast(104%)",
|
||||
"invert(55%) sepia(20%) saturate(738%) hue-rotate(224deg) brightness(86%) contrast(88%)"
|
||||
"invert(20%) sepia(99%) saturate(5628%) hue-rotate(0deg) brightness(95%) contrast(118%)",
|
||||
"invert(53%) sepia(0%) saturate(1071%) hue-rotate(253deg) brightness(95%) contrast(84%)"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -79,5 +79,14 @@
|
||||
"invert(16%) sepia(80%) saturate(5634%) hue-rotate(272deg) brightness(91%) contrast(105%)",
|
||||
"invert(71%) sepia(42%) saturate(4219%) hue-rotate(150deg) brightness(99%) contrast(86%)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "articles",
|
||||
"url": "a",
|
||||
"prefetch": true,
|
||||
"filters": [
|
||||
"invert(30%) sepia(73%) saturate(4089%) hue-rotate(326deg) brightness(96%) contrast(104%)",
|
||||
"invert(55%) sepia(20%) saturate(738%) hue-rotate(224deg) brightness(86%) contrast(88%)"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -18,6 +18,7 @@ import findAllPlusMembersSql from "./findAllPlusMembers.sql";
|
||||
import findAllPatronsSql from "./findAllPatrons.sql";
|
||||
import addResultHighlightSql from "./addResultHighlight.sql";
|
||||
import deleteAllResultHighlightsSql from "./deleteAllResultHighlights.sql";
|
||||
import searchSql from "./search.sql";
|
||||
|
||||
const upsertStm = sql.prepare(upsertSql);
|
||||
export function upsert(
|
||||
@@ -144,3 +145,24 @@ export const updateResultHighlights = sql.transaction(
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const searchStm = sql.prepare(searchSql);
|
||||
export function search(input: string) {
|
||||
const searchString = `%${input}%`;
|
||||
|
||||
return searchStm.all({
|
||||
discordName: searchString,
|
||||
inGameName: searchString,
|
||||
twitter: searchString,
|
||||
}) as Array<
|
||||
Pick<
|
||||
User,
|
||||
| "discordId"
|
||||
| "discordAvatar"
|
||||
| "discordName"
|
||||
| "discordDiscriminator"
|
||||
| "customUrl"
|
||||
| "inGameName"
|
||||
>
|
||||
>;
|
||||
}
|
||||
|
||||
21
app/db/models/users/search.sql
Normal file
21
app/db/models/users/search.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
select
|
||||
"discordName",
|
||||
"discordId",
|
||||
"discordAvatar",
|
||||
"discordDiscriminator",
|
||||
"customUrl",
|
||||
"inGameName"
|
||||
from
|
||||
"User"
|
||||
left join "PlusTier" on "PlusTier"."userId" = "User"."id"
|
||||
where
|
||||
"discordName" like @discordName
|
||||
or "inGameName" like @inGameName
|
||||
or "twitter" like @twitter
|
||||
order by
|
||||
case
|
||||
when "PlusTier"."tier" is null then 4
|
||||
else "PlusTier"."tier"
|
||||
end asc
|
||||
limit
|
||||
25
|
||||
@@ -21,10 +21,12 @@ import styles from "~/styles/u.css";
|
||||
import invariant from "tiny-invariant";
|
||||
import {
|
||||
isCustomUrl,
|
||||
navIconUrl,
|
||||
userBuildsPage,
|
||||
userEditProfilePage,
|
||||
userPage,
|
||||
userResultsPage,
|
||||
USER_SEARCH_PAGE,
|
||||
} from "~/utils/urls";
|
||||
import { Main } from "~/components/Main";
|
||||
|
||||
@@ -42,6 +44,22 @@ export const meta: MetaFunction = ({ data }: { data: UserPageLoaderData }) => {
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: "user",
|
||||
breadcrumb: ({ match }) => {
|
||||
const data = match.data as UserPageLoaderData;
|
||||
|
||||
return [
|
||||
{
|
||||
imgPath: navIconUrl("u"),
|
||||
href: USER_SEARCH_PAGE,
|
||||
type: "IMAGE",
|
||||
},
|
||||
{
|
||||
text: data.discordName,
|
||||
href: userPage(data),
|
||||
type: "TEXT",
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export const userParamsSchema = z.object({ identifier: z.string() });
|
||||
|
||||
105
app/routes/u.tsx
Normal file
105
app/routes/u.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { LinksFunction, LoaderArgs } from "@remix-run/node";
|
||||
import { Main } from "~/components/Main";
|
||||
import type { SendouRouteHandle } from "~/utils/remix";
|
||||
import { navIconUrl, userPage, USER_SEARCH_PAGE } from "~/utils/urls";
|
||||
import styles from "~/styles/u.css";
|
||||
import { Input } from "~/components/Input";
|
||||
import { SearchIcon } from "~/components/icons/Search";
|
||||
import { db } from "~/db";
|
||||
import { Link, useLoaderData, useSearchParams } from "@remix-run/react";
|
||||
import { useDebounce } from "react-use";
|
||||
import * as React from "react";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { discordFullName } from "~/utils/strings";
|
||||
import { useTranslation } from "~/hooks/useTranslation";
|
||||
|
||||
export const links: LinksFunction = () => {
|
||||
return [{ rel: "stylesheet", href: styles }];
|
||||
};
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["user"],
|
||||
breadcrumb: () => ({
|
||||
imgPath: navIconUrl("u"),
|
||||
href: USER_SEARCH_PAGE,
|
||||
type: "IMAGE",
|
||||
}),
|
||||
};
|
||||
|
||||
export const loader = ({ request }: LoaderArgs) => {
|
||||
const url = new URL(request.url);
|
||||
const input = url.searchParams.get("q");
|
||||
|
||||
if (!input) return null;
|
||||
|
||||
return { users: db.users.search(input), input };
|
||||
};
|
||||
|
||||
export default function UserSearchPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [inputValue, setInputValue] = React.useState(
|
||||
searchParams.get("q") ?? ""
|
||||
);
|
||||
useDebounce(
|
||||
() => {
|
||||
if (!inputValue) return;
|
||||
|
||||
setSearchParams({ q: inputValue });
|
||||
},
|
||||
1500,
|
||||
[inputValue]
|
||||
);
|
||||
|
||||
return (
|
||||
<Main className="u-search__container">
|
||||
<Input
|
||||
className="u-search__input"
|
||||
icon={<SearchIcon className="u-search__icon" />}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
/>
|
||||
<UsersList />
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function UsersList() {
|
||||
const { t } = useTranslation(["user"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
if (!data) {
|
||||
return <div className="u-search__info">{t("user:search.info")}</div>;
|
||||
}
|
||||
|
||||
if (data.users.length === 0) {
|
||||
return (
|
||||
<div className="u-search__info">
|
||||
{t("user:search.noResults", { query: data.input })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="u-search__users">
|
||||
{data.users.map((user) => {
|
||||
return (
|
||||
<li key={user.discordId}>
|
||||
<Link to={userPage(user)}>
|
||||
<div className="u-search__user">
|
||||
<Avatar size="sm" user={user} />
|
||||
<div>
|
||||
<div>{discordFullName(user)}</div>
|
||||
{user.inGameName ? (
|
||||
<div className="u-search__ign">
|
||||
{t("user:ign.short")}: {user.inGameName}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -137,7 +137,7 @@ textarea:not(.plain) {
|
||||
height: 8rem;
|
||||
padding: var(--s-2-5) var(--s-3);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: var(--rounded);
|
||||
border-radius: var(--rounded-sm);
|
||||
accent-color: var(--theme-secondary);
|
||||
background-color: var(--bg-input);
|
||||
color: var(--text);
|
||||
@@ -166,7 +166,7 @@ input:not(.plain) {
|
||||
height: 1rem;
|
||||
padding: var(--s-4) var(--s-3);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: var(--rounded);
|
||||
border-radius: var(--rounded-sm);
|
||||
accent-color: var(--theme-secondary);
|
||||
background-color: var(--bg-input);
|
||||
color: var(--text);
|
||||
@@ -252,7 +252,7 @@ select {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: var(--rounded);
|
||||
border-radius: var(--rounded-sm);
|
||||
background: var(--select-background, var(--bg-input));
|
||||
|
||||
/* TODO: Get color from CSS var */
|
||||
@@ -415,7 +415,7 @@ dialog::backdrop {
|
||||
.input-container {
|
||||
display: flex;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: var(--rounded);
|
||||
border-radius: var(--rounded-sm);
|
||||
accent-color: var(--theme-secondary);
|
||||
background-color: var(--bg-input);
|
||||
color: var(--text);
|
||||
@@ -434,6 +434,7 @@ dialog::backdrop {
|
||||
.input-container > input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
margin: auto 0;
|
||||
}
|
||||
/* stylelint-enable no-descending-specificity */
|
||||
|
||||
@@ -443,7 +444,7 @@ dialog::backdrop {
|
||||
|
||||
.input-addon {
|
||||
display: grid;
|
||||
border-radius: var(--rounded) 0 0 var(--rounded);
|
||||
border-radius: var(--rounded-sm) 0 0 var(--rounded-sm);
|
||||
background-color: var(--bg-lighter);
|
||||
color: var(--text-lighter);
|
||||
font-size: var(--fonts-xs);
|
||||
|
||||
@@ -169,3 +169,54 @@
|
||||
gap: var(--s-3);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.u-search__container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-12);
|
||||
}
|
||||
|
||||
.u-search__users {
|
||||
display: flex;
|
||||
width: 325px;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
margin: 0 auto;
|
||||
gap: var(--s-6);
|
||||
}
|
||||
|
||||
.u-search__user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: var(--body);
|
||||
gap: var(--s-4);
|
||||
line-height: 1.2rem;
|
||||
}
|
||||
|
||||
.u-search__ign {
|
||||
color: var(--text-lighter);
|
||||
font-size: var(--fonts-sm);
|
||||
font-weight: var(--body);
|
||||
}
|
||||
|
||||
.u-search__users > li {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.u-search__icon {
|
||||
height: 25px;
|
||||
margin: auto;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.u-search__info {
|
||||
color: var(--text-lighter);
|
||||
font-size: var(--fonts-sm);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.u-search__input {
|
||||
height: 40px !important;
|
||||
margin: 0 auto;
|
||||
font-size: var(--fonts-lg);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ export const FAQ_PAGE = "/faq";
|
||||
export const CONTRIBUTIONS_PAGE = "/contributions";
|
||||
export const BADGES_PAGE = "/badges";
|
||||
export const BUILDS_PAGE = "/builds";
|
||||
export const USER_SEARCH_PAGE = "/u";
|
||||
export const CALENDAR_PAGE = "/calendar";
|
||||
export const STOP_IMPERSONATING_URL = "/auth/impersonate/stop";
|
||||
export const SEED_URL = "/seed";
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"pages.plans": "Planner",
|
||||
"pages.object-damage-calculator": "DMG Calc",
|
||||
"pages.myPage": "My Page",
|
||||
"pages.u": "User Search",
|
||||
|
||||
"header.profile": "Profile",
|
||||
"header.logout": "Log out",
|
||||
|
||||
@@ -25,5 +25,8 @@
|
||||
"forms.errors.invalidCustomUrl.numbers": "Custom URL can't only contain numbers",
|
||||
"forms.errors.invalidCustomUrl.strangeCharacter": "Custom URL can't contain special characters",
|
||||
"forms.errors.invalidCustomUrl.duplicate": "Someone is already using this custom URL",
|
||||
"forms.errors.invalidSens": "Motion sens can't be set if R-stick sens isn't"
|
||||
"forms.errors.invalidSens": "Motion sens can't be set if R-stick sens isn't",
|
||||
|
||||
"search.info": "Search for users by Discord, Splatoon 3 or Twitter name",
|
||||
"search.noResults": "No users found matching '{{query}}'"
|
||||
}
|
||||
|
||||
BIN
public/static-assets/img/layout/u.avif
Normal file
BIN
public/static-assets/img/layout/u.avif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
BIN
public/static-assets/img/layout/u.png
Normal file
BIN
public/static-assets/img/layout/u.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
Reference in New Issue
Block a user