diff --git a/application/client/babel.config.js b/application/client/babel.config.js
index c3c574591a..990563d284 100644
--- a/application/client/babel.config.js
+++ b/application/client/babel.config.js
@@ -4,16 +4,16 @@ module.exports = {
[
"@babel/preset-env",
{
- targets: "ie 11",
+ targets: "> 0.5%, last 2 versions, not dead",
corejs: "3",
- modules: "commonjs",
+ 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..7c9813e70d 100644
--- a/application/client/package.json
+++ b/application/client/package.json
@@ -5,7 +5,8 @@
"license": "MPL-2.0",
"author": "CyberAgent, Inc.",
"scripts": {
- "build": "NODE_ENV=development webpack",
+ "build": "vite build",
+ "dev": "vite",
"typecheck": "tsc"
},
"dependencies": {
@@ -31,7 +32,6 @@
"kuromoji": "0.1.2",
"langs": "2.0.0",
"lodash": "4.17.21",
- "moment": "2.30.1",
"negaposi-analyzer-ja": "1.0.1",
"normalize.css": "8.0.1",
"omggif": "1.0.10",
@@ -53,10 +53,7 @@
"tiny-invariant": "1.3.3"
},
"devDependencies": {
- "@babel/core": "7.28.4",
- "@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",
@@ -73,20 +70,17 @@
"@types/react-dom": "19.2.1",
"@types/react-syntax-highlighter": "15.5.13",
"@types/redux-form": "^8.3.11",
- "babel-loader": "10.0.0",
- "copy-webpack-plugin": "13.0.1",
- "css-loader": "7.1.2",
- "html-webpack-plugin": "5.6.4",
- "mini-css-extract-plugin": "2.9.4",
+ "@vitejs/plugin-react": "^4.5.0",
"postcss": "8.5.6",
"postcss-import": "16.1.1",
- "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",
- "webpack-dev-server": "5.2.2"
+ "vite": "^6.3.0",
+ "vite-plugin-compression2": "^1.3.4",
+ "vite-plugin-node-polyfills": "^0.22.0",
+ "vite-plugin-static-copy": "^2.3.0"
},
"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/buildinfo.ts b/application/client/src/buildinfo.ts
index 48b5dbef9b..fa5d8cd379 100644
--- a/application/client/src/buildinfo.ts
+++ b/application/client/src/buildinfo.ts
@@ -1,3 +1,6 @@
+declare const __BUILD_DATE__: string;
+declare const __COMMIT_HASH__: string;
+
declare global {
var __BUILD_INFO__: {
BUILD_DATE: string | undefined;
@@ -7,8 +10,8 @@ declare global {
/** @note 競技用サーバーで参照します。可能な限りコード内に含めてください */
window.__BUILD_INFO__ = {
- BUILD_DATE: process.env["BUILD_DATE"],
- COMMIT_HASH: process.env["COMMIT_HASH"],
+ BUILD_DATE: __BUILD_DATE__,
+ COMMIT_HASH: __COMMIT_HASH__,
};
export {};
diff --git a/application/client/src/components/direct_message/DirectMessageListPage.tsx b/application/client/src/components/direct_message/DirectMessageListPage.tsx
index 5a373e918e..270c34a65e 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,19 @@ interface Props {
newDmModalId: string;
}
+const rtf = new Intl.RelativeTimeFormat("ja", { numeric: "auto" });
+
+function formatRelativeTime(dateStr: string): string {
+ const diffMs = new Date(dateStr).getTime() - Date.now();
+ const diffSeconds = Math.round(diffMs / 1000);
+ if (Math.abs(diffSeconds) < 60) return rtf.format(diffSeconds, "second");
+ const diffMinutes = Math.round(diffSeconds / 60);
+ if (Math.abs(diffMinutes) < 60) return rtf.format(diffMinutes, "minute");
+ const diffHours = Math.round(diffMinutes / 60);
+ if (Math.abs(diffHours) < 24) return rtf.format(diffHours, "hour");
+ return rtf.format(Math.round(diffHours / 24), "day");
+}
+
export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => {
const [conversations, setConversations] =
useState | null>(null);
@@ -100,7 +112,7 @@ export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => {
className="text-cax-text-subtle text-xs"
dateTime={lastMessage.createdAt}
>
- {moment(lastMessage.createdAt).locale("ja").fromNow()}
+ {formatRelativeTime(lastMessage.createdAt)}
)}
diff --git a/application/client/src/components/direct_message/DirectMessagePage.tsx b/application/client/src/components/direct_message/DirectMessagePage.tsx
index 098c7d2894..6069fda3f7 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 timeFormatter = new Intl.DateTimeFormat("ja-JP", {
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+});
+
export const DirectMessagePage = ({
conversationError,
conversation,
@@ -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..969bfa6ee4 100644
--- a/application/client/src/components/foundation/CoveredImage.tsx
+++ b/application/client/src/components/foundation/CoveredImage.tsx
@@ -1,70 +1,47 @@
-import classNames from "classnames";
-import sizeOf from "image-size";
-import { load, ImageIFD } from "piexifjs";
-import { MouseEvent, RefCallback, useCallback, useId, useMemo, useState } from "react";
+import { Buffer } from "buffer";
+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 = false }: 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 handleShowAlt = useCallback(async () => {
+ if (altLoaded) return;
+ const [data, { load, ImageIFD }] = await Promise.all([
+ fetchBinary(src),
+ import("piexifjs"),
+ ]);
+ const exif = load(Buffer.from(data).toString("binary"));
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;
+ setAlt(raw != null ? new TextDecoder().decode(Buffer.from(raw, "binary")) : "");
+ setAltLoaded(true);
+ }, [src, altLoaded]);
return (
-
+
![{alt}]()
imageRatio,
- "w-full h-auto": containerRatio <= imageRatio,
- },
- )}
- src={blobUrl}
+ className="h-full w-full object-cover"
+ src={src}
+ loading={priority ? "eager" : "lazy"}
+ fetchPriority={priority ? "high" : "auto"}
/>
diff --git a/application/client/src/components/foundation/InfiniteScroll.tsx b/application/client/src/components/foundation/InfiniteScroll.tsx
index 408f24c107..a4bede9c83 100644
--- a/application/client/src/components/foundation/InfiniteScroll.tsx
+++ b/application/client/src/components/foundation/InfiniteScroll.tsx
@@ -7,43 +7,30 @@ interface Props {
}
export const InfiniteScroll = ({ children, fetchMore, items }: Props) => {
+ const sentinelRef = useRef
(null);
const latestItem = items[items.length - 1];
- const prevReachedRef = useRef(false);
-
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 sentinel = sentinelRef.current;
+ if (sentinel == null) return;
- // 画面最下部にスクロールしたタイミングで、登録したハンドラを呼び出す
- if (hasReached && !prevReachedRef.current) {
- // アイテムがないときは追加で読み込まない
- if (latestItem !== undefined) {
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0]?.isIntersecting && latestItem !== undefined) {
fetchMore();
}
- }
-
- prevReachedRef.current = hasReached;
- };
-
- // 最初は実行されないので手動で呼び出す
- prevReachedRef.current = false;
- handler();
+ },
+ { rootMargin: "200px" },
+ );
- document.addEventListener("wheel", handler, { passive: false });
- document.addEventListener("touchmove", handler, { passive: false });
- document.addEventListener("resize", handler, { passive: false });
- document.addEventListener("scroll", handler, { passive: false });
- return () => {
- document.removeEventListener("wheel", handler);
- document.removeEventListener("touchmove", handler);
- document.removeEventListener("resize", handler);
- document.removeEventListener("scroll", handler);
- };
+ observer.observe(sentinel);
+ return () => observer.disconnect();
}, [latestItem, fetchMore]);
- return <>{children}>;
+ return (
+ <>
+ {children}
+
+ >
+ );
};
diff --git a/application/client/src/components/foundation/PausableMovie.tsx b/application/client/src/components/foundation/PausableMovie.tsx
index 98b0df55b0..dc147c1eda 100644
--- a/application/client/src/components/foundation/PausableMovie.tsx
+++ b/application/client/src/components/foundation/PausableMovie.tsx
@@ -1,7 +1,5 @@
import classNames from "classnames";
-import { Animator, Decoder } from "gifler";
-import { GifReader } from "omggif";
-import { RefCallback, useCallback, useRef, useState } from "react";
+import { RefCallback, useCallback, useEffect, 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";
@@ -16,9 +14,31 @@ interface Props {
* クリックすると再生・一時停止を切り替えます。
*/
export const PausableMovie = ({ src }: Props) => {
- const { data, isLoading } = useFetch(src, fetchBinary);
+ const containerRef = useRef(null);
+ const [isVisible, setIsVisible] = useState(false);
- const animatorRef = useRef(null);
+ // 画面内に入ったときのみ GIF を取得・デコードする
+ useEffect(() => {
+ const el = containerRef.current;
+ if (el == null) return;
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0]?.isIntersecting) {
+ setIsVisible(true);
+ observer.disconnect();
+ }
+ },
+ { rootMargin: "200px" },
+ );
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, []);
+
+ const { data, isLoading } = useFetch(isVisible ? src : null, fetchBinary);
+
+ // Animator 型は gifler の型だが、動的 import するため any で保持
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const animatorRef = useRef(null);
const canvasCallbackRef = useCallback>(
(el) => {
animatorRef.current?.stop();
@@ -27,24 +47,27 @@ export const PausableMovie = ({ src }: Props) => {
return;
}
- // GIF を解析する
- const reader = new GifReader(new Uint8Array(data));
- const frames = Decoder.decodeFramesSync(reader);
- const animator = new Animator(reader, frames);
+ // gifler / omggif を動的 import してデコード
+ void Promise.all([import("gifler"), import("omggif")]).then(
+ ([{ Animator, Decoder }, { GifReader }]) => {
+ 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]!);
+ 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();
- }
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
+ setIsPlaying(false);
+ animator.stop();
+ } else {
+ setIsPlaying(true);
+ animator.start();
+ }
- animatorRef.current = animator;
+ animatorRef.current = animator;
+ },
+ );
},
[data],
);
@@ -61,30 +84,30 @@ export const PausableMovie = ({ src }: Props) => {
});
}, []);
- if (isLoading || data === null) {
- return null;
- }
-
return (
-
-
-
+
+
+ {!isLoading && data !== null ? (
+
+ ) : null}
+
+
);
};
diff --git a/application/client/src/components/foundation/SoundPlayer.tsx b/application/client/src/components/foundation/SoundPlayer.tsx
index 2fb4784189..ce6dc80244 100644
--- a/application/client/src/components/foundation/SoundPlayer.tsx
+++ b/application/client/src/components/foundation/SoundPlayer.tsx
@@ -1,10 +1,8 @@
-import { ReactEventHandler, useCallback, useMemo, useRef, useState } from "react";
+import { ReactEventHandler, useCallback, useEffect, 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 { SoundWaveSVG } from "@web-speed-hackathon-2026/client/src/components/foundation/SoundWaveSVG";
-import { useFetch } from "@web-speed-hackathon-2026/client/src/hooks/use_fetch";
-import { fetchBinary } from "@web-speed-hackathon-2026/client/src/utils/fetchers";
import { getSoundPath } from "@web-speed-hackathon-2026/client/src/utils/get_path";
interface Props {
@@ -12,11 +10,35 @@ interface Props {
}
export const SoundPlayer = ({ sound }: Props) => {
- const { data, isLoading } = useFetch(getSoundPath(sound.id), fetchBinary);
+ const src = getSoundPath(sound.id);
+ const containerRef = useRef(null);
+ const [isVisible, setIsVisible] = useState(false);
- const blobUrl = useMemo(() => {
- return data !== null ? URL.createObjectURL(new Blob([data])) : null;
- }, [data]);
+ // ビューポートに入ったときだけ波形バイナリをフェッチする
+ useEffect(() => {
+ const el = containerRef.current;
+ if (el == null) return;
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0]?.isIntersecting) {
+ setIsVisible(true);
+ observer.disconnect();
+ }
+ },
+ { rootMargin: "200px" },
+ );
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, []);
+
+ const [waveData, setWaveData] = useState(null);
+ useEffect(() => {
+ if (!isVisible) return;
+ fetch(src)
+ .then((r) => r.arrayBuffer())
+ .then(setWaveData)
+ .catch(() => {});
+ }, [src, isVisible]);
const [currentTimeRatio, setCurrentTimeRatio] = useState(0);
const handleTimeUpdate = useCallback>((ev) => {
@@ -37,13 +59,9 @@ export const SoundPlayer = ({ sound }: Props) => {
});
}, []);
- if (isLoading || data === null || blobUrl === null) {
- return null;
- }
-
return (
-
-
+
+
);
};
diff --git a/application/client/src/components/post/TranslatableText.tsx b/application/client/src/components/post/TranslatableText.tsx
index d772529d92..b099174f0a 100644
--- a/application/client/src/components/post/TranslatableText.tsx
+++ b/application/client/src/components/post/TranslatableText.tsx
@@ -1,7 +1,5 @@
import { useCallback, useState } from "react";
-import { createTranslator } from "@web-speed-hackathon-2026/client/src/utils/create_translator";
-
type State =
| { type: "idle"; text: string }
| { type: "loading" }
@@ -20,6 +18,9 @@ export const TranslatableText = ({ text }: Props) => {
(async () => {
updateState({ type: "loading" });
try {
+ const { createTranslator } = await import(
+ "@web-speed-hackathon-2026/client/src/utils/create_translator"
+ );
using translator = await createTranslator({
sourceLanguage: "ja",
targetLanguage: "en",
diff --git a/application/client/src/components/timeline/Timeline.tsx b/application/client/src/components/timeline/Timeline.tsx
index 752a4d973b..76f41ce491 100644
--- a/application/client/src/components/timeline/Timeline.tsx
+++ b/application/client/src/components/timeline/Timeline.tsx
@@ -7,8 +7,8 @@ interface Props {
export const Timeline = ({ timeline }: Props) => {
return (
- {timeline.map((post) => {
- return ;
+ {timeline.map((post, idx) => {
+ return ;
})}
);
diff --git a/application/client/src/components/timeline/TimelineItem.tsx b/application/client/src/components/timeline/TimelineItem.tsx
index 21b88980f8..f3a91f49d1 100644
--- a/application/client/src/components/timeline/TimelineItem.tsx
+++ b/application/client/src/components/timeline/TimelineItem.tsx
@@ -1,5 +1,6 @@
-import moment from "moment";
import { MouseEventHandler, useCallback } from "react";
+
+const dateFormatter = new Intl.DateTimeFormat("ja-JP", { dateStyle: "long" });
import { Link, useNavigate } from "react-router";
import { ImageArea } from "@web-speed-hackathon-2026/client/src/components/post/ImageArea";
@@ -28,9 +29,10 @@ const isClickedAnchorOrButton = (target: EventTarget | null, currentTarget: Elem
*/
interface Props {
post: Models.Post;
+ priority?: boolean;
}
-export const TimelineItem = ({ post }: Props) => {
+export const TimelineItem = ({ post, priority = false }: Props) => {
const navigate = useNavigate();
/**
@@ -57,6 +59,8 @@ export const TimelineItem = ({ post }: Props) => {
@@ -76,8 +80,8 @@ export const TimelineItem = ({ post }: Props) => {
-
-
{post.images?.length > 0 ? (
-
+
) : null}
{post.movie ? (
diff --git a/application/client/src/components/user_profile/UserProfileHeader.tsx b/application/client/src/components/user_profile/UserProfileHeader.tsx
index c1c3355e19..f4fbba7528 100644
--- a/application/client/src/components/user_profile/UserProfileHeader.tsx
+++ b/application/client/src/components/user_profile/UserProfileHeader.tsx
@@ -1,10 +1,11 @@
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";
import { getProfileImagePath } from "@web-speed-hackathon-2026/client/src/utils/get_path";
+const dateFormatter = new Intl.DateTimeFormat("ja-JP", { dateStyle: "long" });
+
interface Props {
user: Models.User;
}
@@ -43,8 +44,8 @@ export const UserProfileHeader = ({ user }: Props) => {
-
- {moment(user.createdAt).locale("ja").format("LL")}
+
+ {dateFormatter.format(new Date(user.createdAt))}
からサービスを利用しています
diff --git a/application/client/src/containers/AppContainer.tsx b/application/client/src/containers/AppContainer.tsx
index d66858a949..6eef6b85fe 100644
--- a/application/client/src/containers/AppContainer.tsx
+++ b/application/client/src/containers/AppContainer.tsx
@@ -1,21 +1,58 @@
-import { useCallback, useEffect, useId, useState } from "react";
+import { Suspense, lazy, 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";
+const CrokContainer = lazy(() =>
+ import("@web-speed-hackathon-2026/client/src/containers/CrokContainer").then((m) => ({
+ default: m.CrokContainer,
+ })),
+);
+const DirectMessageContainer = lazy(() =>
+ import("@web-speed-hackathon-2026/client/src/containers/DirectMessageContainer").then((m) => ({
+ default: m.DirectMessageContainer,
+ })),
+);
+const DirectMessageListContainer = lazy(() =>
+ import("@web-speed-hackathon-2026/client/src/containers/DirectMessageListContainer").then(
+ (m) => ({ default: m.DirectMessageListContainer }),
+ ),
+);
+const NewPostModalContainer = lazy(() =>
+ import("@web-speed-hackathon-2026/client/src/containers/NewPostModalContainer").then((m) => ({
+ default: m.NewPostModalContainer,
+ })),
+);
+const NotFoundContainer = lazy(() =>
+ import("@web-speed-hackathon-2026/client/src/containers/NotFoundContainer").then((m) => ({
+ default: m.NotFoundContainer,
+ })),
+);
+const PostContainer = lazy(() =>
+ import("@web-speed-hackathon-2026/client/src/containers/PostContainer").then((m) => ({
+ default: m.PostContainer,
+ })),
+);
+const SearchContainer = lazy(() =>
+ import("@web-speed-hackathon-2026/client/src/containers/SearchContainer").then((m) => ({
+ default: m.SearchContainer,
+ })),
+);
+const TermContainer = lazy(() =>
+ import("@web-speed-hackathon-2026/client/src/containers/TermContainer").then((m) => ({
+ default: m.TermContainer,
+ })),
+);
+const UserProfileContainer = lazy(() =>
+ import("@web-speed-hackathon-2026/client/src/containers/UserProfileContainer").then((m) => ({
+ default: m.UserProfileContainer,
+ })),
+);
+
export const AppContainer = () => {
const { pathname } = useLocation();
const navigate = useNavigate();
@@ -24,16 +61,15 @@ export const AppContainer = () => {
}, [pathname]);
const [activeUser, setActiveUser] = useState(null);
- const [isLoadingActiveUser, setIsLoadingActiveUser] = useState(true);
useEffect(() => {
void fetchJSON("/api/v1/me")
.then((user) => {
setActiveUser(user);
})
- .finally(() => {
- setIsLoadingActiveUser(false);
+ .catch(() => {
+ // 未ログイン時は null のまま
});
- }, [setActiveUser, setIsLoadingActiveUser]);
+ }, [setActiveUser]);
const handleLogout = useCallback(async () => {
await sendJSON("/api/v1/signout", {});
setActiveUser(null);
@@ -43,16 +79,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/hooks/use_fetch.ts b/application/client/src/hooks/use_fetch.ts
index 36039ed35d..cf6c7243c5 100644
--- a/application/client/src/hooks/use_fetch.ts
+++ b/application/client/src/hooks/use_fetch.ts
@@ -7,16 +7,20 @@ interface ReturnValues {
}
export function useFetch(
- apiPath: string,
+ apiPath: string | null,
fetcher: (apiPath: string) => Promise,
): ReturnValues {
const [result, setResult] = useState>({
data: null,
error: null,
- isLoading: true,
+ isLoading: apiPath !== null,
});
useEffect(() => {
+ if (apiPath === null) {
+ return;
+ }
+
setResult(() => ({
data: null,
error: null,
diff --git a/application/client/src/hooks/use_infinite_fetch.ts b/application/client/src/hooks/use_infinite_fetch.ts
index 394fccd9ea..8ab290eaad 100644
--- a/application/client/src/hooks/use_infinite_fetch.ts
+++ b/application/client/src/hooks/use_infinite_fetch.ts
@@ -36,11 +36,14 @@ export function useInfiniteFetch(
offset,
};
- void fetcher(apiPath).then(
- (allData) => {
+ const separator = apiPath.includes("?") ? "&" : "?";
+ const pagedPath = `${apiPath}${separator}limit=${LIMIT}&offset=${offset}`;
+
+ void fetcher(pagedPath).then(
+ (pageData) => {
setResult((cur) => ({
...cur,
- data: [...cur.data, ...allData.slice(offset, offset + LIMIT)],
+ data: [...cur.data, ...pageData],
isLoading: false,
}));
internalRef.current = {
diff --git a/application/client/src/index.css b/application/client/src/index.css
index 8612ebcdd2..73ba2de1ad 100644
--- a/application/client/src/index.css
+++ b/application/client/src/index.css
@@ -1,11 +1,12 @@
-@layer normalize, theme, base, components, utilities;
-
+@import "tailwindcss";
@import "normalize.css" layer(normalize);
+@layer normalize, theme, base, components, utilities;
+
@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 +14,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;
}
@@ -23,3 +24,167 @@
vertical-align: -0.125em;
width: 1em;
}
+
+@layer base {
+ button:not(:disabled),
+ [role="button"]:not(:disabled) {
+ cursor: pointer;
+ }
+}
+
+@theme {
+ --color-cax-canvas: var(--color-stone-100);
+ --color-cax-surface: var(--color-white);
+ --color-cax-surface-raised: var(--color-white);
+ --color-cax-surface-subtle: var(--color-stone-50);
+ --color-cax-overlay: var(--color-slate-950);
+ --color-cax-border: var(--color-stone-300);
+ --color-cax-border-strong: var(--color-stone-400);
+ --color-cax-text: var(--color-teal-950);
+ --color-cax-text-muted: var(--color-teal-700);
+ --color-cax-text-subtle: var(--color-slate-500);
+ --color-cax-brand: var(--color-teal-700);
+ --color-cax-brand-strong: var(--color-teal-800);
+ --color-cax-brand-soft: var(--color-teal-100);
+ --color-cax-accent: var(--color-orange-700);
+ --color-cax-accent-soft: var(--color-orange-100);
+ --color-cax-danger: var(--color-red-600);
+ --color-cax-danger-soft: var(--color-red-100);
+ --color-cax-highlight: var(--color-amber-200);
+ --color-cax-highlight-ink: var(--color-amber-950);
+}
+
+@utility markdown {
+ @apply text-sm wrap-anywhere;
+ line-break: strict;
+
+ /* インライン要素 */
+ :where(a) {
+ @apply text-cax-accent decoration-cax-accent underline underline-offset-2;
+ }
+ :where(strong) {
+ @apply font-bold;
+ }
+ :where(em) {
+ @apply italic;
+ }
+ :where(code):not(:where(pre > code)) {
+ @apply bg-cax-surface-subtle text-cax-text rounded px-1 py-0.5 font-mono;
+ }
+ :where(del) {
+ @apply decoration-cax-text-subtle line-through;
+ }
+
+ /* ブロック要素 */
+ :where(p) {
+ @apply text-cax-text my-6;
+ }
+ :where(blockquote) {
+ @apply border-cax-border text-cax-text-muted my-6 border-l-4 pl-4;
+ }
+ :where(hr) {
+ @apply border-cax-border my-10 border-t;
+ }
+
+ /* リスト */
+ :where(ol) {
+ @apply my-6 list-decimal pl-6;
+ }
+ :where(ul) {
+ @apply my-6 list-disc pl-6;
+ }
+ :where(li) {
+ @apply my-2;
+ }
+ :where(ol > li, ul > li)::marker {
+ @apply text-cax-text-muted;
+ }
+ :where(ol ol, ul ul, ol ul, ul ol) {
+ @apply my-2;
+ }
+
+ /* テーブル */
+ :where(table) {
+ @apply text-cax-text my-6 w-full table-auto text-sm;
+ }
+ :where(thead) {
+ @apply border-cax-border border-b;
+ }
+ :where(thead th) {
+ @apply px-2 pb-1.5 font-bold;
+ }
+ :where(tbody tr) {
+ @apply border-cax-border border-b;
+ }
+ :where(tbody tr:last-child) {
+ @apply border-b-0;
+ }
+ :where(tbody td) {
+ @apply align-baseline;
+ }
+ :where(tfoot) {
+ @apply border-cax-border border-t;
+ }
+ :where(tfoot td) {
+ @apply align-top;
+ }
+ :where(th, td) {
+ @apply text-left;
+ }
+ :where(tbody td, tfoot td) {
+ @apply px-2 py-1.5;
+ }
+ :where(tbody tr:last-child td, tfoot tr:last-child td) {
+ @apply pb-0;
+ }
+
+ /* 見出し */
+ :where(h1, h2, h3, h4, h5, h6) {
+ @apply text-cax-text text-pretty;
+ }
+ :where(h1, h2, h3, h4) {
+ @apply font-bold;
+ }
+ :where(h1, h2) {
+ @apply mt-10 mb-8 text-2xl;
+ }
+ :where(h3) {
+ @apply mt-8 mb-6 text-xl;
+ }
+ :where(h4) {
+ @apply mt-6 mb-4 text-lg;
+ }
+ :where(h5) {
+ @apply border-cax-border mt-6 mb-4 border-b py-0.5 pl-2 font-bold;
+ }
+ :where(h6) {
+ @apply border-cax-border mt-6 mb-4 border-b py-0.5 pl-2;
+ }
+ :where(h1 + *, h2 + *, h3 + *, h4 + *, h5 + *, h6 + *) {
+ @apply mt-0;
+ }
+
+ /* 注釈 */
+ :where(.footnotes) {
+ @apply border-cax-border mt-8 border-t;
+ }
+ :where(.footnotes h2) {
+ @apply sr-only;
+ }
+ :where(.footnotes ol) {
+ @apply mt-8 mb-0 text-sm;
+ }
+ :where(.footnotes ol li p) {
+ @apply my-0;
+ }
+
+ /* 最初の要素はマージンを0にする */
+ & > *:first-child {
+ @apply mt-0;
+ }
+
+ /* 最後の要素はマージンを0にする */
+ & > *:last-child {
+ @apply mb-0;
+ }
+}
diff --git a/application/client/src/index.html b/application/client/src/index.html
index 3d949e7473..2e046689c4 100644
--- a/application/client/src/index.html
+++ b/application/client/src/index.html
@@ -4,176 +4,9 @@
CaX
-
-
-
-
+