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 !== "" && ( +
+ + + + {title} +
+
+ )} +
{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 !== "" && ( -
- - - - {title} -
-
- )} -
{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 ( +
+
+
+