diff --git a/application/client/babel.config.js b/application/client/babel.config.js
index c3c574591a..de8c6128e5 100644
--- a/application/client/babel.config.js
+++ b/application/client/babel.config.js
@@ -4,16 +4,15 @@ module.exports = {
[
"@babel/preset-env",
{
- targets: "ie 11",
- corejs: "3",
- modules: "commonjs",
+ targets: "chrome >= 120",
+ modules: false,
useBuiltIns: false,
},
],
[
"@babel/preset-react",
{
- development: true,
+ development: false,
runtime: "automatic",
},
],
diff --git a/application/client/package.json b/application/client/package.json
index 9f8e80a6a8..a899353261 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": "NODE_ENV=production webpack",
"typecheck": "tsc"
},
"dependencies": {
@@ -57,6 +57,7 @@
"@babel/preset-env": "7.28.3",
"@babel/preset-react": "7.27.1",
"@babel/preset-typescript": "7.27.1",
+ "@tailwindcss/postcss": "4.2.2",
"@tsconfig/strictest": "2.0.8",
"@types/bluebird": "3.5.42",
"@types/common-tags": "1.8.4",
@@ -83,6 +84,7 @@
"postcss-loader": "8.2.0",
"postcss-preset-env": "10.4.0",
"react-markdown": "10.1.0",
+ "tailwindcss": "4.2.2",
"typescript": "5.9.3",
"webpack": "5.102.1",
"webpack-cli": "6.0.1",
diff --git a/application/client/postcss.config.js b/application/client/postcss.config.js
index d7ee920b94..1517f884ef 100644
--- a/application/client/postcss.config.js
+++ b/application/client/postcss.config.js
@@ -1,11 +1,7 @@
-const postcssImport = require("postcss-import");
-const postcssPresetEnv = require("postcss-preset-env");
+const tailwindcss = require("@tailwindcss/postcss");
module.exports = {
plugins: [
- postcssImport(),
- postcssPresetEnv({
- stage: 3,
- }),
+ tailwindcss(),
],
};
diff --git a/application/client/src/components/direct_message/DirectMessageListPage.tsx b/application/client/src/components/direct_message/DirectMessageListPage.tsx
index 5a373e918e..b315ce2f5c 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";
@@ -8,6 +7,8 @@ import { useWs } from "@web-speed-hackathon-2026/client/src/hooks/use_ws";
import { fetchJSON } from "@web-speed-hackathon-2026/client/src/utils/fetchers";
import { getProfileImagePath } from "@web-speed-hackathon-2026/client/src/utils/get_path";
+const rtf = new Intl.RelativeTimeFormat("ja", { numeric: "auto" });
+
interface Props {
activeUser: Models.User;
newDmModalId: string;
@@ -86,8 +87,10 @@ export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => {
@@ -100,7 +103,17 @@ export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => {
className="text-cax-text-subtle text-xs"
dateTime={lastMessage.createdAt}
>
- {moment(lastMessage.createdAt).locale("ja").fromNow()}
+ {(() => {
+ const diff = Date.now() - new Date(lastMessage.createdAt).getTime();
+ const sec = Math.round(diff / 1000);
+ if (sec < 60) return rtf.format(-sec, "second");
+ const min = Math.round(diff / 60000);
+ if (min < 60) return rtf.format(-min, "minute");
+ const hr = Math.round(diff / 3600000);
+ if (hr < 24) return rtf.format(-hr, "hour");
+ const day = Math.round(diff / 86400000);
+ return rtf.format(-day, "day");
+ })()}
)}
diff --git a/application/client/src/components/direct_message/DirectMessagePage.tsx b/application/client/src/components/direct_message/DirectMessagePage.tsx
index 098c7d2894..b54d9467f0 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,
@@ -15,6 +14,8 @@ import { FontAwesomeIcon } from "@web-speed-hackathon-2026/client/src/components
import { DirectMessageFormData } from "@web-speed-hackathon-2026/client/src/direct_message/types";
import { getProfileImagePath } from "@web-speed-hackathon-2026/client/src/utils/get_path";
+const timeFormatter = new Intl.DateTimeFormat("ja", { hour: "2-digit", minute: "2-digit", hour12: false });
+
interface Props {
conversationError: Error | null;
conversation: Models.DirectMessageConversation;
@@ -74,15 +75,17 @@ export const DirectMessagePage = ({
);
useEffect(() => {
- const id = setInterval(() => {
- const height = Number(window.getComputedStyle(document.body).height.replace("px", ""));
+ const scrollToBottom = () => {
+ const height = document.body.scrollHeight;
if (height !== scrollHeightRef.current) {
scrollHeightRef.current = height;
window.scrollTo(0, height);
}
- }, 1);
-
- return () => clearInterval(id);
+ };
+ scrollToBottom();
+ const observer = new MutationObserver(scrollToBottom);
+ observer.observe(document.body, { childList: true, subtree: true });
+ return () => observer.disconnect();
}, []);
if (conversationError != null) {
@@ -100,6 +103,8 @@ export const DirectMessagePage = ({
alt={peer.profileImage.alt}
className="h-12 w-12 rounded-full object-cover"
src={getProfileImagePath(peer.profileImage.id)}
+ width={48}
+ height={48}
/>
@@ -141,7 +146,7 @@ export const DirectMessagePage = ({
{isActiveUserSend && message.isRead && (
既読
diff --git a/application/client/src/components/foundation/AspectRatioBox.tsx b/application/client/src/components/foundation/AspectRatioBox.tsx
index 0ae891963c..aa1c54cbf3 100644
--- a/application/client/src/components/foundation/AspectRatioBox.tsx
+++ b/application/client/src/components/foundation/AspectRatioBox.tsx
@@ -1,4 +1,4 @@
-import { ReactNode, useEffect, useRef, useState } from "react";
+import { ReactNode } from "react";
interface Props {
aspectHeight: number;
@@ -10,28 +10,9 @@ interface Props {
* 親要素の横幅を基準にして、指定したアスペクト比のブロック要素を作ります
*/
export const AspectRatioBox = ({ aspectHeight, aspectWidth, children }: Props) => {
- const ref = useRef
(null);
- const [clientHeight, setClientHeight] = useState(0);
-
- useEffect(() => {
- // clientWidth とアスペクト比から clientHeight を計算する
- function calcStyle() {
- const clientWidth = ref.current?.clientWidth ?? 0;
- setClientHeight((clientWidth / aspectWidth) * aspectHeight);
- }
- setTimeout(() => calcStyle(), 500);
-
- // ウィンドウサイズが変わるたびに計算する
- window.addEventListener("resize", calcStyle, { passive: false });
- return () => {
- window.removeEventListener("resize", calcStyle);
- };
- }, [aspectHeight, aspectWidth]);
-
return (
-
- {/* 高さが計算できるまで render しない */}
- {clientHeight !== 0 ?
{children}
: null}
+
);
};
diff --git a/application/client/src/components/foundation/CoveredImage.tsx b/application/client/src/components/foundation/CoveredImage.tsx
index 8ad9cc1f7d..a0dcf425b3 100644
--- a/application/client/src/components/foundation/CoveredImage.tsx
+++ b/application/client/src/components/foundation/CoveredImage.tsx
@@ -1,70 +1,60 @@
-import classNames from "classnames";
-import sizeOf from "image-size";
-import { load, ImageIFD } from "piexifjs";
-import { MouseEvent, RefCallback, useCallback, useId, useMemo, useState } from "react";
+import { MouseEvent, useCallback, useId, useState } from "react";
import { Button } from "@web-speed-hackathon-2026/client/src/components/foundation/Button";
import { Modal } from "@web-speed-hackathon-2026/client/src/components/modal/Modal";
-import { useFetch } from "@web-speed-hackathon-2026/client/src/hooks/use_fetch";
import { fetchBinary } from "@web-speed-hackathon-2026/client/src/utils/fetchers";
interface Props {
src: string;
+ priority?: boolean;
}
/**
* アスペクト比を維持したまま、要素のコンテンツボックス全体を埋めるように画像を拡大縮小します
*/
-export const CoveredImage = ({ src }: Props) => {
+export const CoveredImage = ({ src, priority }: Props) => {
const dialogId = useId();
// ダイアログの背景をクリックしたときに投稿詳細ページに遷移しないようにする
const handleDialogClick = useCallback((ev: MouseEvent
) => {
ev.stopPropagation();
}, []);
- const { data, isLoading } = useFetch(src, fetchBinary);
+ const [alt, setAlt] = useState("");
+ const [altLoaded, setAltLoaded] = useState(false);
- const imageSize = useMemo(() => {
- return data != null ? sizeOf(Buffer.from(data)) : { height: 0, width: 0 };
- }, [data]);
-
- const alt = useMemo(() => {
- const exif = data != null ? load(Buffer.from(data).toString("binary")) : null;
- const raw = exif?.["0th"]?.[ImageIFD.ImageDescription];
- return raw != null ? new TextDecoder().decode(Buffer.from(raw, "binary")) : "";
- }, [data]);
-
- const blobUrl = useMemo(() => {
- return data != null ? URL.createObjectURL(new Blob([data])) : null;
- }, [data]);
-
- const [containerSize, setContainerSize] = useState({ height: 0, width: 0 });
- const callbackRef = useCallback>((el) => {
- setContainerSize({
- height: el?.clientHeight ?? 0,
- width: el?.clientWidth ?? 0,
- });
- }, []);
-
- if (isLoading || data === null || blobUrl === null) {
- return null;
- }
-
- const containerRatio = containerSize.height / containerSize.width;
- const imageRatio = imageSize?.height / imageSize?.width;
+ // ALT テキストを EXIF からオンデマンドで取得(ボタンクリック時のみ)
+ const loadAlt = useCallback(async () => {
+ if (altLoaded) return;
+ setAltLoaded(true);
+ try {
+ const data = await fetchBinary(src);
+ const { load, ImageIFD } = await import("piexifjs");
+ const arr = new Uint8Array(data);
+ let binary = "";
+ for (let i = 0; i < arr.length; i++) {
+ binary += String.fromCharCode(arr[i]!);
+ }
+ const exif = load(binary);
+ const raw = exif?.["0th"]?.[ImageIFD.ImageDescription];
+ if (raw != null) {
+ const decoded = new TextDecoder().decode(
+ Uint8Array.from(raw as string, (c: string) => c.charCodeAt(0)),
+ );
+ setAlt(decoded);
+ }
+ } catch {
+ // EXIF 読み取り失敗は無視
+ }
+ }, [src, altLoaded]);
return (
-
+
![{alt}]()
imageRatio,
- "w-full h-auto": containerRatio <= imageRatio,
- },
- )}
- src={blobUrl}
+ className="h-full w-full object-cover"
+ src={src}
+ loading={priority ? undefined : "lazy"}
+ {...(priority ? { fetchPriority: "high" as const } : {})}
/>
diff --git a/application/client/src/components/foundation/InfiniteScroll.tsx b/application/client/src/components/foundation/InfiniteScroll.tsx
index 408f24c107..0221fde640 100644
--- a/application/client/src/components/foundation/InfiniteScroll.tsx
+++ b/application/client/src/components/foundation/InfiniteScroll.tsx
@@ -13,14 +13,9 @@ export const InfiniteScroll = ({ children, fetchMore, items }: Props) => {
useEffect(() => {
const handler = () => {
- // 念の為 2の18乗 回、最下部かどうかを確認する
- const hasReached = Array.from(Array(2 ** 18), () => {
- return window.innerHeight + Math.ceil(window.scrollY) >= document.body.offsetHeight;
- }).every(Boolean);
+ const hasReached = window.innerHeight + Math.ceil(window.scrollY) >= document.body.offsetHeight;
- // 画面最下部にスクロールしたタイミングで、登録したハンドラを呼び出す
if (hasReached && !prevReachedRef.current) {
- // アイテムがないときは追加で読み込まない
if (latestItem !== undefined) {
fetchMore();
}
@@ -29,19 +24,14 @@ export const InfiniteScroll = ({ children, fetchMore, items }: Props) => {
prevReachedRef.current = hasReached;
};
- // 最初は実行されないので手動で呼び出す
prevReachedRef.current = false;
handler();
- document.addEventListener("wheel", handler, { passive: false });
- document.addEventListener("touchmove", handler, { passive: false });
- document.addEventListener("resize", handler, { passive: false });
- document.addEventListener("scroll", handler, { passive: false });
+ document.addEventListener("scroll", handler, { passive: true });
+ window.addEventListener("resize", handler, { passive: true });
return () => {
- document.removeEventListener("wheel", handler);
- document.removeEventListener("touchmove", handler);
- document.removeEventListener("resize", handler);
document.removeEventListener("scroll", handler);
+ window.removeEventListener("resize", handler);
};
}, [latestItem, fetchMore]);
diff --git a/application/client/src/components/foundation/PausableMovie.tsx b/application/client/src/components/foundation/PausableMovie.tsx
index 98b0df55b0..bcb6d57d7e 100644
--- a/application/client/src/components/foundation/PausableMovie.tsx
+++ b/application/client/src/components/foundation/PausableMovie.tsx
@@ -1,12 +1,8 @@
import classNames from "classnames";
-import { Animator, Decoder } from "gifler";
-import { GifReader } from "omggif";
-import { RefCallback, useCallback, useRef, useState } from "react";
+import { useCallback, useRef, useState } from "react";
import { AspectRatioBox } from "@web-speed-hackathon-2026/client/src/components/foundation/AspectRatioBox";
import { FontAwesomeIcon } from "@web-speed-hackathon-2026/client/src/components/foundation/FontAwesomeIcon";
-import { useFetch } from "@web-speed-hackathon-2026/client/src/hooks/use_fetch";
-import { fetchBinary } from "@web-speed-hackathon-2026/client/src/utils/fetchers";
interface Props {
src: string;
@@ -14,57 +10,32 @@ interface Props {
/**
* クリックすると再生・一時停止を切り替えます。
+ * ブラウザネイティブの GIF 表示を利用(JS デコード不要で TBT 改善)
*/
export const PausableMovie = ({ src }: Props) => {
- const { data, isLoading } = useFetch(src, fetchBinary);
-
- const animatorRef = useRef
(null);
- const canvasCallbackRef = useCallback>(
- (el) => {
- animatorRef.current?.stop();
-
- if (el === null || data === null) {
- return;
- }
-
- // GIF を解析する
- const reader = new GifReader(new Uint8Array(data));
- const frames = Decoder.decodeFramesSync(reader);
- const animator = new Animator(reader, frames);
-
- animator.animateInCanvas(el);
- animator.onFrame(frames[0]!);
-
- // 視覚効果 off のとき GIF を自動再生しない
- if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
- setIsPlaying(false);
- animator.stop();
- } else {
- setIsPlaying(true);
- animator.start();
- }
-
- animatorRef.current = animator;
- },
- [data],
- );
-
+ const imgRef = useRef(null);
+ const canvasRef = useRef(null);
const [isPlaying, setIsPlaying] = useState(true);
+
const handleClick = useCallback(() => {
- setIsPlaying((isPlaying) => {
- if (isPlaying) {
- animatorRef.current?.stop();
- } else {
- animatorRef.current?.start();
+ setIsPlaying((prev) => {
+ if (prev) {
+ // 一時停止: 現在のフレームをキャンバスにキャプチャして表示
+ const img = imgRef.current;
+ const canvas = canvasRef.current;
+ if (img && canvas) {
+ canvas.width = img.naturalWidth;
+ canvas.height = img.naturalHeight;
+ const ctx = canvas.getContext("2d");
+ if (ctx) {
+ ctx.drawImage(img, 0, 0);
+ }
+ }
}
- return !isPlaying;
+ return !prev;
});
}, []);
- if (isLoading || data === null) {
- return null;
- }
-
return (