From cd615ba2e1104f345ca5a65545d7294a5a852f3c Mon Sep 17 00:00:00 2001
From: aster <137767097+aster-void@users.noreply.github.com>
Date: Mon, 9 Jun 2025 23:22:03 +0900
Subject: [PATCH 1/6] wip: UserFormController
---
server/routes/users/me.ts | 19 +-
tsconfig.json | 4 +-
.../(auth)/settings/layout.client.tsx | 66 +++++
.../app/[locale]/(auth)/settings/layout.tsx | 67 +----
web/src/app/[locale]/registration/layout.tsx | 7 +-
web/src/data/_consts.ts | 3 +
web/src/data/_cookie.ts | 9 +
.../data/fetchers/fetch-relational-users.ts | 31 +++
web/src/data/formData.client.ts | 30 ++
web/src/data/formData.server.ts | 63 +++++
web/src/data/types.ts | 0
web/src/features/image/resize.ts | 48 ++++
.../features/settings/UserFormController.tsx | 259 ++++--------------
13 files changed, 327 insertions(+), 279 deletions(-)
create mode 100644 web/src/app/[locale]/(auth)/settings/layout.client.tsx
create mode 100644 web/src/data/_consts.ts
create mode 100644 web/src/data/_cookie.ts
create mode 100644 web/src/data/fetchers/fetch-relational-users.ts
create mode 100644 web/src/data/formData.client.ts
create mode 100644 web/src/data/formData.server.ts
create mode 100644 web/src/data/types.ts
create mode 100644 web/src/features/image/resize.ts
diff --git a/server/routes/users/me.ts b/server/routes/users/me.ts
index e7caef57..06dc07e6 100644
--- a/server/routes/users/me.ts
+++ b/server/routes/users/me.ts
@@ -65,18 +65,25 @@ const route = new Hono()
: {}),
},
include: {
- fluentLanguages: {
- select: { language: true },
- },
- learningLanguages: {
- select: { language: true },
- },
campus: {
include: {
university: true,
},
},
division: true,
+ motherLanguage: true,
+ fluentLanguages: {
+ include: {
+ language: true,
+ },
+ },
+ learningLanguages: {
+ include: {
+ language: true,
+ },
+ },
+ marking: true,
+ markedAs: true,
},
});
diff --git a/tsconfig.json b/tsconfig.json
index 2390076c..87d7d9c8 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -18,8 +18,8 @@
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
- "noUnusedLocals": true,
- "noUnusedParameters": true,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
// "noUncheckedSideEffectImports": true,
// ?
"incremental": true,
diff --git a/web/src/app/[locale]/(auth)/settings/layout.client.tsx b/web/src/app/[locale]/(auth)/settings/layout.client.tsx
new file mode 100644
index 00000000..bdf0f5d5
--- /dev/null
+++ b/web/src/app/[locale]/(auth)/settings/layout.client.tsx
@@ -0,0 +1,66 @@
+"use client";
+
+import SideNav from "@/features/settings/SideNav";
+import { useNormalizedPathname } from "@/hooks/useNormalizedPath";
+import { useTranslations } from "next-intl";
+import Link from "next/link";
+import { AiOutlineLeft } from "react-icons/ai";
+
+function useTransition(pathname: string) {
+ switch (pathname) {
+ case "":
+ return "basic.title";
+ case "/basic":
+ return "basic.title";
+ case "/university":
+ return "university.title";
+ case "/language":
+ return "language.title";
+ case "/topic":
+ return "topic.title";
+ case "/favorite":
+ return "favorite.title";
+ case "/block":
+ return "block.title";
+ case "/privacy":
+ return "other.privacy.title";
+ case "/terms":
+ return "other.terms.title";
+ case "/contact":
+ return "other.contact.title";
+ case "/delete":
+ return "delete.title";
+ case "/email":
+ return "email.title";
+ default:
+ console.error("got unexpected pathname:", pathname);
+ return "basic.title";
+ }
+}
+
+export default function ClientLayout({ children }: { children: React.ReactNode }) {
+ const t = useTranslations("settings");
+ const pathnameAfterSettings = useNormalizedPathname().replace("/settings", "");
+
+ const title = t(useTransition(pathnameAfterSettings));
+
+ return (
+ <>
+
+
+
+
+ {pathnameAfterSettings !== "" && (
+
+ )}
+
{children}
+
+ >
+ );
+}
diff --git a/web/src/app/[locale]/(auth)/settings/layout.tsx b/web/src/app/[locale]/(auth)/settings/layout.tsx
index 24a576ba..352b4389 100644
--- a/web/src/app/[locale]/(auth)/settings/layout.tsx
+++ b/web/src/app/[locale]/(auth)/settings/layout.tsx
@@ -1,68 +1,15 @@
-"use client";
-
-import SideNav from "@/features/settings/SideNav";
+import { getGlobalData, getPersonalData } from "@/data/formData.server";
import { UserFormProvider } from "@/features/settings/UserFormController.tsx";
-import { useNormalizedPathname } from "@/hooks/useNormalizedPath";
-import { Link } from "@/i18n/navigation";
-import { useTranslations } from "next-intl";
-import { AiOutlineLeft } from "react-icons/ai";
-
-function getTransition(pathname: string) {
- switch (pathname) {
- case "":
- return "basic.title";
- case "/basic":
- return "basic.title";
- case "/university":
- return "university.title";
- case "/language":
- return "language.title";
- case "/topic":
- return "topic.title";
- case "/favorite":
- return "favorite.title";
- case "/block":
- return "block.title";
- case "/privacy":
- return "other.privacy.title";
- case "/terms":
- return "other.terms.title";
- case "/contact":
- return "other.contact.title";
- case "/delete":
- return "delete.title";
- case "/email":
- return "email.title";
- default:
- console.error("got unexpected pathname:", pathname);
- return "basic.title";
- }
-}
-
-export default function Layout({ children }: { children: React.ReactNode }) {
- const t = useTranslations("settings");
- const pathnameAfterSettings = useNormalizedPathname().replace("/settings", "");
+import ClientLayout from "./layout.client";
- const title = t(getTransition(pathnameAfterSettings));
+export default async function Layout({ children }: { children: React.ReactNode }) {
+ const globalData = await getGlobalData();
+ const personalData = await getPersonalData();
return (
<>
-
-
-
-
-
- {pathnameAfterSettings !== "" && (
-
- )}
-
{children}
-
+
+ {children}
>
);
diff --git a/web/src/app/[locale]/registration/layout.tsx b/web/src/app/[locale]/registration/layout.tsx
index 8d0ce4c1..cf68ba71 100644
--- a/web/src/app/[locale]/registration/layout.tsx
+++ b/web/src/app/[locale]/registration/layout.tsx
@@ -1,15 +1,18 @@
import Header from "@/components/Header";
+import { getGlobalData } from "@/data/formData.server";
import { AuthProvider } from "@/features/auth/providers/AuthProvider";
import { UserFormProvider } from "@/features/settings/UserFormController";
import { ToastProvider } from "@/features/toast/ToastProvider";
-export default function Layout({ children }: { children: React.ReactNode }) {
+export default async function Layout({ children }: { children: React.ReactNode }) {
+ const globalData = await getGlobalData();
+
return (
-
+
{children}
diff --git a/web/src/data/_consts.ts b/web/src/data/_consts.ts
new file mode 100644
index 00000000..c0e4bc9e
--- /dev/null
+++ b/web/src/data/_consts.ts
@@ -0,0 +1,3 @@
+export const cookieNames = {
+ idToken: "ut-bridge::id-token",
+};
diff --git a/web/src/data/_cookie.ts b/web/src/data/_cookie.ts
new file mode 100644
index 00000000..5337ea61
--- /dev/null
+++ b/web/src/data/_cookie.ts
@@ -0,0 +1,9 @@
+import { cookies } from "next/headers";
+import { redirect } from "next/navigation";
+import { cookieNames } from "./_consts.ts";
+
+export async function getIdToken() {
+ const idToken = (await cookies()).get(cookieNames.idToken)?.value;
+ if (!idToken) throw redirect("/login");
+ return idToken;
+}
diff --git a/web/src/data/fetchers/fetch-relational-users.ts b/web/src/data/fetchers/fetch-relational-users.ts
new file mode 100644
index 00000000..05d8121d
--- /dev/null
+++ b/web/src/data/fetchers/fetch-relational-users.ts
@@ -0,0 +1,31 @@
+import { client } from "@/client";
+
+export async function getBlockedUsers(idToken: string) {
+ const blockedRes = await client.community.$get({
+ header: {
+ Authorization: idToken,
+ sessionSeed: "", // we don't care about how users are ordered here
+ },
+ query: {
+ marker: "blocked",
+ },
+ });
+ if (!blockedRes.ok) throw new Error("Failed to fetch blocked users");
+ const blockedUsers = await blockedRes.json();
+ return blockedUsers;
+}
+
+export async function getFavoriteUsers(idToken: string) {
+ const favoriteRes = await client.community.$get({
+ header: {
+ Authorization: idToken,
+ sessionSeed: "", // we don't care about how users are ordered here
+ },
+ query: {
+ marker: "favorite",
+ },
+ });
+ if (!favoriteRes.ok) throw new Error("Failed to fetch favorite users");
+ const favoriteUsers = await favoriteRes.json();
+ return favoriteUsers;
+}
diff --git a/web/src/data/formData.client.ts b/web/src/data/formData.client.ts
new file mode 100644
index 00000000..76a37958
--- /dev/null
+++ b/web/src/data/formData.client.ts
@@ -0,0 +1,30 @@
+"use client";
+
+import { client } from "@/client.ts";
+import { useEffect, useState } from "react";
+
+export function useUniversitySpecificData(universityId: string | undefined) {
+ const [loading, setLoading] = useState(false);
+ const [campuses, setCampuses] = useState<{ id: string; jaName: string; enName: string }[] | null>(null);
+ const [divisions, setDivisions] = useState<{ id: string; jaName: string; enName: string }[] | null>(null);
+
+ useEffect(() => {
+ async function go() {
+ if (!universityId) return;
+ setLoading(true);
+ const campusRes = await client.campus.$get({ query: { id: universityId } });
+ const divisionRes = await client.division.$get({ query: { id: universityId } });
+ if (!campusRes.ok || !divisionRes.ok) {
+ throw new Error("キャンパスまたは学部データの取得に失敗しました");
+ }
+ const campuses = await campusRes.json();
+ const divisions = await divisionRes.json();
+ setCampuses(campuses);
+ setDivisions(divisions);
+ setLoading(false);
+ }
+ go();
+ }, [universityId]);
+
+ return { campuses, divisions, loading };
+}
diff --git a/web/src/data/formData.server.ts b/web/src/data/formData.server.ts
new file mode 100644
index 00000000..431807d5
--- /dev/null
+++ b/web/src/data/formData.server.ts
@@ -0,0 +1,63 @@
+import { client } from "@/client";
+import { formatCardUser } from "@/features/format.ts";
+import type { FlatCardUser, MYDATA } from "common/zod/schema";
+import { getLocale } from "next-intl/server";
+import { getIdToken } from "./_cookie.ts";
+import { getBlockedUsers, getFavoriteUsers } from "./fetchers/fetch-relational-users.ts";
+
+export async function getUserData(): Promise {
+ const idToken = await getIdToken();
+
+ const res = await client.users.me.$get({
+ header: {
+ Authorization: idToken,
+ },
+ });
+ if (!res.ok) throw new Error("Failed to fetch user");
+ const data = await res.json();
+ if (!data) throw new Error("User not found");
+ return data;
+}
+
+export async function getGlobalData(): Promise<{
+ universities: { id: string; jaName: string; enName: string }[];
+ languages: { id: string; jaName: string; enName: string }[];
+}> {
+ const universityRes = await client.university.$get();
+ const languageRes = await client.language.$get();
+
+ if (!universityRes.ok || !languageRes.ok) {
+ throw new Error(
+ `データ取得に失敗しました: {
+ university: ${await universityRes.text()}
+ language: ${await languageRes.text()}
+ }`,
+ );
+ }
+
+ const universities = await universityRes.json();
+ const languages = await languageRes.json();
+
+ return { universities, languages };
+}
+
+export async function getPersonalData(): Promise<{
+ blockedUsers: FlatCardUser[];
+ favoriteUsers: FlatCardUser[];
+ savedData: MYDATA | null;
+}> {
+ const idToken = await getIdToken();
+ const locale = await getLocale();
+
+ const [blockedUsers, favoriteUsers, user] = await Promise.all([
+ getBlockedUsers(idToken),
+ getFavoriteUsers(idToken),
+ getUserData(),
+ ]);
+
+ return {
+ blockedUsers: blockedUsers.users.map((user) => formatCardUser(user, locale)),
+ favoriteUsers: favoriteUsers.users.map((user) => formatCardUser(user, locale)),
+ savedData: user,
+ };
+}
diff --git a/web/src/data/types.ts b/web/src/data/types.ts
new file mode 100644
index 00000000..e69de29b
diff --git a/web/src/features/image/resize.ts b/web/src/features/image/resize.ts
new file mode 100644
index 00000000..d154741f
--- /dev/null
+++ b/web/src/features/image/resize.ts
@@ -0,0 +1,48 @@
+export const resizeImage = (file: File, maxWidth = 800, maxHeight = 800): Promise => {
+ return new Promise((resolve, reject) => {
+ const img = new Image();
+ const canvas = document.createElement("canvas");
+ const reader = new FileReader();
+
+ reader.onload = (e) => {
+ img.onload = () => {
+ let { width, height } = img;
+
+ // 比率を保ってリサイズ
+ if (width > maxWidth || height > maxHeight) {
+ const scale = Math.min(maxWidth / width, maxHeight / height);
+ width *= scale;
+ height *= scale;
+ }
+
+ canvas.width = width;
+ canvas.height = height;
+
+ const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ reject(new Error("Canvas 2D context is not supported"));
+ return;
+ }
+ ctx.drawImage(img, 0, 0, width, height);
+
+ canvas.toBlob((blob) => {
+ if (blob) {
+ resolve(new File([blob], file.name, { type: file.type }));
+ } else {
+ reject(new Error("Blob conversion failed"));
+ }
+ }, file.type);
+ };
+ img.onerror = reject;
+ const result = e.target?.result;
+ if (typeof result === "string") {
+ img.src = result;
+ } else {
+ reject(new Error("FileReader result is not a string"));
+ }
+ };
+
+ reader.onerror = reject;
+ reader.readAsDataURL(file);
+ });
+};
diff --git a/web/src/features/settings/UserFormController.tsx b/web/src/features/settings/UserFormController.tsx
index 7eb20e3a..8ac12785 100644
--- a/web/src/features/settings/UserFormController.tsx
+++ b/web/src/features/settings/UserFormController.tsx
@@ -1,67 +1,19 @@
"use client";
+
import { client } from "@/client";
-import Loading from "@/components/Loading.tsx";
+import { getBlockedUsers, getFavoriteUsers } from "@/data/fetchers/fetch-relational-users.ts";
+import { useUniversitySpecificData } from "@/data/formData.client.ts";
import { formatCardUser } from "@/features/format";
-import { useUserContext } from "@/features/user/userProvider";
import type { CreateUser, FlatCardUser, MYDATA } from "common/zod/schema.ts";
import { useLocale, useTranslations } from "next-intl";
-import { createContext, useCallback, useContext, useEffect, useState } from "react";
-import { useRef } from "react";
+import { createContext, useCallback, useContext, useState } from "react";
import { useAuthContext } from "../auth/providers/AuthProvider.tsx";
import { upload } from "../image/ImageUpload.tsx";
+import { resizeImage } from "../image/resize.ts";
import { useToast } from "../toast/ToastProvider.tsx";
export type Status = "ready" | "error" | "success" | "processing";
-const resizeImage = (file: File, maxWidth = 800, maxHeight = 800): Promise => {
- return new Promise((resolve, reject) => {
- const img = new Image();
- const canvas = document.createElement("canvas");
- const reader = new FileReader();
-
- reader.onload = (e) => {
- img.onload = () => {
- let { width, height } = img;
-
- // 比率を保ってリサイズ
- if (width > maxWidth || height > maxHeight) {
- const scale = Math.min(maxWidth / width, maxHeight / height);
- width *= scale;
- height *= scale;
- }
-
- canvas.width = width;
- canvas.height = height;
-
- const ctx = canvas.getContext("2d");
- if (!ctx) {
- reject(new Error("Canvas 2D context is not supported"));
- return;
- }
- ctx.drawImage(img, 0, 0, width, height);
-
- canvas.toBlob((blob) => {
- if (blob) {
- resolve(new File([blob], file.name, { type: file.type }));
- } else {
- reject(new Error("Blob conversion failed"));
- }
- }, file.type);
- };
- img.onerror = reject;
- const result = e.target?.result;
- if (typeof result === "string") {
- img.src = result;
- } else {
- reject(new Error("FileReader result is not a string"));
- }
- };
-
- reader.onerror = reject;
- reader.readAsDataURL(file);
- });
-};
-
type UserFormContextType = {
loadingUniversitySpecificData: boolean;
formData: Partial;
@@ -72,7 +24,7 @@ type UserFormContextType = {
setImagePreviewURL: React.Dispatch>;
handleImageFileChange: (file: File) => void;
uploadImage: () => Promise;
- onSuccess: (data: Partial) => void;
+ onSuccess: (data: MYDATA) => void;
onFailure: () => void;
submitPatch: (e: React.FormEvent) => void;
status: Status;
@@ -98,63 +50,51 @@ export const useUserFormContext = () => {
export const UserFormProvider = ({
children,
- loadPreviousData,
+ globalData: { universities, languages },
+ personalData,
}: {
children: React.ReactNode;
- loadPreviousData: boolean;
+ globalData: {
+ universities: { id: string; jaName: string; enName: string }[];
+ languages: { id: string; jaName: string; enName: string }[];
+ };
+ personalData: {
+ savedData: MYDATA | null;
+ blockedUsers: FlatCardUser[];
+ favoriteUsers: FlatCardUser[];
+ } | null;
}) => {
const t = useTranslations();
const toast = useToast();
const locale = useLocale();
- let me: MYDATA | null = null;
- const setMyData = useRef<((data: Partial) => void) | null>(null);
const [status, setStatus] = useState("ready");
- // HELP: how do I optionally use another context? loadPreviousData will never change
- if (loadPreviousData) {
- const usercx = useUserContext();
- me = usercx.me;
- setMyData.current = (data) => usercx.updateMyData((prev) => ({ ...prev, ...data }));
- }
+
const { idToken: Authorization } = useAuthContext();
- const [formData, setFormData] = useState>({ loading: true });
const [image, setImage] = useState(null);
const [imagePreviewURL, setImagePreviewURL] = useState(null);
- const [universities, setUniversities] = useState<{ id: string; jaName: string; enName: string }[]>([]);
- const [campuses, setCampuses] = useState<{ id: string; jaName: string; enName: string }[]>([]);
- const [divisions, setDivisions] = useState<{ id: string; jaName: string; enName: string }[]>([]);
- const [languages, setLanguages] = useState<{ id: string; jaName: string; enName: string }[]>([]);
- const [favoriteUsers, setFavoriteUsers] = useState([]);
- const [blockedUsers, setBlockedUsers] = useState([]);
- const [loadingUniversitySpecificData, setLoadingUniversitySpecificData] = useState(false);
-
- // static データ取得(大学 & 言語)
- useEffect(() => {
- const fetchData = async () => {
- try {
- const [universityRes, languageRes] = await Promise.all([client.university.$get(), client.language.$get()]);
-
- if (!universityRes.ok || !languageRes.ok) {
- throw new Error(
- `データ取得に失敗しました: {
- university: ${await universityRes.text()}
- language: ${await languageRes.text()}
- }`,
- );
- }
-
- const [universitiesData, languagesData] = await Promise.all([universityRes.json(), languageRes.json()]);
-
- setUniversities(universitiesData);
- setLanguages(languagesData);
- } catch (error) {
- console.error("大学・言語データの取得に失敗しました", error);
- }
+ // need help: what the heck
+ const [_userData, setUserData] = useState(personalData?.savedData ?? null);
+ const [formData, setFormData] = useState>(() => {
+ return {
+ ...personalData?.savedData,
+ imageUrl: personalData?.savedData?.imageUrl ?? undefined,
+ customEmail: personalData?.savedData?.customEmail ?? undefined,
+ universityId: personalData?.savedData?.campus.university.id ?? undefined,
+ campusId: personalData?.savedData?.campus.id ?? undefined,
+ divisionId: personalData?.savedData?.division.id ?? undefined,
+ motherLanguageId: personalData?.savedData?.motherLanguage.id ?? undefined,
+ fluentLanguageIds: personalData?.savedData?.fluentLanguages.map((lang) => lang.language.id) ?? [],
+ learningLanguageIds: personalData?.savedData?.learningLanguages.map((lang) => lang.language.id) ?? [],
};
+ });
- fetchData();
- }, []);
+ const {
+ campuses,
+ divisions,
+ loading: loadingUniversitySpecificData,
+ } = useUniversitySpecificData(formData.universityId);
const submitPatch = async (e: React.FormEvent) => {
e.preventDefault();
@@ -191,7 +131,9 @@ export const UserFormProvider = ({
throw new Error(`レスポンスステータス: ${res.status}, response: ${await res.text()}`);
}
setStatus("success");
- onSuccess(await res.json());
+ const data = await res.json();
+ onSuccess(data);
+ setUserData(data);
} catch (error) {
console.error("ユーザー情報の更新に失敗しました", error);
setStatus("error");
@@ -203,134 +145,34 @@ export const UserFormProvider = ({
}
};
- // 大学固有の static データを取得
- useEffect(() => {
- if (!formData.universityId) return;
- const fetchCampusAndDivisions = async () => {
- console.log(`fetching university-specific data for university ${formData.universityId} ...`);
- setLoadingUniversitySpecificData(true);
- try {
- const [campusRes, divisionRes] = await Promise.all([
- client.campus.$get({ query: { id: formData.universityId } }),
- client.division.$get({ query: { id: formData.universityId } }),
- ]);
-
- if (!campusRes.ok || !divisionRes.ok) {
- throw new Error("キャンパスまたは学部データの取得に失敗しました");
- }
-
- setCampuses(await campusRes.json());
- setDivisions(await divisionRes.json());
- } catch (error) {
- console.error(error);
- }
- setLoadingUniversitySpecificData(false);
- };
-
- fetchCampusAndDivisions();
- }, [formData.universityId]);
-
- // ユーザー情報を取得
- useEffect(() => {
- const fetchMyData = async () => {
- try {
- if (!loadPreviousData) {
- setFormData({});
- } else {
- const meRes = await client.users.me.$get({ header: { Authorization } });
- if (!meRes.ok) {
- throw new Error("Failed to fetch user data!");
- }
- const me = await meRes.json();
- if (!me) {
- throw new Error("User Not Found in Database!");
- }
-
- console.log(
- `
- [useFormData] me =
- defaultEmail: ${me.defaultEmail}
- customEmail: ${me.customEmail}
- `,
- );
-
- setFormData({
- ...me,
- customEmail: me.customEmail ?? undefined,
- imageUrl: me.imageUrl ?? undefined,
- universityId: me.campus.university.id,
- motherLanguageId: me.motherLanguage.id,
- fluentLanguageIds: me.fluentLanguages.map((entry) => entry.language.id),
- learningLanguageIds: me.learningLanguages.map((entry) => entry.language.id),
- campusId: me.campus.id,
- divisionId: me.division.id,
- });
- }
- } catch (err) {
- console.error("ユーザー情報の取得に失敗しました", err);
- }
- };
-
- fetchMyData();
- }, [loadPreviousData, Authorization]);
+ const [favoriteUsers, setFavoriteUsers] = useState(personalData?.favoriteUsers ?? []);
+ const [blockedUsers, setBlockedUsers] = useState(personalData?.blockedUsers ?? []);
const refetchFavoriteUsers = useCallback(async () => {
try {
- if (!me) return;
- const res = await client.community.$get({
- header: { Authorization, sessionSeed: "" }, // we don't care about the order of users here
- query: {
- except: me.id,
- marker: "favorite",
- },
- });
-
- if (!res.ok) {
- throw new Error(await res.text());
- }
-
- const data = await res.json();
+ const data = await getFavoriteUsers(Authorization);
setFavoriteUsers(data.users.map((user) => formatCardUser(user, locale)));
} catch (error) {
console.error("お気に入りユーザーの取得に失敗しました", error);
}
- }, [me, Authorization, locale]);
+ }, [Authorization, locale]);
const refetchBlockedUsers = useCallback(async () => {
try {
- if (!me) return;
- const res = await client.community.$get({
- header: { Authorization, sessionSeed: "" }, // we don't care about how users are ordered here
- query: {
- except: me.id,
- marker: "blocked",
- },
- });
-
- if (!res.ok) {
- throw new Error(`ブロックユーザーの取得失敗: ${await res.text()}`);
- }
-
- const data = await res.json();
+ const data = await getBlockedUsers(Authorization);
setBlockedUsers(data.users.map((user) => formatCardUser(user, locale)));
} catch (error) {
console.error("ブロックユーザーの取得に失敗しました", error);
}
- }, [me, Authorization, locale]);
-
- useEffect(() => {
- refetchFavoriteUsers();
- refetchBlockedUsers();
- }, [refetchFavoriteUsers, refetchBlockedUsers]);
+ }, [Authorization, locale]);
const onSuccess = useCallback(
- (data: Partial) => {
- console.log("ok 1");
+ (data: MYDATA) => {
toast.push({
color: "success",
message: t("settings.success"),
});
- setMyData.current?.(data);
+ setUserData(data);
},
[toast, t],
);
@@ -429,7 +271,6 @@ export const UserFormProvider = ({
});
};
- if (formData.loading) return ;
return (
Date: Tue, 10 Jun 2025 22:03:01 +0900
Subject: [PATCH 2/6] complete: UserFormController
---
web/src/consts.ts | 4 ++++
web/src/data/_consts.ts | 3 ---
web/src/data/formData.server.ts | 2 +-
web/src/data/{_cookie.ts => utils.ts} | 2 +-
web/src/features/auth/functions/cookies.ts | 10 ++++++++++
web/src/features/auth/functions/login.ts | 12 +++++++++++-
web/src/features/auth/functions/logout.ts | 2 ++
7 files changed, 29 insertions(+), 6 deletions(-)
delete mode 100644 web/src/data/_consts.ts
rename web/src/data/{_cookie.ts => utils.ts} (85%)
create mode 100644 web/src/features/auth/functions/cookies.ts
diff --git a/web/src/consts.ts b/web/src/consts.ts
index cf89092a..99b0e052 100644
--- a/web/src/consts.ts
+++ b/web/src/consts.ts
@@ -5,3 +5,7 @@ export const PATHNAME_LANG_PREFIX_PATTERN = /^\/(ja|en)/;
export const STEP_1_DATA_SESSION_STORAGE_KEY = "ut_bridge_step_1_data";
// registration formのimagePreviewUrlをsession storageに保存するkey
export const IMAGE_PREVIEW_URL_SESSION_STORAGE_KEY = "ut_bridge_image_preview_url";
+
+export const cookieNames = {
+ idToken: "ut-bridge::id-token",
+};
diff --git a/web/src/data/_consts.ts b/web/src/data/_consts.ts
deleted file mode 100644
index c0e4bc9e..00000000
--- a/web/src/data/_consts.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export const cookieNames = {
- idToken: "ut-bridge::id-token",
-};
diff --git a/web/src/data/formData.server.ts b/web/src/data/formData.server.ts
index 431807d5..e8894970 100644
--- a/web/src/data/formData.server.ts
+++ b/web/src/data/formData.server.ts
@@ -2,8 +2,8 @@ import { client } from "@/client";
import { formatCardUser } from "@/features/format.ts";
import type { FlatCardUser, MYDATA } from "common/zod/schema";
import { getLocale } from "next-intl/server";
-import { getIdToken } from "./_cookie.ts";
import { getBlockedUsers, getFavoriteUsers } from "./fetchers/fetch-relational-users.ts";
+import { getIdToken } from "./utils.ts";
export async function getUserData(): Promise {
const idToken = await getIdToken();
diff --git a/web/src/data/_cookie.ts b/web/src/data/utils.ts
similarity index 85%
rename from web/src/data/_cookie.ts
rename to web/src/data/utils.ts
index 5337ea61..18f34edd 100644
--- a/web/src/data/_cookie.ts
+++ b/web/src/data/utils.ts
@@ -1,6 +1,6 @@
+import { cookieNames } from "@/consts.ts";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
-import { cookieNames } from "./_consts.ts";
export async function getIdToken() {
const idToken = (await cookies()).get(cookieNames.idToken)?.value;
diff --git a/web/src/features/auth/functions/cookies.ts b/web/src/features/auth/functions/cookies.ts
new file mode 100644
index 00000000..519d96a7
--- /dev/null
+++ b/web/src/features/auth/functions/cookies.ts
@@ -0,0 +1,10 @@
+import { cookieNames } from "@/consts.ts";
+
+const SECONDS_PER_YEAR = 60 * 60 * 24 * 365;
+
+export function setAuthCookies({ idToken }: { idToken: string }) {
+ document.cookie = `${cookieNames.idToken}=${idToken}; path=/; max-age=${SECONDS_PER_YEAR}`;
+}
+export function removeAuthCookies() {
+ document.cookie = `${cookieNames.idToken}=; path=/; max-age=-1`;
+}
diff --git a/web/src/features/auth/functions/login.ts b/web/src/features/auth/functions/login.ts
index 0b156316..a4e3be43 100644
--- a/web/src/features/auth/functions/login.ts
+++ b/web/src/features/auth/functions/login.ts
@@ -3,6 +3,7 @@ import { signInWithPopup } from "firebase/auth";
import { useRouter } from "next/navigation";
import { useCallback, useState } from "react";
import { auth, provider } from "../config.ts";
+import { setAuthCookies } from "./cookies.ts";
async function login() {
try {
@@ -18,6 +19,8 @@ async function login() {
});
const { exists } = await res.json();
+ setAuthCookies({ idToken });
+
return exists ? { status: "hasData", user: result.user } : { status: "auth-nodata", user: result.user };
} catch (error) {
console.error("Login Error:", error);
@@ -41,9 +44,16 @@ export function useGoogleSignIn() {
case "auth-nodata":
router.push("/registration");
break;
- default:
+ case "noAuth":
+ router.push("/login");
+ break;
+ case "error":
+ console.error("Login Error:", response.error);
router.push("/login");
break;
+ default:
+ console.error("Login Error: unknown status", response);
+ break;
}
}, [router]);
diff --git a/web/src/features/auth/functions/logout.ts b/web/src/features/auth/functions/logout.ts
index 4ac6f94d..776caf95 100644
--- a/web/src/features/auth/functions/logout.ts
+++ b/web/src/features/auth/functions/logout.ts
@@ -2,6 +2,7 @@ import { signOut } from "firebase/auth";
import { useRouter } from "next/navigation";
import { useCallback } from "react";
import { auth } from "../config.ts";
+import { removeAuthCookies } from "./cookies.ts";
export function useGoogleLogout() {
const router = useRouter();
@@ -9,6 +10,7 @@ export function useGoogleLogout() {
const logout = useCallback(async () => {
try {
await signOut(auth);
+ removeAuthCookies();
router.push("/login");
} catch (error) {
console.error("Logout Error:", error);
From faa8c2b4b826eb98725ba39dd3ebf67979547419 Mon Sep 17 00:00:00 2001
From: aster <137767097+aster-void@users.noreply.github.com>
Date: Tue, 10 Jun 2025 23:12:59 +0900
Subject: [PATCH 3/6] complete: chat
---
common/zod/schema.ts | 30 ++
server/routes/chat.ts | 28 +-
.../(auth)/chat/[id]/MessageInput.tsx | 63 ++++
.../app/[locale]/(auth)/chat/[id]/client.tsx | 176 ++++++++++
.../app/[locale]/(auth)/chat/[id]/page.tsx | 315 ++----------------
web/src/app/[locale]/(auth)/chat/page.tsx | 99 +-----
web/src/app/[locale]/(auth)/chat/parts.tsx | 43 +++
web/src/app/[locale]/(auth)/layout.tsx | 4 +-
web/src/app/[locale]/(auth)/loading.tsx | 5 +
.../app/[locale]/(auth)/settings/layout.tsx | 22 +-
.../app/[locale]/(auth)/settings/loading.tsx | 5 +
web/src/app/[locale]/loading.tsx | 4 +-
web/src/components/Avatar.tsx | 2 +
web/src/components/Loading.tsx | 11 +-
web/src/data/formData.server.ts | 15 +-
web/src/data/room.server.ts | 32 ++
web/src/data/user.server.ts | 17 +
web/src/features/chat/state.ts | 2 +-
web/src/next/types.ts | 2 +
web/src/{react => next}/useData.tsx | 0
20 files changed, 454 insertions(+), 421 deletions(-)
create mode 100644 web/src/app/[locale]/(auth)/chat/[id]/MessageInput.tsx
create mode 100644 web/src/app/[locale]/(auth)/chat/[id]/client.tsx
create mode 100644 web/src/app/[locale]/(auth)/chat/parts.tsx
create mode 100644 web/src/app/[locale]/(auth)/loading.tsx
create mode 100644 web/src/app/[locale]/(auth)/settings/loading.tsx
create mode 100644 web/src/data/room.server.ts
create mode 100644 web/src/data/user.server.ts
create mode 100644 web/src/next/types.ts
rename web/src/{react => next}/useData.tsx (100%)
diff --git a/common/zod/schema.ts b/common/zod/schema.ts
index eb92d704..1a597ca5 100644
--- a/common/zod/schema.ts
+++ b/common/zod/schema.ts
@@ -136,3 +136,33 @@ export type MYDATA = Omit & {
allowNotifications: boolean;
allowPeriodicNotifications: boolean;
};
+
+// chat types, maybe consider creating schemas for this?
+type Room = {
+ id: string;
+ members: {
+ user: {
+ id: string;
+ name: string;
+ imageUrl: string | null;
+ };
+ }[];
+};
+export type RoomPreview = Room & {
+ lastMessage: string | null;
+};
+export type Message = {
+ id: string;
+ roomId: string;
+ senderId: string;
+ content: string;
+ createdAt: Date;
+ isPhoto: boolean;
+ isEdited: boolean;
+ sender: {
+ name: string;
+ };
+};
+export type ContentfulRoom = Room & {
+ messages: Message[];
+};
diff --git a/server/routes/chat.ts b/server/routes/chat.ts
index 6a5aa03f..3dedb282 100644
--- a/server/routes/chat.ts
+++ b/server/routes/chat.ts
@@ -6,16 +6,7 @@ import { streamSSE } from "hono/streaming";
import z from "zod";
import { prisma } from "../config/prisma.ts";
-// TODO: use types from schema
-import type { Message as PrismaMessage } from "@prisma/client";
-export type { Room } from "@prisma/client";
-export type Message = PrismaMessage & {
- sender: {
- name: string;
- };
-};
-
-import { MESSAGE_MAX_LENGTH } from "common/zod/schema.ts";
+import { MESSAGE_MAX_LENGTH, type Message } from "common/zod/schema.ts";
import { HTTPException } from "hono/http-exception";
import { getUserID } from "../auth/func.ts";
import { onMessageSend } from "../email/hooks/onMessageSend.ts";
@@ -192,7 +183,14 @@ const router = new Hono()
},
});
- return c.json(resp, 200);
+ return c.json(
+ resp.map((it) => ({
+ ...it,
+ lastMessage: it.messages[0]?.content ?? null,
+ messages: undefined,
+ })),
+ 200,
+ );
})
// ## room data
.get(
@@ -305,7 +303,7 @@ const router = new Hono()
},
},
});
- if (!resp) return c.json({ error: "room not found" }, 404);
+ if (!resp) throw new HTTPException(404, { message: "room not found" });
return c.json(resp, 200);
},
)
@@ -332,7 +330,7 @@ const router = new Hono()
const { room: roomId } = c.req.valid("param");
const json = c.req.valid("json");
- const message: PrismaMessage = {
+ const message = {
...json,
roomId,
id: randomUUIDv7(),
@@ -374,7 +372,9 @@ const router = new Hono()
}
})();
const resp = await prisma.message.create({
- data: message,
+ data: {
+ ...message,
+ },
});
return c.json(resp, 201);
},
diff --git a/web/src/app/[locale]/(auth)/chat/[id]/MessageInput.tsx b/web/src/app/[locale]/(auth)/chat/[id]/MessageInput.tsx
new file mode 100644
index 00000000..d1bab7ca
--- /dev/null
+++ b/web/src/app/[locale]/(auth)/chat/[id]/MessageInput.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import { client } from "@/client";
+import { useAuthContext } from "@/features/auth/providers/AuthProvider";
+import clsx from "clsx";
+import { MESSAGE_MAX_LENGTH } from "common/zod/schema";
+import { useState } from "react";
+import { AiOutlineSend } from "react-icons/ai";
+
+export function MessageInput({ roomId }: { roomId: string }) {
+ const { idToken: Authorization } = useAuthContext();
+ const [input, setInput] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+ const isSendButtonDisabled = submitting || input === "";
+
+ const handleSubmit = async (ev: React.FormEvent) => {
+ ev.preventDefault();
+ if (submitting) return;
+ setSubmitting(true);
+ setInput("");
+ await client.chat.rooms[":room"].messages.$post({
+ header: { Authorization },
+ param: {
+ room: roomId,
+ },
+ json: {
+ content: input,
+ isPhoto: false,
+ },
+ });
+ setSubmitting(false);
+ };
+
+ return (
+
+ );
+}
diff --git a/web/src/app/[locale]/(auth)/chat/[id]/client.tsx b/web/src/app/[locale]/(auth)/chat/[id]/client.tsx
new file mode 100644
index 00000000..3ad818a5
--- /dev/null
+++ b/web/src/app/[locale]/(auth)/chat/[id]/client.tsx
@@ -0,0 +1,176 @@
+"use client";
+
+import { client } from "@/client";
+import { useAuthContext } from "@/features/auth/providers/AuthProvider";
+import { handlers } from "@/features/chat/state";
+import { useUserContext } from "@/features/user/userProvider";
+import type { ContentfulRoom } from "common/zod/schema.ts";
+import { useEffect, useRef, useState } from "react";
+
+export function MessageList({
+ data,
+}: {
+ data: ContentfulRoom;
+}) {
+ const [messages, setMessages] = useState(data.messages);
+ const { idToken: Authorization } = useAuthContext();
+
+ useEffect(() => {
+ handlers.onCreate = (message) => {
+ console.log("onCreate: updating messages...");
+ if (data.id === message.roomId) {
+ setMessages((prev) => {
+ // avoid react from automatically optimizing the update away
+ return [...prev, message];
+ });
+ return true;
+ }
+ return false;
+ };
+ handlers.onUpdate = (id, newMessage) => {
+ setMessages((prev) => {
+ for (const m of prev) {
+ if (m.id === id) {
+ m.content = newMessage.content;
+ }
+ }
+ // avoid react from automatically optimizing the update away
+ return [...prev];
+ });
+ };
+ handlers.onDelete = (id) => {
+ setMessages((prev) => {
+ return prev.filter((m) => m.id !== id);
+ });
+ };
+ return () => {
+ handlers.onCreate = undefined;
+ handlers.onUpdate = undefined;
+ handlers.onDelete = undefined;
+ };
+ }, [data.id]);
+ const { me } = useUserContext();
+
+ const target = document.getElementById("scroll-bottom");
+ if (target) {
+ target.scrollIntoView(false);
+ }
+
+ const bottomRef = useRef(null);
+
+ useEffect(() => {
+ messages;
+ bottomRef.current?.scrollIntoView({ behavior: "auto" });
+ }, [messages]);
+
+ const [selectedMessageId, setSelectedMessageId] = useState(null);
+ const [showConfirmModal, setShowConfirmModal] = useState(false);
+ const [deletingMessageId, setDeletingMessageId] = useState(null);
+ const longPressTimer = useRef(null);
+
+ const handleRequestDelete = (id: string) => {
+ setDeletingMessageId(id);
+ setShowConfirmModal(true);
+ };
+
+ const handleDelete = async () => {
+ if (!deletingMessageId) return;
+
+ try {
+ await client.chat.messages[":message"][":room"].$delete({
+ header: { Authorization },
+ param: { message: deletingMessageId, room: data.id },
+ });
+
+ setMessages((prev) => prev.filter((m) => m.id !== deletingMessageId));
+
+ setDeletingMessageId(null);
+ setShowConfirmModal(false);
+ } catch (error) {
+ alert("削除に失敗しました");
+ }
+ };
+
+ const handleEdit = (id: string) => {
+ console.log("編集", id);
+ setSelectedMessageId(null);
+ };
+
+ const handleLongPressStart = (id: string) => {
+ longPressTimer.current = setTimeout(() => {
+ setSelectedMessageId(id);
+ }, 600); // 600ms 長押しで発動
+ };
+
+ const handleLongPressEnd = () => {
+ if (longPressTimer.current) clearTimeout(longPressTimer.current);
+ };
+
+ return (
+
+ );
+}
diff --git a/web/src/app/[locale]/(auth)/chat/[id]/page.tsx b/web/src/app/[locale]/(auth)/chat/[id]/page.tsx
index e6b4ed65..3e9c6dd0 100644
--- a/web/src/app/[locale]/(auth)/chat/[id]/page.tsx
+++ b/web/src/app/[locale]/(auth)/chat/[id]/page.tsx
@@ -1,30 +1,29 @@
-"use client";
-
-import { client } from "@/client";
import Avatar from "@/components/Avatar";
-import Loading from "@/components/Loading";
-import { useAuthContext } from "@/features/auth/providers/AuthProvider";
-import { handlers } from "@/features/chat/state";
-import { useUserContext } from "@/features/user/userProvider";
+import Loading from "@/components/Loading.tsx";
+import { getRoomData } from "@/data/room.server.ts";
+import { getUserData } from "@/data/user.server.ts";
import { Link } from "@/i18n/navigation";
-import { assert } from "@/lib";
-import { use } from "@/react/useData";
-import clsx from "clsx";
-import type { MYDATA } from "common/zod/schema.ts";
-import { MESSAGE_MAX_LENGTH } from "common/zod/schema.ts";
-import { useParams } from "next/navigation";
-import { useEffect, useRef, useState } from "react";
-import { AiOutlineLeft, AiOutlineSend } from "react-icons/ai";
-
-export default function Page() {
- const roomId = useParams().id as string;
- assert(typeof roomId === "string");
+import type { SearchParams } from "@/next/types";
+import type { MYDATA, RoomPreview } from "common/zod/schema.ts";
+import { Suspense } from "react";
+import { AiOutlineLeft } from "react-icons/ai";
+import z from "zod";
+import { MessageInput } from "./MessageInput.tsx";
+import { MessageList } from "./client.tsx";
+
+const SearchParamsSchema = z.object({
+ id: z.string(),
+});
+export default async function Page({ searchParams }: { searchParams: SearchParams }) {
+ const roomId = SearchParamsSchema.parse(await searchParams).id;
return (
@@ -32,282 +31,18 @@ export default function Page() {
);
}
-function MessageInput({ roomId }: { roomId: string }) {
- const { idToken: Authorization } = useAuthContext();
- const [input, setInput] = useState
("");
- const [submitting, setSubmitting] = useState(false);
- const isSendButtonDisabled = submitting || input === "";
-
- const handleSubmit = async (ev: React.FormEvent) => {
- ev.preventDefault();
- if (submitting) return;
- setSubmitting(true);
- setInput("");
- await client.chat.rooms[":room"].messages.$post({
- header: { Authorization },
- param: {
- room: roomId,
- },
- json: {
- content: input,
- isPhoto: false,
- },
- });
- setSubmitting(false);
- };
-
- return (
-
-
-
- );
-}
-function Load({ roomId }: { roomId: string }) {
- const { idToken: Authorization } = useAuthContext();
- const { me } = useUserContext();
- const m = use(async () => {
- const res = await client.chat.rooms[":room"].$get({
- header: { Authorization },
- param: {
- room: roomId,
- },
- });
- const json = await res.json();
- if ("error" in json) throw new Error(json.error);
- return json;
- });
- if (m.loading) return ;
- if (m.error) {
- console.error(m.error);
- return something went wrong;
- }
+async function Load({ roomId }: { roomId: string }) {
+ const me = await getUserData();
+ const room = await getRoomData(roomId);
return (
<>
-
- ({
- ...m,
- createdAt: new Date(m.createdAt),
- }))}
- roomId={roomId}
- />
+
+
>
);
}
-function MessageList({
- data,
- roomId,
-}: {
- data: {
- id: string;
- senderId: string;
- createdAt: Date;
- content: string;
- sender: { name: string };
- }[];
- roomId: string;
-}) {
- const [messages, setMessages] = useState(data);
- const { idToken: Authorization } = useAuthContext();
-
- useEffect(() => {
- handlers.onCreate = (message) => {
- console.log("onCreate: updating messages...");
- if (roomId === message.roomId) {
- setMessages((prev) => {
- // avoid react from automatically optimizing the update away
- return [...prev, message];
- });
- return true;
- }
- return false;
- };
- handlers.onUpdate = (id, newMessage) => {
- setMessages((prev) => {
- for (const m of prev) {
- if (m.id === id) {
- m.content = newMessage.content;
- }
- }
- // avoid react from automatically optimizing the update away
- return [...prev];
- });
- };
- handlers.onDelete = (id) => {
- setMessages((prev) => {
- return prev.filter((m) => m.id !== id);
- });
- };
- return () => {
- handlers.onCreate = undefined;
- handlers.onUpdate = undefined;
- handlers.onDelete = undefined;
- };
- }, [roomId]);
- const { me } = useUserContext();
-
- const target = document.getElementById("scroll-bottom");
- if (target) {
- target.scrollIntoView(false);
- }
-
- const bottomRef = useRef(null);
-
- useEffect(() => {
- messages;
- bottomRef.current?.scrollIntoView({ behavior: "auto" });
- }, [messages]);
-
- const [selectedMessageId, setSelectedMessageId] = useState(null);
- const [showConfirmModal, setShowConfirmModal] = useState(false);
- const [deletingMessageId, setDeletingMessageId] = useState(null);
- const longPressTimer = useRef(null);
-
- const handleRequestDelete = (id: string) => {
- setDeletingMessageId(id);
- setShowConfirmModal(true);
- };
-
- const handleDelete = async () => {
- if (!deletingMessageId) return;
-
- try {
- await client.chat.messages[":message"][":room"].$delete({
- header: { Authorization },
- param: { message: deletingMessageId, room: roomId },
- });
-
- setMessages((prev) => prev.filter((m) => m.id !== deletingMessageId));
-
- setDeletingMessageId(null);
- setShowConfirmModal(false);
- } catch (error) {
- alert("削除に失敗しました");
- }
- };
-
- const handleEdit = (id: string) => {
- console.log("編集", id);
- setSelectedMessageId(null);
- };
-
- const handleLongPressStart = (id: string) => {
- longPressTimer.current = setTimeout(() => {
- setSelectedMessageId(id);
- }, 600); // 600ms 長押しで発動
- };
-
- const handleLongPressEnd = () => {
- if (longPressTimer.current) clearTimeout(longPressTimer.current);
- };
-
- return (
-
- );
-}
-
-type Room = {
- id: string;
- name: string | null;
- members: {
- user: {
- id: string;
- name: string;
- imageUrl: string | null;
- };
- }[];
-};
-
-function Header({ room, me }: { room: Room; me: MYDATA }) {
+function ChatHeader({ room, me }: { room: RoomPreview; me: MYDATA }) {
return (
<>
diff --git a/web/src/app/[locale]/(auth)/chat/page.tsx b/web/src/app/[locale]/(auth)/chat/page.tsx
index 52c44906..b75550c9 100644
--- a/web/src/app/[locale]/(auth)/chat/page.tsx
+++ b/web/src/app/[locale]/(auth)/chat/page.tsx
@@ -1,100 +1,15 @@
-"use client";
+import { getRoomsPreview } from "@/data/room.server.ts";
+import { getUserData } from "@/data/user.server.ts";
+import { Rooms } from "./parts.tsx";
-import { client } from "@/client";
-import Avatar from "@/components/Avatar";
-import Loading from "@/components/Loading";
-import { useAuthContext } from "@/features/auth/providers/AuthProvider";
-import { useUserContext } from "@/features/user/userProvider";
-import { Link } from "@/i18n/navigation";
-import { useTranslations } from "next-intl";
-import { useEffect, useState } from "react";
-
-export default function Page() {
+export default async function Page() {
+ const user = await getUserData(); // TODO: we don't need full user data here
+ const rooms = await getRoomsPreview();
return (
<>
-
+
>
);
}
-
-function Rooms() {
- const [rooms, setRooms] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const { idToken } = useAuthContext();
- const t = useTranslations();
-
- useEffect(() => {
- async function fetchRooms() {
- try {
- const res = await client.chat.rooms.preview.$get({
- header: { Authorization: idToken },
- });
- if (!res.ok) throw new Error("Failed to fetch rooms");
- const data = await res.json();
- setRooms(data);
- } catch {
- setError("An error has occurred.");
- } finally {
- setLoading(false);
- }
- }
- fetchRooms();
- }, [idToken]);
-
- if (loading) return ;
- if (error) return {error};
- if (rooms.length === 0)
- return (
-
- );
-
- return (
-
- {rooms.map((room) => (
- -
-
-
- ))}
-
- );
-}
-
-type RoomPreview = {
- id: string;
- messages: {
- content: string;
- }[];
- members: {
- user: {
- id: string;
- imageUrl: string | null;
- name: string;
- };
- }[];
-};
-
-function Room({ room }: { room: RoomPreview }) {
- const { me } = useUserContext();
- const firstMember = room.members.filter((m) => m.user.id !== me.id)[0]?.user ?? null;
-
- return (
-
-
-
-
-
{firstMember?.name || "Unknown User"}
-
- {room.messages[0]?.content ?? ""}
-
-
-
-
- );
-}
diff --git a/web/src/app/[locale]/(auth)/chat/parts.tsx b/web/src/app/[locale]/(auth)/chat/parts.tsx
new file mode 100644
index 00000000..653a1102
--- /dev/null
+++ b/web/src/app/[locale]/(auth)/chat/parts.tsx
@@ -0,0 +1,43 @@
+import Avatar from "@/components/Avatar";
+import { Link } from "@/i18n/navigation";
+import type { RoomPreview } from "common/zod/schema";
+import { getTranslations } from "next-intl/server";
+
+export async function Rooms({ rooms, userId }: { rooms: RoomPreview[]; userId: string }) {
+ const t = await getTranslations();
+
+ if (rooms.length === 0)
+ return (
+
+ );
+
+ return (
+
+ {rooms.map((room) => (
+ -
+
+
+ ))}
+
+ );
+}
+
+function Room({ room, userId }: { room: RoomPreview; userId: string }) {
+ const firstMember = room.members.filter((m) => m.user.id !== userId)[0]?.user ?? null;
+
+ return (
+
+
+
+
+
{firstMember?.name || "Unknown User"}
+
{room.lastMessage ?? ""}
+
+
+
+ );
+}
diff --git a/web/src/app/[locale]/(auth)/layout.tsx b/web/src/app/[locale]/(auth)/layout.tsx
index 8ec463ca..3cc320a4 100644
--- a/web/src/app/[locale]/(auth)/layout.tsx
+++ b/web/src/app/[locale]/(auth)/layout.tsx
@@ -2,10 +2,12 @@
import Footer from "@/components/Footer";
import Header from "@/components/Header";
+import Loading from "@/components/Loading.tsx";
import { AuthProvider } from "@/features/auth/providers/AuthProvider";
import { ChatNotificationProvider } from "@/features/chat/NotificationProvider";
import { ToastProvider } from "@/features/toast/ToastProvider";
import { UserProvider } from "@/features/user/userProvider";
+import { Suspense } from "react";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
@@ -16,7 +18,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
- {children}
+ }>{children}
diff --git a/web/src/app/[locale]/(auth)/loading.tsx b/web/src/app/[locale]/(auth)/loading.tsx
new file mode 100644
index 00000000..429fdb64
--- /dev/null
+++ b/web/src/app/[locale]/(auth)/loading.tsx
@@ -0,0 +1,5 @@
+import Loading from "@/components/Loading";
+
+export default function Fallback() {
+ return ;
+}
diff --git a/web/src/app/[locale]/(auth)/settings/layout.tsx b/web/src/app/[locale]/(auth)/settings/layout.tsx
index 352b4389..48ad4de8 100644
--- a/web/src/app/[locale]/(auth)/settings/layout.tsx
+++ b/web/src/app/[locale]/(auth)/settings/layout.tsx
@@ -1,16 +1,28 @@
+import Loading from "@/components/Loading.tsx";
import { getGlobalData, getPersonalData } from "@/data/formData.server";
import { UserFormProvider } from "@/features/settings/UserFormController.tsx";
+import { Suspense } from "react";
import ClientLayout from "./layout.client";
export default async function Layout({ children }: { children: React.ReactNode }) {
+ return (
+ <>
+
+ }>
+ {children}
+
+
+ >
+ );
+}
+
+async function DataLoader({ children }: { children: React.ReactNode }) {
const globalData = await getGlobalData();
const personalData = await getPersonalData();
return (
- <>
-
- {children}
-
- >
+
+ {children}
+
);
}
diff --git a/web/src/app/[locale]/(auth)/settings/loading.tsx b/web/src/app/[locale]/(auth)/settings/loading.tsx
new file mode 100644
index 00000000..8805a325
--- /dev/null
+++ b/web/src/app/[locale]/(auth)/settings/loading.tsx
@@ -0,0 +1,5 @@
+import Loading from "@/components/Loading.tsx";
+
+export default function Fallback() {
+ return ;
+}
diff --git a/web/src/app/[locale]/loading.tsx b/web/src/app/[locale]/loading.tsx
index 32f542d1..8cd6f0ad 100644
--- a/web/src/app/[locale]/loading.tsx
+++ b/web/src/app/[locale]/loading.tsx
@@ -1,5 +1,5 @@
import Loading from "@/components/Loading";
-export default function Load() {
- return ;
+export default function Fallback() {
+ return ;
}
diff --git a/web/src/components/Avatar.tsx b/web/src/components/Avatar.tsx
index 3a688b8c..80baaaa5 100644
--- a/web/src/components/Avatar.tsx
+++ b/web/src/components/Avatar.tsx
@@ -1,3 +1,5 @@
+"use client";
+
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
export default function Avatar({
diff --git a/web/src/components/Loading.tsx b/web/src/components/Loading.tsx
index 39fb3c76..2e17d055 100644
--- a/web/src/components/Loading.tsx
+++ b/web/src/components/Loading.tsx
@@ -1,10 +1,17 @@
-const debug_mode = false;
+const debug_mode = true;
+
export default function Loading(_props: { stage: string }) {
+ const server = typeof window === "undefined" ? "Server" : "Client";
+
return (
<>
- {debug_mode && ` loading stage: ${_props.stage}`}
+ {debug_mode && (
+
+ [{server}] loading stage: {_props.stage}
+
+ )}
>
);
diff --git a/web/src/data/formData.server.ts b/web/src/data/formData.server.ts
index e8894970..4cdfc3b6 100644
--- a/web/src/data/formData.server.ts
+++ b/web/src/data/formData.server.ts
@@ -3,22 +3,9 @@ import { formatCardUser } from "@/features/format.ts";
import type { FlatCardUser, MYDATA } from "common/zod/schema";
import { getLocale } from "next-intl/server";
import { getBlockedUsers, getFavoriteUsers } from "./fetchers/fetch-relational-users.ts";
+import { getUserData } from "./user.server.ts";
import { getIdToken } from "./utils.ts";
-export async function getUserData(): Promise {
- const idToken = await getIdToken();
-
- const res = await client.users.me.$get({
- header: {
- Authorization: idToken,
- },
- });
- if (!res.ok) throw new Error("Failed to fetch user");
- const data = await res.json();
- if (!data) throw new Error("User not found");
- return data;
-}
-
export async function getGlobalData(): Promise<{
universities: { id: string; jaName: string; enName: string }[];
languages: { id: string; jaName: string; enName: string }[];
diff --git a/web/src/data/room.server.ts b/web/src/data/room.server.ts
new file mode 100644
index 00000000..b502f17e
--- /dev/null
+++ b/web/src/data/room.server.ts
@@ -0,0 +1,32 @@
+import { client } from "@/client";
+import type { ContentfulRoom, RoomPreview } from "common/zod/schema.ts";
+import { getIdToken } from "./utils.ts";
+
+export async function getRoomsPreview(): Promise {
+ const idToken = await getIdToken();
+
+ const res = await client.chat.rooms.preview.$get({
+ header: { Authorization: idToken },
+ });
+ const json = await res.json();
+ return json;
+}
+
+export async function getRoomData(roomId: string): Promise {
+ const idToken = await getIdToken();
+
+ const res = await client.chat.rooms[":room"].$get({
+ header: { Authorization: idToken },
+ param: {
+ room: roomId,
+ },
+ });
+ const json = await res.json();
+ return {
+ ...json,
+ messages: json.messages.map((it) => ({
+ ...it,
+ createdAt: new Date(it.createdAt), // json can't even serialize Date
+ })),
+ };
+}
diff --git a/web/src/data/user.server.ts b/web/src/data/user.server.ts
new file mode 100644
index 00000000..6c55f2e4
--- /dev/null
+++ b/web/src/data/user.server.ts
@@ -0,0 +1,17 @@
+import { client } from "@/client";
+import type { MYDATA } from "common/zod/schema";
+import { getIdToken } from "./utils.ts";
+
+export async function getUserData(): Promise {
+ const idToken = await getIdToken();
+
+ const res = await client.users.me.$get({
+ header: {
+ Authorization: idToken,
+ },
+ });
+ if (!res.ok) throw new Error("Failed to fetch user");
+ const data = await res.json();
+ if (!data) throw new Error("User not found");
+ return data;
+}
diff --git a/web/src/features/chat/state.ts b/web/src/features/chat/state.ts
index 6748395c..e3c7af5a 100644
--- a/web/src/features/chat/state.ts
+++ b/web/src/features/chat/state.ts
@@ -1,4 +1,4 @@
-import type { Message } from "server/routes/chat";
+import type { Message } from "common/zod/schema.ts";
export type Handlers = {
// retval: prevent default if true
diff --git a/web/src/next/types.ts b/web/src/next/types.ts
new file mode 100644
index 00000000..70232f5f
--- /dev/null
+++ b/web/src/next/types.ts
@@ -0,0 +1,2 @@
+// next can't type searchParams ew
+export type SearchParams = Promise<{ [key: string]: string | string[] | undefined }>;
diff --git a/web/src/react/useData.tsx b/web/src/next/useData.tsx
similarity index 100%
rename from web/src/react/useData.tsx
rename to web/src/next/useData.tsx
From 7283a1e57c2e30bbe5679d46bc89356a14e63b20 Mon Sep 17 00:00:00 2001
From: aster <137767097+aster-void@users.noreply.github.com>
Date: Wed, 11 Jun 2025 15:38:52 +0900
Subject: [PATCH 4/6] complete: UserProvider
---
.../app/[locale]/(auth)/chat/[id]/page.tsx | 4 +-
.../app/[locale]/(auth)/community/page.tsx | 7 ++-
web/src/app/[locale]/(auth)/layout.tsx | 45 +++++++++++++------
web/src/app/[locale]/login/layout.tsx | 2 +-
web/src/app/[locale]/page.tsx | 2 +-
web/src/app/[locale]/registration/layout.tsx | 2 +-
web/src/components/Header.tsx | 24 +++++-----
web/src/consts.ts | 3 ++
web/src/features/user/userProvider.tsx | 33 ++------------
9 files changed, 57 insertions(+), 65 deletions(-)
diff --git a/web/src/app/[locale]/(auth)/chat/[id]/page.tsx b/web/src/app/[locale]/(auth)/chat/[id]/page.tsx
index 3e9c6dd0..e9d4aefa 100644
--- a/web/src/app/[locale]/(auth)/chat/[id]/page.tsx
+++ b/web/src/app/[locale]/(auth)/chat/[id]/page.tsx
@@ -4,7 +4,7 @@ import { getRoomData } from "@/data/room.server.ts";
import { getUserData } from "@/data/user.server.ts";
import { Link } from "@/i18n/navigation";
import type { SearchParams } from "@/next/types";
-import type { MYDATA, RoomPreview } from "common/zod/schema.ts";
+import type { ContentfulRoom, MYDATA } from "common/zod/schema.ts";
import { Suspense } from "react";
import { AiOutlineLeft } from "react-icons/ai";
import z from "zod";
@@ -42,7 +42,7 @@ async function Load({ roomId }: { roomId: string }) {
);
}
-function ChatHeader({ room, me }: { room: RoomPreview; me: MYDATA }) {
+function ChatHeader({ room, me }: { room: ContentfulRoom; me: MYDATA }) {
return (
<>
diff --git a/web/src/app/[locale]/(auth)/community/page.tsx b/web/src/app/[locale]/(auth)/community/page.tsx
index e4a9a9b2..158dcb1e 100644
--- a/web/src/app/[locale]/(auth)/community/page.tsx
+++ b/web/src/app/[locale]/(auth)/community/page.tsx
@@ -1,7 +1,7 @@
"use client";
import { client } from "@/client";
import Loading from "@/components/Loading.tsx";
-import { PATHNAME_LANG_PREFIX_PATTERN } from "@/consts";
+import { PATHNAME_LANG_PREFIX_PATTERN, sessionStorageKeys } from "@/consts";
import { useAuthContext } from "@/features/auth/providers/AuthProvider";
import { formatCardUser } from "@/features/format";
import UserCard from "@/features/user/UserCard.tsx";
@@ -89,11 +89,10 @@ export default function Page() {
const { idToken: Authorization } = useAuthContext();
useEffect(() => {
- const SESSION_SEED_KEY = "ut-bridge:community:session-seed";
- let sessionSeed = sessionStorage.getItem(SESSION_SEED_KEY);
+ let sessionSeed = sessionStorage.getItem(sessionStorageKeys.communitySessionSeed);
if (!sessionSeed) {
sessionSeed = crypto.randomUUID();
- sessionStorage.setItem(SESSION_SEED_KEY, sessionSeed);
+ sessionStorage.setItem(sessionStorageKeys.communitySessionSeed, sessionSeed);
}
const ctl = new AbortController();
diff --git a/web/src/app/[locale]/(auth)/layout.tsx b/web/src/app/[locale]/(auth)/layout.tsx
index 3cc320a4..0a5ddced 100644
--- a/web/src/app/[locale]/(auth)/layout.tsx
+++ b/web/src/app/[locale]/(auth)/layout.tsx
@@ -1,28 +1,47 @@
-"use client";
-
import Footer from "@/components/Footer";
-import Header from "@/components/Header";
+import HeaderComponent from "@/components/Header";
import Loading from "@/components/Loading.tsx";
+import { getUserData } from "@/data/user.server";
import { AuthProvider } from "@/features/auth/providers/AuthProvider";
import { ChatNotificationProvider } from "@/features/chat/NotificationProvider";
import { ToastProvider } from "@/features/toast/ToastProvider";
import { UserProvider } from "@/features/user/userProvider";
import { Suspense } from "react";
+async function LazyHeader() {
+ const user = await getUserData();
+ return ;
+}
+function Header() {
+ return (
+ }>
+
+
+ );
+}
+
export default function Layout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+ }>
+ {children}
+
+
+
+
+ );
+}
+
+async function LazyLayout({ children }: { children: React.ReactNode }) {
+ const user = await getUserData();
+
return (
-
+
-
-
-
+ {children}
diff --git a/web/src/app/[locale]/login/layout.tsx b/web/src/app/[locale]/login/layout.tsx
index eb44acf5..512e8300 100644
--- a/web/src/app/[locale]/login/layout.tsx
+++ b/web/src/app/[locale]/login/layout.tsx
@@ -4,7 +4,7 @@ import { ToastProvider } from "@/features/toast/ToastProvider";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
-
+
{children}
>
);
diff --git a/web/src/app/[locale]/page.tsx b/web/src/app/[locale]/page.tsx
index 9e41839e..615ca002 100644
--- a/web/src/app/[locale]/page.tsx
+++ b/web/src/app/[locale]/page.tsx
@@ -14,7 +14,7 @@ export default function LandingPage() {
const pathname = usePathname();
return (
<>
-
+
diff --git a/web/src/app/[locale]/registration/layout.tsx b/web/src/app/[locale]/registration/layout.tsx
index cf68ba71..03682bff 100644
--- a/web/src/app/[locale]/registration/layout.tsx
+++ b/web/src/app/[locale]/registration/layout.tsx
@@ -9,7 +9,7 @@ export default async function Layout({ children }: { children: React.ReactNode }
return (
-
+
diff --git a/web/src/components/Header.tsx b/web/src/components/Header.tsx
index 8110cc6c..4f1d14f5 100644
--- a/web/src/components/Header.tsx
+++ b/web/src/components/Header.tsx
@@ -1,21 +1,17 @@
"use client";
+
import { useUserContext } from "@/features/user/userProvider.tsx";
import { useNormalizedPathname } from "@/hooks/useNormalizedPath.ts";
import { Link } from "@/i18n/navigation.ts";
+import type { MYDATA } from "common/zod/schema";
import { useTranslations } from "next-intl";
import { AppIcon } from "./AppIcon.tsx";
import Avatar from "./Avatar.tsx";
-export default function Header() {
+export default function Header({ user }: { user: MYDATA | null }) {
const t = useTranslations();
const pathname = useNormalizedPathname();
- const { me } =
- pathname.startsWith("/registration") || pathname === "/login" || pathname === ""
- ? { me: null }
- : // eslint-disable-next-line react-hooks/rules-of-hooks
- useUserContext();
-
let title = "";
if (pathname === "") title = "UT-Bridge";
else if (pathname.startsWith("/chat")) title = t("chat.title");
@@ -31,10 +27,10 @@ export default function Header() {
<>