diff --git a/web/api/user.ts b/web/api/user.ts index a45f53b2..add3efcb 100644 --- a/web/api/user.ts +++ b/web/api/user.ts @@ -10,6 +10,9 @@ import type { Hook as UseHook } from "./share/types.ts"; const UserListSchema = z.array(UserSchema); +export function useAll(): Hook { + return useCustomizedSWR("users::all", all, UserListSchema); +} export function useRecommended(): UseHook { const url = endpoints.recommendedUsers; return useAuthorizedData(url); @@ -28,6 +31,11 @@ export function usePendingFromMe(): Hook { ); } +async function all(): Promise { + const res = await credFetch("GET", endpoints.users); + return res.json(); +} + async function matched(): Promise { const res = await credFetch("GET", endpoints.matchedUsers); return res.json(); @@ -131,6 +139,8 @@ export async function deleteAccount(): Promise { const res = await credFetch("DELETE", endpoints.me); if (res.status !== 204) throw new Error( - `failed to delete account: expected status code 204, but got ${res.status} with text ${await res.text()}`, + `failed to delete account: expected status code 204, but got ${ + res.status + } with text ${await res.text()}`, ); } diff --git a/web/app/chat/layout.tsx b/web/app/chat/layout.tsx index c40e4153..ca3f1c81 100644 --- a/web/app/chat/layout.tsx +++ b/web/app/chat/layout.tsx @@ -13,7 +13,7 @@ export default function ChatPageLayout({
{children}
- + ); } diff --git a/web/app/friends/layout.tsx b/web/app/friends/layout.tsx index 735f5e62..0db17f26 100644 --- a/web/app/friends/layout.tsx +++ b/web/app/friends/layout.tsx @@ -1,5 +1,6 @@ import BottomBar from "~/components/BottomBar"; import Header from "~/components/Header"; +import { NavigateByAuthState } from "~/components/common/NavigateByAuthState"; export default function FriendsPageLayout({ children, @@ -7,12 +8,12 @@ export default function FriendsPageLayout({ children: React.ReactNode; }) { return ( - <> +
{children}
- + ); } diff --git a/web/app/home/layout.tsx b/web/app/home/layout.tsx index 9dca6bd3..d9b2aa5f 100644 --- a/web/app/home/layout.tsx +++ b/web/app/home/layout.tsx @@ -1,5 +1,6 @@ import BottomBar from "~/components/BottomBar"; import Header from "~/components/Header"; +import { NavigateByAuthState } from "~/components/common/NavigateByAuthState"; export default function HomePageLayout({ children, @@ -7,12 +8,12 @@ export default function HomePageLayout({ children: React.ReactNode; }) { return ( - <> +
{children}
- + ); } diff --git a/web/app/search/layout.tsx b/web/app/search/layout.tsx new file mode 100644 index 00000000..a3d8c230 --- /dev/null +++ b/web/app/search/layout.tsx @@ -0,0 +1,18 @@ +import BottomBar from "~/components/BottomBar"; +import Header from "~/components/Header"; + +export default function HomePageLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + <> +
+
+ {children} +
+ + + ); +} diff --git a/web/app/search/page.tsx b/web/app/search/page.tsx new file mode 100644 index 00000000..055212d5 --- /dev/null +++ b/web/app/search/page.tsx @@ -0,0 +1,27 @@ +"use client"; + +import type React from "react"; +import { useState } from "react"; +import Search from "~/components/search/search"; +import Table from "~/components/search/table"; + +export default function SearchPage({ + searchParams, +}: { + searchParams?: { + query?: string; + page?: string; + }; +}) { + const [query, setQuery] = useState(searchParams?.query ?? ""); + + return ( +
+
+

ユーザー検索

+ + + + + ); +} diff --git a/web/app/settings/layout.tsx b/web/app/settings/layout.tsx index 1fc99689..e48edcd8 100644 --- a/web/app/settings/layout.tsx +++ b/web/app/settings/layout.tsx @@ -12,7 +12,7 @@ export default function SettingsPageLayout({
{children}
- + ); } diff --git a/web/components/BottomBar.tsx b/web/components/BottomBar.tsx index 8520ec7a..5442c9e0 100644 --- a/web/components/BottomBar.tsx +++ b/web/components/BottomBar.tsx @@ -3,9 +3,10 @@ import { MdHome } from "react-icons/md"; import { MdPeople } from "react-icons/md"; import { MdChat } from "react-icons/md"; import { MdSettings } from "react-icons/md"; +import { MdSearch } from "react-icons/md"; type Props = { - activeTab: "0_home" | "1_friends" | "2_chat" | "3_settings"; + activeTab: "0_home" | "1_friends" | "2_search" | "3_chat" | "4_settings"; }; function BottomBarCell({ @@ -22,7 +23,9 @@ function BottomBarCell({ return ( {iconComponent} } /> + } + /> } /> } /> diff --git a/web/components/search/search.tsx b/web/components/search/search.tsx new file mode 100644 index 00000000..e826aa5c --- /dev/null +++ b/web/components/search/search.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { usePathname, useSearchParams } from "next/navigation"; +import { MdOutlineSearch } from "react-icons/md"; + +type Props = { placeholder: string; setSearchString: (s: string) => void }; +export default function Search({ placeholder, setSearchString }: Props) { + const searchParams = useSearchParams(); + const pathname = usePathname(); + + function handleSearch(term: string) { + setSearchString(term); + const params = new URLSearchParams(searchParams); + if (term) { + params.set("query", term); + } else { + params.delete("query"); + } + const newUrl = `${pathname}?${params.toString()}`; + history.replaceState(undefined, "", newUrl); + } + + return ( +
+ + { + handleSearch(e.target.value); + }} + defaultValue={searchParams.get("query")?.toString()} + /> + +
+ ); +} diff --git a/web/components/search/table.tsx b/web/components/search/table.tsx new file mode 100644 index 00000000..e7ee69b0 --- /dev/null +++ b/web/components/search/table.tsx @@ -0,0 +1,37 @@ +"use client"; +import { useMemo } from "react"; +import { useAll, useMyID } from "~/api/user"; +import { useModal } from "../common/modal/ModalProvider"; +import { HumanListItem } from "../human/humanListItem"; + +export default function UserTable({ query }: { query: string }) { + const { openModal } = useModal(); + const { + state: { data }, + } = useAll(); + const { + state: { data: myId }, + } = useMyID(); + const initialData = useMemo(() => { + return data?.filter((item) => item.id !== myId && item.id !== 0) ?? null; + }, [data, myId]); + const users = query + ? initialData?.filter((user) => + user.name.toLowerCase().includes(query.toLowerCase()), + ) + : initialData; + + return ( +
+ {users?.map((user) => ( + openModal(user)} + /> + ))} +
+ ); +} diff --git a/web/hooks/useCustomizedSWR.ts b/web/hooks/useCustomizedSWR.ts index 69378be1..e087a947 100644 --- a/web/hooks/useCustomizedSWR.ts +++ b/web/hooks/useCustomizedSWR.ts @@ -51,9 +51,17 @@ export function useCustomizedSWR( ): Hook { const CACHE_KEY = SWR_PREFIX + cacheKey; - const [state, setState] = useState>(() => - loadOldData(CACHE_KEY, schema), - ); + // HACK: 最初の描画時の挙動をサーバー上の挙動とそろえるため、 useEffect() でstaleデータの読み込みを遅延している。 + // >> https://github.com/vercel/next.js/discussions/17443 + // >> Code that is only supposed to run in the browser should be executed inside useEffect. That's required because the first render should match the initial render of the server. If you manipulate that result it creates a mismatch and React won't be able to hydrate the page successfully. + const [state, setState] = useState>({ + current: "loading", + data: null, + error: null, + }); + useEffect(() => { + setState(loadOldData(CACHE_KEY, schema)); + }, [CACHE_KEY, schema]); const reload = useCallback(async () => { setState((state) => @@ -77,7 +85,6 @@ export function useCustomizedSWR( console.error( `useSWR: Schema Parse Error | in incoming data | at schema ${CACHE_KEY} | Error: ${result.error.message}`, ); - console.log("data:", data); } setState({ data: data, @@ -152,5 +159,5 @@ function loadOldData( } function go(fn: () => Promise) { - fn(); + fn().catch(console.warn); }