diff --git a/app/components/Input.tsx b/app/components/Input.tsx index 12f643dce..3fbf52d0a 100644 --- a/app/components/Input.tsx +++ b/app/components/Input.tsx @@ -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) => void; }) { return (
@@ -32,7 +38,10 @@ export function Input({ pattern={pattern} list={list} data-cy={dataCy} + value={value} + onChange={onChange} /> + {icon}
); } diff --git a/app/components/layout/nav-items.json b/app/components/layout/nav-items.json index 08679ec6e..579fb6f9f 100644 --- a/app/components/layout/nav-items.json +++ b/app/components/layout/nav-items.json @@ -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%)" + ] } ] diff --git a/app/db/models/users/queries.server.ts b/app/db/models/users/queries.server.ts index cb04ed6ad..623c27682 100644 --- a/app/db/models/users/queries.server.ts +++ b/app/db/models/users/queries.server.ts @@ -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" + > + >; +} diff --git a/app/db/models/users/search.sql b/app/db/models/users/search.sql new file mode 100644 index 000000000..dace7d3a6 --- /dev/null +++ b/app/db/models/users/search.sql @@ -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 diff --git a/app/routes/u.$identifier.tsx b/app/routes/u.$identifier.tsx index 26ada3ba0..6f05aed23 100644 --- a/app/routes/u.$identifier.tsx +++ b/app/routes/u.$identifier.tsx @@ -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() }); diff --git a/app/routes/u.tsx b/app/routes/u.tsx new file mode 100644 index 000000000..00c486570 --- /dev/null +++ b/app/routes/u.tsx @@ -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 ( +
+ } + value={inputValue} + onChange={(e) => setInputValue(e.target.value)} + /> + +
+ ); +} + +function UsersList() { + const { t } = useTranslation(["user"]); + const data = useLoaderData(); + + if (!data) { + return
{t("user:search.info")}
; + } + + if (data.users.length === 0) { + return ( +
+ {t("user:search.noResults", { query: data.input })} +
+ ); + } + + return ( + + ); +} diff --git a/app/styles/common.css b/app/styles/common.css index 7a329d2e5..990d06bbc 100644 --- a/app/styles/common.css +++ b/app/styles/common.css @@ -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); diff --git a/app/styles/u.css b/app/styles/u.css index 891db35eb..4063d7839 100644 --- a/app/styles/u.css +++ b/app/styles/u.css @@ -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); +} diff --git a/app/utils/urls.ts b/app/utils/urls.ts index 7541dd760..788308346 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -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"; diff --git a/public/locales/en/common.json b/public/locales/en/common.json index d509afe1c..95d7cb204 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -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", diff --git a/public/locales/en/user.json b/public/locales/en/user.json index 901b3b64a..02ebf12d9 100644 --- a/public/locales/en/user.json +++ b/public/locales/en/user.json @@ -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}}'" } diff --git a/public/static-assets/img/layout/u.avif b/public/static-assets/img/layout/u.avif new file mode 100644 index 000000000..c0b3dad4c Binary files /dev/null and b/public/static-assets/img/layout/u.avif differ diff --git a/public/static-assets/img/layout/u.png b/public/static-assets/img/layout/u.png new file mode 100644 index 000000000..253bc8bc1 Binary files /dev/null and b/public/static-assets/img/layout/u.png differ