From 6287e36fe21c73c34a8fcdf34da47741a378442d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=90=E8=97=A4=E7=94=B1=E8=8E=89=E6=9E=9D?= Date: Fri, 20 Mar 2026 23:56:06 +0900 Subject: [PATCH 1/4] =?UTF-8?q?E2E=E3=83=86=E3=82=B9=E3=83=88=E3=81=AE?= =?UTF-8?q?=E5=AE=89=E5=AE=9A=E5=8C=96=E3=81=A8=E3=83=91=E3=83=95=E3=82=A9?= =?UTF-8?q?=E3=83=BC=E3=83=9E=E3=83=B3=E3=82=B9=E6=94=B9=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NavigationItemにaria-label追加(Playwright対応) - サインイン処理の安定化対応 - useInfiniteFetchの最適化(サーバーページング対応) - 画像表示の軽量化(SimpleCoveredImage導入) - 初期取得件数を削減(LIMIT=10) - .gitignoreにcookie.txtを追加 --- .gitignore | 1 + .../components/application/NavigationItem.tsx | 21 ++- .../foundation/SimpleCoveredImage.tsx | 15 ++ .../client/src/components/post/PostItem.tsx | 6 +- .../src/components/post/TimelineImageArea.tsx | 35 ++++ .../src/components/timeline/TimelineItem.tsx | 23 ++- .../client/src/containers/AppContainer.tsx | 162 ++++++++++++++++-- .../src/containers/AuthModalContainer.tsx | 31 ++-- .../client/src/hooks/use_infinite_fetch.ts | 33 ++-- application/client/src/index.tsx | 2 +- 10 files changed, 280 insertions(+), 49 deletions(-) create mode 100644 application/client/src/components/foundation/SimpleCoveredImage.tsx create mode 100644 application/client/src/components/post/TimelineImageArea.tsx diff --git a/.gitignore b/.gitignore index c9fdd6f0b0..1ec5fcd1a5 100644 --- a/.gitignore +++ b/.gitignore @@ -220,3 +220,4 @@ $RECYCLE.BIN/ application/upload/** !application/upload/**/ !application/upload/**/.gitkeep +cookie.txt diff --git a/application/client/src/components/application/NavigationItem.tsx b/application/client/src/components/application/NavigationItem.tsx index 57e64b004a..81d4425a5b 100644 --- a/application/client/src/components/application/NavigationItem.tsx +++ b/application/client/src/components/application/NavigationItem.tsx @@ -12,16 +12,24 @@ interface Props { commandfor?: string; } -export const NavigationItem = ({ badge, href, icon, command, commandfor, text }: Props) => { +export const NavigationItem = ({ + badge, + href, + icon, + command, + commandfor, + text, +}: Props) => { const location = useLocation(); const isActive = location.pathname === href; return (
  • {href !== undefined ? ( @@ -29,10 +37,13 @@ export const NavigationItem = ({ badge, href, icon, command, commandfor, text }: {icon} {badge} - {text} + + {text} + ) : ( )}
  • diff --git a/application/client/src/components/foundation/SimpleCoveredImage.tsx b/application/client/src/components/foundation/SimpleCoveredImage.tsx new file mode 100644 index 0000000000..f91a474777 --- /dev/null +++ b/application/client/src/components/foundation/SimpleCoveredImage.tsx @@ -0,0 +1,15 @@ +interface Props { + src: string; + alt?: string; +} + +export const SimpleCoveredImage = ({ src, alt = "" }: Props) => { + return ( + {alt} + ); +}; diff --git a/application/client/src/components/post/PostItem.tsx b/application/client/src/components/post/PostItem.tsx index 5fa904c91a..f492372bdc 100644 --- a/application/client/src/components/post/PostItem.tsx +++ b/application/client/src/components/post/PostItem.tsx @@ -24,6 +24,7 @@ export const PostItem = ({ post }: Props) => { {post.user.profileImage.alt} @@ -66,7 +67,10 @@ export const PostItem = ({ post }: Props) => { ) : null}

    - + diff --git a/application/client/src/components/post/TimelineImageArea.tsx b/application/client/src/components/post/TimelineImageArea.tsx new file mode 100644 index 0000000000..fc1bfc2307 --- /dev/null +++ b/application/client/src/components/post/TimelineImageArea.tsx @@ -0,0 +1,35 @@ +import classNames from "classnames"; + +import { AspectRatioBox } from "@web-speed-hackathon-2026/client/src/components/foundation/AspectRatioBox"; +import { SimpleCoveredImage } from "@web-speed-hackathon-2026/client/src/components/foundation/SimpleCoveredImage"; +import { getImagePath } from "@web-speed-hackathon-2026/client/src/utils/get_path"; + +interface Props { + images: Models.Image[]; +} + +export const TimelineImageArea = ({ images }: Props) => { + return ( + +

    + {images.map((image, idx) => { + return ( +
    2 && (images.length !== 3 || idx !== 0), + "row-span-2": + images.length <= 2 || (images.length === 3 && idx === 0), + })} + > + +
    + ); + })} +
    + + ); +}; diff --git a/application/client/src/components/timeline/TimelineItem.tsx b/application/client/src/components/timeline/TimelineItem.tsx index 21b88980f8..16858cbff1 100644 --- a/application/client/src/components/timeline/TimelineItem.tsx +++ b/application/client/src/components/timeline/TimelineItem.tsx @@ -8,7 +8,10 @@ import { SoundArea } from "@web-speed-hackathon-2026/client/src/components/post/ import { TranslatableText } from "@web-speed-hackathon-2026/client/src/components/post/TranslatableText"; import { getProfileImagePath } from "@web-speed-hackathon-2026/client/src/utils/get_path"; -const isClickedAnchorOrButton = (target: EventTarget | null, currentTarget: Element): boolean => { +const isClickedAnchorOrButton = ( + target: EventTarget | null, + currentTarget: Element +): boolean => { while (target !== null && target instanceof Element) { const tagName = target.tagName.toLowerCase(); if (["button", "a"].includes(tagName)) { @@ -39,15 +42,21 @@ export const TimelineItem = ({ post }: Props) => { const handleClick = useCallback( (ev) => { const isSelectedText = document.getSelection()?.isCollapsed === false; - if (!isClickedAnchorOrButton(ev.target, ev.currentTarget) && !isSelectedText) { + if ( + !isClickedAnchorOrButton(ev.target, ev.currentTarget) && + !isSelectedText + ) { navigate(`/posts/${post.id}`); } }, - [post, navigate], + [post, navigate] ); return ( -
    +
    { {post.user.profileImage.alt}
    @@ -75,7 +85,10 @@ export const TimelineItem = ({ post }: Props) => { @{post.user.username} - - + diff --git a/application/client/src/containers/AppContainer.tsx b/application/client/src/containers/AppContainer.tsx index d66858a949..8669217efd 100644 --- a/application/client/src/containers/AppContainer.tsx +++ b/application/client/src/containers/AppContainer.tsx @@ -1,4 +1,97 @@ -import { useCallback, useEffect, useId, useState } from "react"; +// import { useCallback, useEffect, useId, useState } from "react"; +// import { Helmet, HelmetProvider } from "react-helmet"; +// import { Route, Routes, useLocation, useNavigate } from "react-router"; + +// import { AppPage } from "@web-speed-hackathon-2026/client/src/components/application/AppPage"; +// import { AuthModalContainer } from "@web-speed-hackathon-2026/client/src/containers/AuthModalContainer"; +// import { CrokContainer } from "@web-speed-hackathon-2026/client/src/containers/CrokContainer"; +// import { DirectMessageContainer } from "@web-speed-hackathon-2026/client/src/containers/DirectMessageContainer"; +// import { DirectMessageListContainer } from "@web-speed-hackathon-2026/client/src/containers/DirectMessageListContainer"; +// import { NewPostModalContainer } from "@web-speed-hackathon-2026/client/src/containers/NewPostModalContainer"; +// import { NotFoundContainer } from "@web-speed-hackathon-2026/client/src/containers/NotFoundContainer"; +// import { PostContainer } from "@web-speed-hackathon-2026/client/src/containers/PostContainer"; +// import { SearchContainer } from "@web-speed-hackathon-2026/client/src/containers/SearchContainer"; +// import { TermContainer } from "@web-speed-hackathon-2026/client/src/containers/TermContainer"; +// import { TimelineContainer } from "@web-speed-hackathon-2026/client/src/containers/TimelineContainer"; +// import { UserProfileContainer } from "@web-speed-hackathon-2026/client/src/containers/UserProfileContainer"; +// import { fetchJSON, sendJSON } from "@web-speed-hackathon-2026/client/src/utils/fetchers"; + +// export const AppContainer = () => { +// const { pathname } = useLocation(); +// const navigate = useNavigate(); +// useEffect(() => { +// window.scrollTo(0, 0); +// }, [pathname]); + +// const [activeUser, setActiveUser] = useState(null); +// const [isLoadingActiveUser, setIsLoadingActiveUser] = useState(true); +// useEffect(() => { +// void fetchJSON("/api/v1/me") +// .then((user) => { +// setActiveUser(user); +// }) +// .finally(() => { +// setIsLoadingActiveUser(false); +// }); +// }, [setActiveUser, setIsLoadingActiveUser]); +// const handleLogout = useCallback(async () => { +// await sendJSON("/api/v1/signout", {}); +// setActiveUser(null); +// navigate("/"); +// }, [navigate]); + +// const authModalId = useId(); +// const newPostModalId = useId(); + +// if (isLoadingActiveUser) { +// return ( +// +// +// 読込中 - CaX +// +// +// ); +// } + +// return ( +// +// +// +// } path="/" /> +// +// } +// path="/dm" +// /> +// } +// path="/dm/:conversationId" +// /> +// } path="/search" /> +// } path="/users/:username" /> +// } path="/posts/:postId" /> +// } path="/terms" /> +// } +// path="/crok" +// /> +// } path="*" /> +// +// + +// +// +// +// ); +// }; + +import { useCallback, useEffect, useId, useRef, useState } from "react"; import { Helmet, HelmetProvider } from "react-helmet"; import { Route, Routes, useLocation, useNavigate } from "react-router"; @@ -14,26 +107,51 @@ import { SearchContainer } from "@web-speed-hackathon-2026/client/src/containers import { TermContainer } from "@web-speed-hackathon-2026/client/src/containers/TermContainer"; import { TimelineContainer } from "@web-speed-hackathon-2026/client/src/containers/TimelineContainer"; import { UserProfileContainer } from "@web-speed-hackathon-2026/client/src/containers/UserProfileContainer"; -import { fetchJSON, sendJSON } from "@web-speed-hackathon-2026/client/src/utils/fetchers"; +import { + fetchJSON, + sendJSON, +} from "@web-speed-hackathon-2026/client/src/utils/fetchers"; export const AppContainer = () => { const { pathname } = useLocation(); const navigate = useNavigate(); + useEffect(() => { window.scrollTo(0, 0); }, [pathname]); const [activeUser, setActiveUser] = useState(null); const [isLoadingActiveUser, setIsLoadingActiveUser] = useState(true); - useEffect(() => { - void fetchJSON("/api/v1/me") - .then((user) => { - setActiveUser(user); - }) - .finally(() => { + + const activeUserRequestIdRef = useRef(0); + + const reloadActiveUser = useCallback(async () => { + const requestId = ++activeUserRequestIdRef.current; + + try { + const user = await fetchJSON("/api/v1/me"); + if (requestId !== activeUserRequestIdRef.current) { + return null; + } + setActiveUser(user); + return user; + } catch { + if (requestId !== activeUserRequestIdRef.current) { + return null; + } + setActiveUser(null); + return null; + } finally { + if (requestId === activeUserRequestIdRef.current) { setIsLoadingActiveUser(false); - }); - }, [setActiveUser, setIsLoadingActiveUser]); + } + } + }, []); + + useEffect(() => { + void reloadActiveUser(); + }, [reloadActiveUser]); + const handleLogout = useCallback(async () => { await sendJSON("/api/v1/signout", {}); setActiveUser(null); @@ -65,12 +183,20 @@ export const AppContainer = () => { } path="/" /> + } path="/dm" /> } + element={ + + } path="/dm/:conversationId" /> } path="/search" /> @@ -78,14 +204,22 @@ export const AppContainer = () => { } path="/posts/:postId" /> } path="/terms" /> } + element={ + + } path="/crok" /> } path="*" /> - + ); diff --git a/application/client/src/containers/AuthModalContainer.tsx b/application/client/src/containers/AuthModalContainer.tsx index 8d159f3528..13c255d5a5 100644 --- a/application/client/src/containers/AuthModalContainer.tsx +++ b/application/client/src/containers/AuthModalContainer.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { flushSync } from "react-dom"; import { SubmissionError } from "redux-form"; import { AuthFormData } from "@web-speed-hackathon-2026/client/src/auth/types"; @@ -16,7 +17,10 @@ const ERROR_MESSAGES: Record = { USERNAME_TAKEN: "ユーザー名が使われています", }; -function getErrorCode(err: JQuery.jqXHR, type: "signin" | "signup"): string { +function getErrorCode( + err: JQuery.jqXHR, + type: "signin" | "signup" +): string { const responseJSON = err.responseJSON; if ( typeof responseJSON !== "object" || @@ -38,34 +42,41 @@ function getErrorCode(err: JQuery.jqXHR, type: "signin" | "signup"): st export const AuthModalContainer = ({ id, onUpdateActiveUser }: Props) => { const ref = useRef(null); const [resetKey, setResetKey] = useState(0); + useEffect(() => { if (!ref.current) return; const element = ref.current; const handleToggle = () => { - // モーダル開閉時にkeyを更新することでフォームの状態をリセットする setResetKey((key) => key + 1); }; element.addEventListener("toggle", handleToggle); return () => { element.removeEventListener("toggle", handleToggle); }; - }, [ref, setResetKey]); + }, []); const handleRequestCloseModal = useCallback(() => { ref.current?.close(); - }, [ref]); - + }, []); const handleSubmit = useCallback( async (values: AuthFormData) => { try { + let user: Models.User; if (values.type === "signup") { - const user = await sendJSON("/api/v1/signup", values); - onUpdateActiveUser(user); + user = await sendJSON("/api/v1/signup", values); } else { - const user = await sendJSON("/api/v1/signin", values); - onUpdateActiveUser(user); + user = await sendJSON("/api/v1/signin", values); } + + flushSync(() => { + onUpdateActiveUser(user); + }); + + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + handleRequestCloseModal(); } catch (err: unknown) { const error = getErrorCode(err as JQuery.jqXHR, values.type); @@ -74,7 +85,7 @@ export const AuthModalContainer = ({ id, onUpdateActiveUser }: Props) => { }); } }, - [handleRequestCloseModal, onUpdateActiveUser], + [handleRequestCloseModal, onUpdateActiveUser] ); return ( diff --git a/application/client/src/hooks/use_infinite_fetch.ts b/application/client/src/hooks/use_infinite_fetch.ts index 394fccd9ea..63ee4c9050 100644 --- a/application/client/src/hooks/use_infinite_fetch.ts +++ b/application/client/src/hooks/use_infinite_fetch.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; -const LIMIT = 30; +const LIMIT = 10; interface ReturnValues { data: Array; @@ -11,9 +11,9 @@ interface ReturnValues { export function useInfiniteFetch( apiPath: string, - fetcher: (apiPath: string) => Promise, + fetcher: (apiPath: string) => Promise ): ReturnValues { - const internalRef = useRef({ isLoading: false, offset: 0 }); + const internalRef = useRef({ isLoading: false, offset: 0, hasMore: true }); const [result, setResult] = useState, "fetchMore">>({ data: [], @@ -22,8 +22,8 @@ export function useInfiniteFetch( }); const fetchMore = useCallback(() => { - const { isLoading, offset } = internalRef.current; - if (isLoading) { + const { isLoading, offset, hasMore } = internalRef.current; + if (isLoading || !hasMore) { return; } @@ -32,20 +32,24 @@ export function useInfiniteFetch( isLoading: true, })); internalRef.current = { + ...internalRef.current, isLoading: true, - offset, }; - void fetcher(apiPath).then( - (allData) => { + const separator = apiPath.includes("?") ? "&" : "?"; + const pagedApiPath = `${apiPath}${separator}limit=${LIMIT}&offset=${offset}`; + + void fetcher(pagedApiPath).then( + (pagedData) => { setResult((cur) => ({ ...cur, - data: [...cur.data, ...allData.slice(offset, offset + LIMIT)], + data: [...cur.data, ...pagedData], isLoading: false, })); internalRef.current = { isLoading: false, - offset: offset + LIMIT, + offset: offset + pagedData.length, + hasMore: pagedData.length === LIMIT, }; }, (error) => { @@ -55,22 +59,23 @@ export function useInfiniteFetch( isLoading: false, })); internalRef.current = { + ...internalRef.current, isLoading: false, - offset, }; - }, + } ); }, [apiPath, fetcher]); useEffect(() => { - setResult(() => ({ + setResult({ data: [], error: null, isLoading: true, - })); + }); internalRef.current = { isLoading: false, offset: 0, + hasMore: true, }; fetchMore(); diff --git a/application/client/src/index.tsx b/application/client/src/index.tsx index b1833b0af3..3a2f95c0b5 100644 --- a/application/client/src/index.tsx +++ b/application/client/src/index.tsx @@ -5,7 +5,7 @@ import { BrowserRouter } from "react-router"; import { AppContainer } from "@web-speed-hackathon-2026/client/src/containers/AppContainer"; import { store } from "@web-speed-hackathon-2026/client/src/store"; -window.addEventListener("load", () => { +window.addEventListener("DOMContentLoaded", () => { createRoot(document.getElementById("app")!).render( From b0c5825afc76011448010b90b13b6e34fdae6d63 Mon Sep 17 00:00:00 2001 From: edako-sato Date: Sat, 21 Mar 2026 01:33:53 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=E4=B8=8D=E8=A6=81=E3=81=AA=E3=82=B3?= =?UTF-8?q?=E3=83=A1=E3=83=B3=E3=83=88=E3=82=A2=E3=82=A6=E3=83=88=E5=89=8A?= =?UTF-8?q?=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client/src/containers/AppContainer.tsx | 98 +------------------ 1 file changed, 1 insertion(+), 97 deletions(-) diff --git a/application/client/src/containers/AppContainer.tsx b/application/client/src/containers/AppContainer.tsx index 8669217efd..08f2147b43 100644 --- a/application/client/src/containers/AppContainer.tsx +++ b/application/client/src/containers/AppContainer.tsx @@ -1,96 +1,3 @@ -// import { useCallback, useEffect, useId, useState } from "react"; -// import { Helmet, HelmetProvider } from "react-helmet"; -// import { Route, Routes, useLocation, useNavigate } from "react-router"; - -// import { AppPage } from "@web-speed-hackathon-2026/client/src/components/application/AppPage"; -// import { AuthModalContainer } from "@web-speed-hackathon-2026/client/src/containers/AuthModalContainer"; -// import { CrokContainer } from "@web-speed-hackathon-2026/client/src/containers/CrokContainer"; -// import { DirectMessageContainer } from "@web-speed-hackathon-2026/client/src/containers/DirectMessageContainer"; -// import { DirectMessageListContainer } from "@web-speed-hackathon-2026/client/src/containers/DirectMessageListContainer"; -// import { NewPostModalContainer } from "@web-speed-hackathon-2026/client/src/containers/NewPostModalContainer"; -// import { NotFoundContainer } from "@web-speed-hackathon-2026/client/src/containers/NotFoundContainer"; -// import { PostContainer } from "@web-speed-hackathon-2026/client/src/containers/PostContainer"; -// import { SearchContainer } from "@web-speed-hackathon-2026/client/src/containers/SearchContainer"; -// import { TermContainer } from "@web-speed-hackathon-2026/client/src/containers/TermContainer"; -// import { TimelineContainer } from "@web-speed-hackathon-2026/client/src/containers/TimelineContainer"; -// import { UserProfileContainer } from "@web-speed-hackathon-2026/client/src/containers/UserProfileContainer"; -// import { fetchJSON, sendJSON } from "@web-speed-hackathon-2026/client/src/utils/fetchers"; - -// export const AppContainer = () => { -// const { pathname } = useLocation(); -// const navigate = useNavigate(); -// useEffect(() => { -// window.scrollTo(0, 0); -// }, [pathname]); - -// const [activeUser, setActiveUser] = useState(null); -// const [isLoadingActiveUser, setIsLoadingActiveUser] = useState(true); -// useEffect(() => { -// void fetchJSON("/api/v1/me") -// .then((user) => { -// setActiveUser(user); -// }) -// .finally(() => { -// setIsLoadingActiveUser(false); -// }); -// }, [setActiveUser, setIsLoadingActiveUser]); -// const handleLogout = useCallback(async () => { -// await sendJSON("/api/v1/signout", {}); -// setActiveUser(null); -// navigate("/"); -// }, [navigate]); - -// const authModalId = useId(); -// const newPostModalId = useId(); - -// if (isLoadingActiveUser) { -// return ( -// -// -// 読込中 - CaX -// -// -// ); -// } - -// return ( -// -// -// -// } path="/" /> -// -// } -// path="/dm" -// /> -// } -// path="/dm/:conversationId" -// /> -// } path="/search" /> -// } path="/users/:username" /> -// } path="/posts/:postId" /> -// } path="/terms" /> -// } -// path="/crok" -// /> -// } path="*" /> -// -// - -// -// -// -// ); -// }; - import { useCallback, useEffect, useId, useRef, useState } from "react"; import { Helmet, HelmetProvider } from "react-helmet"; import { Route, Routes, useLocation, useNavigate } from "react-router"; @@ -216,10 +123,7 @@ export const AppContainer = () => { - + ); From 47e284fc8f0157821ed2b12e4c9897b690afcddb Mon Sep 17 00:00:00 2001 From: edako-sato Date: Sat, 21 Mar 2026 17:56:28 +0900 Subject: [PATCH 3/4] =?UTF-8?q?FCP=E3=82=B9=E3=82=B3=E3=82=A2=E6=94=B9?= =?UTF-8?q?=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- application/client/package.json | 6 +- application/client/postcss.config.js | 2 + .../direct_message/DirectMessageListPage.tsx | 34 ++++-- .../direct_message/DirectMessagePage.tsx | 27 ++++- .../src/components/post/CommentItem.tsx | 10 +- .../client/src/components/post/PostItem.tsx | 10 +- .../src/components/post/TimelineImageArea.tsx | 7 +- .../src/components/post/TimelineMovieArea.tsx | 22 ++++ .../src/components/post/TimelineSoundArea.tsx | 23 ++++ .../src/components/timeline/TimelineItem.tsx | 40 ++++--- .../user_profile/UserProfileHeader.tsx | 24 ++-- .../client/src/containers/AppContainer.tsx | 106 +++++++++--------- application/client/src/index.css | 4 +- application/client/src/index.html | 9 +- application/client/src/utils/fetchers.ts | 4 - application/client/tailwind.config.js | 32 ++++++ application/client/webpack.config.js | 21 +++- application/server/package.json | 2 + application/server/src/app.ts | 2 + 19 files changed, 269 insertions(+), 116 deletions(-) create mode 100644 application/client/src/components/post/TimelineMovieArea.tsx create mode 100644 application/client/src/components/post/TimelineSoundArea.tsx create mode 100644 application/client/tailwind.config.js diff --git a/application/client/package.json b/application/client/package.json index 9f8e80a6a8..43ad315c8f 100644 --- a/application/client/package.json +++ b/application/client/package.json @@ -5,7 +5,7 @@ "license": "MPL-2.0", "author": "CyberAgent, Inc.", "scripts": { - "build": "NODE_ENV=development webpack", + "build": "webpack", "typecheck": "tsc" }, "dependencies": { @@ -86,7 +86,9 @@ "typescript": "5.9.3", "webpack": "5.102.1", "webpack-cli": "6.0.1", - "webpack-dev-server": "5.2.2" + "webpack-dev-server": "5.2.2", + "@tailwindcss/postcss": "^4.2.1", + "tailwindcss": "^4.2.1" }, "engines": { "node": "24.14.0" diff --git a/application/client/postcss.config.js b/application/client/postcss.config.js index d7ee920b94..0c9a35ec92 100644 --- a/application/client/postcss.config.js +++ b/application/client/postcss.config.js @@ -1,9 +1,11 @@ const postcssImport = require("postcss-import"); +const tailwindcss = require("@tailwindcss/postcss"); const postcssPresetEnv = require("postcss-preset-env"); module.exports = { plugins: [ postcssImport(), + tailwindcss(), postcssPresetEnv({ stage: 3, }), diff --git a/application/client/src/components/direct_message/DirectMessageListPage.tsx b/application/client/src/components/direct_message/DirectMessageListPage.tsx index 5a373e918e..235b970acf 100644 --- a/application/client/src/components/direct_message/DirectMessageListPage.tsx +++ b/application/client/src/components/direct_message/DirectMessageListPage.tsx @@ -1,4 +1,3 @@ -import moment from "moment"; import { useCallback, useEffect, useState } from "react"; import { Button } from "@web-speed-hackathon-2026/client/src/components/foundation/Button"; @@ -13,6 +12,11 @@ interface Props { newDmModalId: string; } +const jaDateTimeFormatter = new Intl.DateTimeFormat("ja-JP", { + dateStyle: "short", + timeStyle: "short", +}); + export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => { const [conversations, setConversations] = useState | null>(null); @@ -24,7 +28,8 @@ export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => { } try { - const conversations = await fetchJSON>("/api/v1/dm"); + const conversations = + await fetchJSON>("/api/v1/dm"); setConversations(conversations); setError(null); } catch (error) { @@ -53,7 +58,9 @@ export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => { @@ -61,7 +68,9 @@ export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => { {error != null ? ( -

    DMの取得に失敗しました

    +

    + DMの取得に失敗しました +

    ) : conversations.length === 0 ? (

    まだDMで会話した相手がいません。 @@ -82,7 +91,10 @@ export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => { return (

  • - +
    {peer.profileImage.alt} {

    {peer.name}

    -

    @{peer.username}

    +

    + @{peer.username} +

    {lastMessage != null && ( )}
    -

    {lastMessage?.body}

    +

    + {lastMessage?.body} +

    {hasUnread ? ( 未読 diff --git a/application/client/src/components/direct_message/DirectMessagePage.tsx b/application/client/src/components/direct_message/DirectMessagePage.tsx index 098c7d2894..0fed217a8b 100644 --- a/application/client/src/components/direct_message/DirectMessagePage.tsx +++ b/application/client/src/components/direct_message/DirectMessagePage.tsx @@ -1,5 +1,4 @@ import classNames from "classnames"; -import moment from "moment"; import { ChangeEvent, useCallback, @@ -25,6 +24,12 @@ interface Props { onSubmit: (params: DirectMessageFormData) => Promise; } +const jaTimeFormatter = new Intl.DateTimeFormat("ja-JP", { + hour: "2-digit", + minute: "2-digit", + hour12: false, +}); + export const DirectMessagePage = ({ conversationError, conversation, @@ -38,7 +43,9 @@ export const DirectMessagePage = ({ const textAreaId = useId(); const peer = - conversation.initiator.id !== activeUser.id ? conversation.initiator : conversation.member; + conversation.initiator.id !== activeUser.id + ? conversation.initiator + : conversation.member; const [text, setText] = useState(""); const textAreaRows = Math.min((text || "").split("\n").length, 5); @@ -55,7 +62,11 @@ export const DirectMessagePage = ({ const handleKeyDown = useCallback( (event: KeyboardEvent) => { - if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { + if ( + event.key === "Enter" && + !event.shiftKey && + !event.nativeEvent.isComposing + ) { event.preventDefault(); formRef.current?.requestSubmit(); } @@ -75,7 +86,9 @@ export const DirectMessagePage = ({ useEffect(() => { const id = setInterval(() => { - const height = Number(window.getComputedStyle(document.body).height.replace("px", "")); + const height = Number( + window.getComputedStyle(document.body).height.replace("px", ""), + ); if (height !== scrollHeightRef.current) { scrollHeightRef.current = height; window.scrollTo(0, height); @@ -88,7 +101,9 @@ export const DirectMessagePage = ({ if (conversationError != null) { return (
    -

    メッセージの取得に失敗しました

    +

    + メッセージの取得に失敗しました +

    ); } @@ -141,7 +156,7 @@ export const DirectMessagePage = ({

    {isActiveUserSend && message.isRead && ( 既読 diff --git a/application/client/src/components/post/CommentItem.tsx b/application/client/src/components/post/CommentItem.tsx index cb5bd38bda..9bcd64a961 100644 --- a/application/client/src/components/post/CommentItem.tsx +++ b/application/client/src/components/post/CommentItem.tsx @@ -1,5 +1,3 @@ -import moment from "moment"; - import { Link } from "@web-speed-hackathon-2026/client/src/components/foundation/Link"; import { TranslatableText } from "@web-speed-hackathon-2026/client/src/components/post/TranslatableText"; import { getProfileImagePath } from "@web-speed-hackathon-2026/client/src/utils/get_path"; @@ -8,6 +6,10 @@ interface Props { comment: Models.Comment; } +const jaLongDateFormatter = new Intl.DateTimeFormat("ja-JP", { + dateStyle: "long", +}); + export const CommentItem = ({ comment }: Props) => { return (
    @@ -42,8 +44,8 @@ export const CommentItem = ({ comment }: Props) => {

    -

    diff --git a/application/client/src/components/post/PostItem.tsx b/application/client/src/components/post/PostItem.tsx index f492372bdc..86d741b5cc 100644 --- a/application/client/src/components/post/PostItem.tsx +++ b/application/client/src/components/post/PostItem.tsx @@ -1,5 +1,3 @@ -import moment from "moment"; - import { Link } from "@web-speed-hackathon-2026/client/src/components/foundation/Link"; import { ImageArea } from "@web-speed-hackathon-2026/client/src/components/post/ImageArea"; import { MovieArea } from "@web-speed-hackathon-2026/client/src/components/post/MovieArea"; @@ -11,6 +9,10 @@ interface Props { post: Models.Post; } +const jaLongDateFormatter = new Intl.DateTimeFormat("ja-JP", { + dateStyle: "long", +}); + export const PostItem = ({ post }: Props) => { return (
    @@ -71,8 +73,8 @@ export const PostItem = ({ post }: Props) => { className="text-cax-text-muted hover:underline" to={`/posts/${post.id}`} > -
  • diff --git a/application/client/src/components/user_profile/UserProfileHeader.tsx b/application/client/src/components/user_profile/UserProfileHeader.tsx index c1c3355e19..9107d72a5c 100644 --- a/application/client/src/components/user_profile/UserProfileHeader.tsx +++ b/application/client/src/components/user_profile/UserProfileHeader.tsx @@ -1,5 +1,4 @@ import { FastAverageColor } from "fast-average-color"; -import moment from "moment"; import { ReactEventHandler, useCallback, useState } from "react"; import { FontAwesomeIcon } from "@web-speed-hackathon-2026/client/src/components/foundation/FontAwesomeIcon"; @@ -9,17 +8,24 @@ interface Props { user: Models.User; } +const jaLongDateFormatter = new Intl.DateTimeFormat("ja-JP", { + dateStyle: "long", +}); + export const UserProfileHeader = ({ user }: Props) => { const [averageColor, setAverageColor] = useState(null); // 画像の平均色を取得します /** @type {React.ReactEventHandler} */ - const handleLoadImage = useCallback>((ev) => { - const fac = new FastAverageColor(); - const { rgb } = fac.getColor(ev.currentTarget, { mode: "precision" }); - setAverageColor(rgb); - fac.destroy(); - }, []); + const handleLoadImage = useCallback>( + (ev) => { + const fac = new FastAverageColor(); + const { rgb } = fac.getColor(ev.currentTarget, { mode: "precision" }); + setAverageColor(rgb); + fac.destroy(); + }, + [], + ); return (
    @@ -43,8 +49,8 @@ export const UserProfileHeader = ({ user }: Props) => { - diff --git a/application/client/src/containers/AppContainer.tsx b/application/client/src/containers/AppContainer.tsx index 08f2147b43..cdd0c73cbe 100644 --- a/application/client/src/containers/AppContainer.tsx +++ b/application/client/src/containers/AppContainer.tsx @@ -1,10 +1,18 @@ -import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { + lazy, + Suspense, + useCallback, + useEffect, + useId, + useRef, + useState, +} from "react"; import { Helmet, HelmetProvider } from "react-helmet"; import { Route, Routes, useLocation, useNavigate } from "react-router"; import { AppPage } from "@web-speed-hackathon-2026/client/src/components/application/AppPage"; import { AuthModalContainer } from "@web-speed-hackathon-2026/client/src/containers/AuthModalContainer"; -import { CrokContainer } from "@web-speed-hackathon-2026/client/src/containers/CrokContainer"; +// import { CrokContainer } from "@web-speed-hackathon-2026/client/src/containers/CrokContainer"; import { DirectMessageContainer } from "@web-speed-hackathon-2026/client/src/containers/DirectMessageContainer"; import { DirectMessageListContainer } from "@web-speed-hackathon-2026/client/src/containers/DirectMessageListContainer"; import { NewPostModalContainer } from "@web-speed-hackathon-2026/client/src/containers/NewPostModalContainer"; @@ -19,6 +27,12 @@ import { sendJSON, } from "@web-speed-hackathon-2026/client/src/utils/fetchers"; +const CrokContainer = lazy(() => + import("@web-speed-hackathon-2026/client/src/containers/CrokContainer").then( + (m) => ({ default: m.CrokContainer }), + ), +); + export const AppContainer = () => { const { pathname } = useLocation(); const navigate = useNavigate(); @@ -28,8 +42,6 @@ export const AppContainer = () => { }, [pathname]); const [activeUser, setActiveUser] = useState(null); - const [isLoadingActiveUser, setIsLoadingActiveUser] = useState(true); - const activeUserRequestIdRef = useRef(0); const reloadActiveUser = useCallback(async () => { @@ -48,10 +60,6 @@ export const AppContainer = () => { } setActiveUser(null); return null; - } finally { - if (requestId === activeUserRequestIdRef.current) { - setIsLoadingActiveUser(false); - } } }, []); @@ -68,16 +76,6 @@ export const AppContainer = () => { const authModalId = useId(); const newPostModalId = useId(); - if (isLoadingActiveUser) { - return ( - - - 読込中 - CaX - - - ); - } - return ( { newPostModalId={newPostModalId} onLogout={handleLogout} > - - } path="/" /> - - } - path="/dm" - /> - - } - path="/dm/:conversationId" - /> - } path="/search" /> - } path="/users/:username" /> - } path="/posts/:postId" /> - } path="/terms" /> - - } - path="/crok" - /> - } path="*" /> - + + + } path="/" /> + + } + path="/dm" + /> + + } + path="/dm/:conversationId" + /> + } path="/search" /> + } path="/users/:username" /> + } path="/posts/:postId" /> + } path="/terms" /> + + } + path="/crok" + /> + } path="*" /> + + diff --git a/application/client/src/index.css b/application/client/src/index.css index 8612ebcdd2..9222352f47 100644 --- a/application/client/src/index.css +++ b/application/client/src/index.css @@ -5,7 +5,7 @@ @font-face { /* Source Han Serif JP Regular の Y 軸を 1/1.43 に縮小した改変フォント */ font-family: "Rei no Are Mincho"; - font-display: block; + font-display: swap; src: url(/fonts/ReiNoAreMincho-Regular.otf) format("opentype"); font-weight: normal; } @@ -13,7 +13,7 @@ @font-face { /* Source Han Serif JP Heavy の Y 軸を 1/1.43 に縮小した改変フォント */ font-family: "Rei no Are Mincho"; - font-display: block; + font-display: swap; src: url(/fonts/ReiNoAreMincho-Heavy.otf) format("opentype"); font-weight: bold; } diff --git a/application/client/src/index.html b/application/client/src/index.html index 3d949e7473..d03ebe3bef 100644 --- a/application/client/src/index.html +++ b/application/client/src/index.html @@ -4,8 +4,15 @@ CaX - + +