diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000000..81776f3a58
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,76 @@
+# Web Speed Hackathon 2026
+
+## プロジェクト概要
+
+CyberAgent主催のWebパフォーマンス改善競技。架空SNS「CaX」のLighthouseスコアを改善する。
+
+- **競技期間**: 2026/03/20 10:30 〜 03/21 18:30 JST
+- **リーダーボード**: https://web-speed-hackathon-scoring-board-2026.fly.dev/
+
+## 採点基準(合計1150点)
+
+### ページの表示(900点)
+9ページ × 100点(FCP / SI / LCP / TBT / CLS)
+
+### ページの操作(250点)
+5シナリオ × 50点(TBT / INP)
+
+> **注意**: 「ページの表示」300点未満の場合、操作スコアが0点になる
+
+## 主要コマンド
+
+```bash
+mise trust && mise install # 初期セットアップ
+pnpm install # 依存インストール
+pnpm run build # ビルド
+pnpm run start # サーバー起動 (localhost:3000)
+pnpm run test # VRT実行
+pnpm run test:update # スクリーンショット更新
+```
+
+採点ツール(`/scoring-tool` ディレクトリから):
+
+```bash
+pnpm start --applicationUrl
+```
+
+## ディレクトリ構成
+
+```
+/application/workspaces/server # サーバー実装
+/application/workspaces/client # クライアント実装
+/application/workspaces/e2e # E2EテストとVRT
+/scoring-tool # ローカル採点ツール
+/docs # ルール・テストケース
+```
+
+## レギュレーション
+
+### 禁止事項(違反=失格)
+- `fly.toml` の変更
+- VRTと手動テスト項目の機能落ち
+- 初期シードデータのIDを変えること
+- 競技終了後のデプロイ更新
+- `GET /api/v1/crok` のSSEプロトコル変更
+
+### 必須事項
+- `POST /api/v1/initialize` でDB初期化が機能すること
+- 競技終了まで本番URLにアクセス可能であること
+
+### 自由なこと
+- コード・ファイルの変更すべて
+- APIレスポンス内容の変更(追加・削除可)
+- 外部SaaSの利用(有料費用は自己負担)
+
+## VRT・手動テスト
+
+- Playwrightを使ったVRT(スクリーンショット比較)
+- `/docs/test_cases.md` の手動テスト項目を遵守
+- 主要機能: タイムライン、投稿詳細、DM、Crok(AIチャット)、検索、認証
+
+## 技術スタック
+
+- **Node.js**: 24.14.0(mise管理)
+- **pnpm**: 10.32.1
+- **デプロイ**: fly.io(GitHub Actions経由)
+- **テスト**: Playwright + Lighthouse 12.8.2
diff --git a/README.md b/README.md
index 854d089d1d..48464d198b 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
今回のテーマは、架空の SNS サイト「CaX」です。
レギュレーションを守った上で、CaX のパフォーマンスを改善してください。
-## 開催日程
+## 開催日程
- 開催日程 | 2026/03/20 10:30 JST - 2026/03/21 18:30 JST
- 募集要項 | https://cyberagent.connpass.com/event/371488/
diff --git a/application/client/babel.config.js b/application/client/babel.config.js
index c3c574591a..f040fa1667 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",
+ targets: "Chrome 133",
corejs: "3",
- modules: "commonjs",
- useBuiltIns: false,
+ modules: false,
+ useBuiltIns: "usage",
},
],
[
"@babel/preset-react",
{
- development: true,
runtime: "automatic",
},
],
diff --git a/application/client/package.json b/application/client/package.json
index 9f8e80a6a8..82961fceda 100644
--- a/application/client/package.json
+++ b/application/client/package.json
@@ -15,7 +15,6 @@
"@mlc-ai/web-llm": "0.2.80",
"@web-speed-hackathon-2026/client": "workspace:*",
"bayesian-bm25": "0.4.0",
- "bluebird": "3.7.2",
"buffer": "6.0.3",
"classnames": "2.5.1",
"common-tags": "1.8.2",
@@ -23,20 +22,14 @@
"encoding-japanese": "2.2.0",
"fast-average-color": "9.5.0",
"gifler": "github:themadcreator/gifler#v0.3.0",
- "image-size": "2.0.2",
- "jquery": "3.7.1",
- "jquery-binarytransport": "1.0.0",
"json-repair-js": "1.0.0",
"katex": "0.16.25",
"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",
"pako": "2.1.0",
- "piexifjs": "1.0.6",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-helmet": "npm:@dr.pogodin/react-helmet@3.0.4",
@@ -57,18 +50,16 @@
"@babel/preset-env": "7.28.3",
"@babel/preset-react": "7.27.1",
"@babel/preset-typescript": "7.27.1",
+ "@tailwindcss/postcss": "4.2.1",
"@tsconfig/strictest": "2.0.8",
- "@types/bluebird": "3.5.42",
+ "tailwindcss": "4.2.1",
"@types/common-tags": "1.8.4",
"@types/encoding-japanese": "2.2.1",
- "@types/jquery": "3.5.33",
"@types/kuromoji": "0.1.3",
"@types/langs": "2.0.5",
- "@types/lodash": "4.17.20",
"@types/node": "22.18.8",
"@types/omggif": "1.0.5",
"@types/pako": "2.0.4",
- "@types/piexifjs": "1.0.0",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.1",
"@types/react-syntax-highlighter": "15.5.13",
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/crok/ChatInput.tsx b/application/client/src/components/crok/ChatInput.tsx
index 6f8c17796b..41ecf66c97 100644
--- a/application/client/src/components/crok/ChatInput.tsx
+++ b/application/client/src/components/crok/ChatInput.tsx
@@ -1,4 +1,3 @@
-import Bluebird from "bluebird";
import kuromoji, { type Tokenizer, type IpadicFeatures } from "kuromoji";
import {
useEffect,
@@ -97,8 +96,12 @@ export const ChatInput = ({ isStreaming, onSendMessage }: Props) => {
let mounted = true;
const init = async () => {
- const builder = Bluebird.promisifyAll(kuromoji.builder({ dicPath: "/dicts" }));
- const nextTokenizer = await builder.buildAsync();
+ const nextTokenizer = await new Promise>((resolve, reject) => {
+ kuromoji.builder({ dicPath: "/dicts" }).build((err, tokenizer) => {
+ if (err) reject(err);
+ else resolve(tokenizer);
+ });
+ });
if (mounted) {
setTokenizer(nextTokenizer);
}
diff --git a/application/client/src/components/direct_message/DirectMessageListPage.tsx b/application/client/src/components/direct_message/DirectMessageListPage.tsx
index 5a373e918e..6fa790cc0d 100644
--- a/application/client/src/components/direct_message/DirectMessageListPage.tsx
+++ b/application/client/src/components/direct_message/DirectMessageListPage.tsx
@@ -1,4 +1,4 @@
-import moment from "moment";
+import { fromNow } from "@web-speed-hackathon-2026/client/src/utils/date";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@web-speed-hackathon-2026/client/src/components/foundation/Button";
@@ -100,7 +100,7 @@ export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => {
className="text-cax-text-subtle text-xs"
dateTime={lastMessage.createdAt}
>
- {moment(lastMessage.createdAt).locale("ja").fromNow()}
+ {fromNow(lastMessage.createdAt)}
)}
diff --git a/application/client/src/components/direct_message/DirectMessagePage.tsx b/application/client/src/components/direct_message/DirectMessagePage.tsx
index 098c7d2894..d3719c88ae 100644
--- a/application/client/src/components/direct_message/DirectMessagePage.tsx
+++ b/application/client/src/components/direct_message/DirectMessagePage.tsx
@@ -1,5 +1,5 @@
import classNames from "classnames";
-import moment from "moment";
+import { formatTime } from "@web-speed-hackathon-2026/client/src/utils/date";
import {
ChangeEvent,
useCallback,
@@ -141,7 +141,7 @@ export const DirectMessagePage = ({
{isActiveUserSend && message.isRead && (
既読
diff --git a/application/client/src/components/foundation/CoveredImage.tsx b/application/client/src/components/foundation/CoveredImage.tsx
index 8ad9cc1f7d..eac2e02670 100644
--- a/application/client/src/components/foundation/CoveredImage.tsx
+++ b/application/client/src/components/foundation/CoveredImage.tsx
@@ -1,92 +1,57 @@
-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 } 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;
+ alt?: string;
}
/**
* アスペクト比を維持したまま、要素のコンテンツボックス全体を埋めるように画像を拡大縮小します
*/
-export const CoveredImage = ({ src }: Props) => {
+export const CoveredImage = ({ src, alt = "" }: Props) => {
const dialogId = useId();
// ダイアログの背景をクリックしたときに投稿詳細ページに遷移しないようにする
const handleDialogClick = useCallback((ev: MouseEvent
) => {
ev.stopPropagation();
}, []);
- const { data, isLoading } = useFetch(src, fetchBinary);
-
- 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;
-
return (
-
+
![{alt}]()
imageRatio,
- "w-full h-auto": containerRatio <= imageRatio,
- },
- )}
- src={blobUrl}
+ className="absolute inset-0 h-full w-full object-cover"
+ src={src}
+ loading="lazy"
+ decoding="async"
/>
-
-
-
-
-
画像の説明
-
-
{alt}
-
-
-
-
+ {alt && (
+ <>
+
+
+
+
+
画像の説明
+
+
{alt}
+
+
+
+
+ >
+ )}
);
};
diff --git a/application/client/src/components/foundation/SoundWaveSVG.tsx b/application/client/src/components/foundation/SoundWaveSVG.tsx
index d95e63164c..f66907a018 100644
--- a/application/client/src/components/foundation/SoundWaveSVG.tsx
+++ b/application/client/src/components/foundation/SoundWaveSVG.tsx
@@ -1,4 +1,3 @@
-import _ from "lodash";
import { useEffect, useRef, useState } from "react";
interface ParsedData {
@@ -6,24 +5,34 @@ interface ParsedData {
peaks: number[];
}
+function mean(arr: number[]): number {
+ return arr.reduce((a, b) => a + b, 0) / arr.length;
+}
+
+function chunk
(arr: T[], size: number): T[][] {
+ return Array.from({ length: Math.ceil(arr.length / size) }, (_, i) =>
+ arr.slice(i * size, i * size + size),
+ );
+}
+
async function calculate(data: ArrayBuffer): Promise {
const audioCtx = new AudioContext();
// 音声をデコードする
const buffer = await audioCtx.decodeAudioData(data.slice(0));
// 左の音声データの絶対値を取る
- const leftData = _.map(buffer.getChannelData(0), Math.abs);
+ const leftData = Array.from(buffer.getChannelData(0), Math.abs);
// 右の音声データの絶対値を取る
- const rightData = _.map(buffer.getChannelData(1), Math.abs);
+ const rightData = Array.from(buffer.getChannelData(1), Math.abs);
// 左右の音声データの平均を取る
- const normalized = _.map(_.zip(leftData, rightData), _.mean);
+ const normalized = leftData.map((l, i) => (l + rightData[i]) / 2);
// 100 個の chunk に分ける
- const chunks = _.chunk(normalized, Math.ceil(normalized.length / 100));
+ const chunks = chunk(normalized, Math.ceil(normalized.length / 100));
// chunk ごとに平均を取る
- const peaks = _.map(chunks, _.mean);
+ const peaks = chunks.map(mean);
// chunk の平均の中から最大値を取る
- const max = _.max(peaks) ?? 0;
+ const max = Math.max(...peaks);
return { max, peaks };
}
diff --git a/application/client/src/components/new_post_modal/NewPostModalPage.tsx b/application/client/src/components/new_post_modal/NewPostModalPage.tsx
index e337c46b74..f6cd84ff6c 100644
--- a/application/client/src/components/new_post_modal/NewPostModalPage.tsx
+++ b/application/client/src/components/new_post_modal/NewPostModalPage.tsx
@@ -1,13 +1,9 @@
-import { MagickFormat } from "@imagemagick/magick-wasm";
import { ChangeEventHandler, FormEventHandler, useCallback, useState } from "react";
import { FontAwesomeIcon } from "@web-speed-hackathon-2026/client/src/components/foundation/FontAwesomeIcon";
import { ModalErrorMessage } from "@web-speed-hackathon-2026/client/src/components/modal/ModalErrorMessage";
import { ModalSubmitButton } from "@web-speed-hackathon-2026/client/src/components/modal/ModalSubmitButton";
import { AttachFileInputButton } from "@web-speed-hackathon-2026/client/src/components/new_post_modal/AttachFileInputButton";
-import { convertImage } from "@web-speed-hackathon-2026/client/src/utils/convert_image";
-import { convertMovie } from "@web-speed-hackathon-2026/client/src/utils/convert_movie";
-import { convertSound } from "@web-speed-hackathon-2026/client/src/utils/convert_sound";
const MAX_UPLOAD_BYTES_LIMIT = 10 * 1024 * 1024;
@@ -53,13 +49,16 @@ export const NewPostModalPage = ({ id, hasError, isLoading, onResetError, onSubm
if (isValid) {
setIsConverting(true);
- Promise.all(
- files.map((file) =>
- convertImage(file, { extension: MagickFormat.Jpg }).then(
- (blob) => new File([blob], "converted.jpg", { type: "image/jpeg" }),
+ import("@web-speed-hackathon-2026/client/src/utils/convert_image")
+ .then(({ convertImage }) =>
+ Promise.all(
+ files.map((file) =>
+ convertImage(file, { extension: "JPG" }).then(
+ (blob) => new File([blob], "converted.jpg", { type: "image/jpeg" }),
+ ),
+ ),
),
- ),
- )
+ )
.then((convertedFiles) => {
setParams((params) => ({
...params,
@@ -82,16 +81,18 @@ export const NewPostModalPage = ({ id, hasError, isLoading, onResetError, onSubm
if (isValid) {
setIsConverting(true);
- convertSound(file, { extension: "mp3" }).then((converted) => {
- setParams((params) => ({
- ...params,
- images: [],
- movie: undefined,
- sound: new File([converted], "converted.mp3", { type: "audio/mpeg" }),
- }));
+ import("@web-speed-hackathon-2026/client/src/utils/convert_sound")
+ .then(({ convertSound }) => convertSound(file, { extension: "mp3" }))
+ .then((converted) => {
+ setParams((params) => ({
+ ...params,
+ images: [],
+ movie: undefined,
+ sound: new File([converted], "converted.mp3", { type: "audio/mpeg" }),
+ }));
- setIsConverting(false);
- });
+ setIsConverting(false);
+ });
}
}, []);
@@ -103,7 +104,8 @@ export const NewPostModalPage = ({ id, hasError, isLoading, onResetError, onSubm
if (isValid) {
setIsConverting(true);
- convertMovie(file, { extension: "gif", size: undefined })
+ import("@web-speed-hackathon-2026/client/src/utils/convert_movie")
+ .then(({ convertMovie }) => convertMovie(file, { extension: "gif", size: undefined }))
.then((converted) => {
setParams((params) => ({
...params,
diff --git a/application/client/src/components/post/CommentItem.tsx b/application/client/src/components/post/CommentItem.tsx
index cb5bd38bda..f6071a0edd 100644
--- a/application/client/src/components/post/CommentItem.tsx
+++ b/application/client/src/components/post/CommentItem.tsx
@@ -1,7 +1,6 @@
-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 { formatLongDate, toISOString } from "@web-speed-hackathon-2026/client/src/utils/date";
import { getProfileImagePath } from "@web-speed-hackathon-2026/client/src/utils/get_path";
interface Props {
@@ -42,8 +41,8 @@ export const CommentItem = ({ comment }: Props) => {
-
diff --git a/application/client/src/components/post/ImageArea.tsx b/application/client/src/components/post/ImageArea.tsx
index 27fe9c018c..e1a4057bdf 100644
--- a/application/client/src/components/post/ImageArea.tsx
+++ b/application/client/src/components/post/ImageArea.tsx
@@ -24,7 +24,7 @@ export const ImageArea = ({ images }: Props) => {
"row-span-2": images.length <= 2 || (images.length === 3 && idx === 0),
})}
>
-
+
);
})}
diff --git a/application/client/src/components/post/PostItem.tsx b/application/client/src/components/post/PostItem.tsx
index 5fa904c91a..fbd6ecc794 100644
--- a/application/client/src/components/post/PostItem.tsx
+++ b/application/client/src/components/post/PostItem.tsx
@@ -1,10 +1,9 @@
-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";
import { SoundArea } from "@web-speed-hackathon-2026/client/src/components/post/SoundArea";
import { TranslatableText } from "@web-speed-hackathon-2026/client/src/components/post/TranslatableText";
+import { formatLongDate, toISOString } from "@web-speed-hackathon-2026/client/src/utils/date";
import { getProfileImagePath } from "@web-speed-hackathon-2026/client/src/utils/get_path";
interface Props {
@@ -67,8 +66,8 @@ export const PostItem = ({ post }: Props) => {
) : null}
-
- {moment(post.createdAt).locale("ja").format("LL")}
+
+ {formatLongDate(post.createdAt)}
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/TimelineItem.tsx b/application/client/src/components/timeline/TimelineItem.tsx
index 21b88980f8..51bb56a6bf 100644
--- a/application/client/src/components/timeline/TimelineItem.tsx
+++ b/application/client/src/components/timeline/TimelineItem.tsx
@@ -1,4 +1,4 @@
-import moment from "moment";
+import { formatLongDate, toISOString } from "@web-speed-hackathon-2026/client/src/utils/date";
import { MouseEventHandler, useCallback } from "react";
import { Link, useNavigate } from "react-router";
@@ -76,8 +76,8 @@ export const TimelineItem = ({ post }: Props) => {
-
-
- {moment(post.createdAt).locale("ja").format("LL")}
+
+ {formatLongDate(post.createdAt)}
diff --git a/application/client/src/components/user_profile/UserProfileHeader.tsx b/application/client/src/components/user_profile/UserProfileHeader.tsx
index c1c3355e19..681a3ee114 100644
--- a/application/client/src/components/user_profile/UserProfileHeader.tsx
+++ b/application/client/src/components/user_profile/UserProfileHeader.tsx
@@ -1,5 +1,5 @@
import { FastAverageColor } from "fast-average-color";
-import moment from "moment";
+import { formatLongDate, toISOString } from "@web-speed-hackathon-2026/client/src/utils/date";
import { ReactEventHandler, useCallback, useState } from "react";
import { FontAwesomeIcon } from "@web-speed-hackathon-2026/client/src/components/foundation/FontAwesomeIcon";
@@ -43,8 +43,8 @@ export const UserProfileHeader = ({ user }: Props) => {
-
- {moment(user.createdAt).locale("ja").format("LL")}
+
+ {formatLongDate(user.createdAt)}
からサービスを利用しています
diff --git a/application/client/src/containers/AppContainer.tsx b/application/client/src/containers/AppContainer.tsx
index d66858a949..13d99fa475 100644
--- a/application/client/src/containers/AppContainer.tsx
+++ b/application/client/src/containers/AppContainer.tsx
@@ -1,20 +1,61 @@
-import { useCallback, useEffect, useId, useState } from "react";
-import { Helmet, HelmetProvider } from "react-helmet";
+import { lazy, Suspense, useCallback, useEffect, useId, useState } from "react";
+import { 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 NewPostModalContainer = lazy(() =>
+ import("@web-speed-hackathon-2026/client/src/containers/NewPostModalContainer").then((m) => ({
+ default: m.NewPostModalContainer,
+ })),
+);
+
+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 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 TimelineContainer = lazy(() =>
+ import("@web-speed-hackathon-2026/client/src/containers/TimelineContainer").then((m) => ({
+ default: m.TimelineContainer,
+ })),
+);
+const UserProfileContainer = lazy(() =>
+ import("@web-speed-hackathon-2026/client/src/containers/UserProfileContainer").then((m) => ({
+ default: m.UserProfileContainer,
+ })),
+);
export const AppContainer = () => {
const { pathname } = useLocation();
@@ -24,16 +65,11 @@ 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);
- });
- }, [setActiveUser, setIsLoadingActiveUser]);
+ void fetchJSON("/api/v1/me").then((user) => {
+ setActiveUser(user);
+ });
+ }, [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/containers/PostContainer.tsx b/application/client/src/containers/PostContainer.tsx
index c296554ead..84c6c1bc7e 100644
--- a/application/client/src/containers/PostContainer.tsx
+++ b/application/client/src/containers/PostContainer.tsx
@@ -21,9 +21,14 @@ const PostContainerContent = ({ postId }: { postId: string | undefined }) => {
if (isLoadingPost) {
return (
-
- 読込中 - CaX
-
+ <>
+
+ 読込中 - CaX
+
+
+ >
);
}
diff --git a/application/client/src/containers/UserProfileContainer.tsx b/application/client/src/containers/UserProfileContainer.tsx
index dae135e89c..e30d7ea3d2 100644
--- a/application/client/src/containers/UserProfileContainer.tsx
+++ b/application/client/src/containers/UserProfileContainer.tsx
@@ -22,9 +22,14 @@ export const UserProfileContainer = () => {
if (isLoadingUser) {
return (
-
- 読込中 - CaX
-
+ <>
+
+ 読込中 - CaX
+
+
+ >
);
}
diff --git a/application/client/src/index.css b/application/client/src/index.css
index 8612ebcdd2..c8ab32dd2e 100644
--- a/application/client/src/index.css
+++ b/application/client/src/index.css
@@ -1,21 +1,14 @@
-@layer normalize, theme, base, components, utilities;
-
+@import "tailwindcss";
@import "normalize.css" layer(normalize);
-@font-face {
- /* Source Han Serif JP Regular の Y 軸を 1/1.43 に縮小した改変フォント */
- font-family: "Rei no Are Mincho";
- font-display: block;
- src: url(/fonts/ReiNoAreMincho-Regular.otf) format("opentype");
- font-weight: normal;
-}
+@layer normalize, theme, base, components, utilities;
@font-face {
- /* Source Han Serif JP Heavy の Y 軸を 1/1.43 に縮小した改変フォント */
+ /* Optimized subset: ASCII + Hiragana + Katakana + Punctuation (35KB) */
font-family: "Rei no Are Mincho";
- font-display: block;
- src: url(/fonts/ReiNoAreMincho-Heavy.otf) format("opentype");
- font-weight: bold;
+ font-display: swap;
+ src: url(/fonts/ReiNoAreMincho-Subset.woff2) format("woff2");
+ font-weight: normal;
}
.font-awesome {
@@ -23,3 +16,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..451cd205b5 100644
--- a/application/client/src/index.html
+++ b/application/client/src/index.html
@@ -4,174 +4,8 @@
CaX
-
-
-
+
diff --git a/application/client/src/index.tsx b/application/client/src/index.tsx
index b1833b0af3..dd68f3a097 100644
--- a/application/client/src/index.tsx
+++ b/application/client/src/index.tsx
@@ -5,12 +5,10 @@ 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", () => {
- createRoot(document.getElementById("app")!).render(
-
-
-
-
- ,
- );
-});
+createRoot(document.getElementById("app")!).render(
+
+
+
+
+ ,
+);
diff --git a/application/client/src/utils/bm25_search.ts b/application/client/src/utils/bm25_search.ts
index c590d12c09..a0766f8fbe 100644
--- a/application/client/src/utils/bm25_search.ts
+++ b/application/client/src/utils/bm25_search.ts
@@ -1,6 +1,5 @@
import { BM25 } from "bayesian-bm25";
import type { Tokenizer, IpadicFeatures } from "kuromoji";
-import _ from "lodash";
const STOP_POS = new Set(["助詞", "助動詞", "記号"]);
@@ -28,15 +27,13 @@ export function filterSuggestionsBM25(
const tokenizedCandidates = candidates.map((c) => extractTokens(tokenizer.tokenize(c)));
bm25.index(tokenizedCandidates);
- const results = _.zipWith(candidates, bm25.getScores(queryTokens), (text, score) => {
- return { text, score };
- });
+ const scores = bm25.getScores(queryTokens);
+ const results = candidates.map((text, i) => ({ text, score: scores[i] }));
// スコアが高い(=類似度が高い)ものが下に来るように、上位10件を取得する
- return _(results)
+ return results
.filter((s) => s.score > 0)
- .sortBy(["score"])
+ .sort((a, b) => a.score - b.score)
.slice(-10)
- .map((s) => s.text)
- .value();
+ .map((s) => s.text);
}
diff --git a/application/client/src/utils/convert_image.ts b/application/client/src/utils/convert_image.ts
index 9fce086d9c..203fc5b456 100644
--- a/application/client/src/utils/convert_image.ts
+++ b/application/client/src/utils/convert_image.ts
@@ -1,6 +1,5 @@
import { initializeImageMagick, ImageMagick, MagickFormat } from "@imagemagick/magick-wasm";
import magickWasm from "@imagemagick/magick-wasm/magick.wasm?binary";
-import { dump, insert, ImageIFD } from "piexifjs";
interface Options {
extension: MagickFormat;
@@ -15,27 +14,8 @@ export async function convertImage(file: File, options: Options): Promise
ImageMagick.read(byteArray, (img) => {
img.format = options.extension;
- const comment = img.comment;
-
img.write((output) => {
- if (comment == null) {
- resolve(new Blob([output as Uint8Array]));
- return;
- }
-
- // ImageMagick では EXIF の ImageDescription フィールドに保存されているデータが
- // 非標準の Comment フィールドに移されてしまうため
- // piexifjs を使って ImageDescription フィールドに書き込む
- const binary = Array.from(output as Uint8Array)
- .map((b) => String.fromCharCode(b))
- .join("");
- const descriptionBinary = Array.from(new TextEncoder().encode(comment))
- .map((b) => String.fromCharCode(b))
- .join("");
- const exifStr = dump({ "0th": { [ImageIFD.ImageDescription]: descriptionBinary } });
- const outputWithExif = insert(exifStr, binary);
- const bytes = Uint8Array.from(outputWithExif.split("").map((c) => c.charCodeAt(0)));
- resolve(new Blob([bytes]));
+ resolve(new Blob([output as Uint8Array]));
});
});
});
diff --git a/application/client/src/utils/date.ts b/application/client/src/utils/date.ts
new file mode 100644
index 0000000000..15351e15f8
--- /dev/null
+++ b/application/client/src/utils/date.ts
@@ -0,0 +1,50 @@
+/**
+ * Format date to ISO string
+ */
+export function toISOString(date: string | Date): string {
+ return new Date(date).toISOString();
+}
+
+/**
+ * Format date to long format (e.g., "2026年3月20日")
+ */
+export function formatLongDate(date: string | Date): string {
+ return new Intl.DateTimeFormat("ja-JP", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ }).format(new Date(date));
+}
+
+/**
+ * Format date to time (e.g., "14:30")
+ */
+export function formatTime(date: string | Date): string {
+ return new Intl.DateTimeFormat("ja-JP", {
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ }).format(new Date(date));
+}
+
+/**
+ * Format date to relative time (e.g., "3分前", "2時間前")
+ */
+export function fromNow(date: string | Date): string {
+ const now = Date.now();
+ const then = new Date(date).getTime();
+ const diffMs = now - then;
+ const diffSec = Math.floor(diffMs / 1000);
+ const diffMin = Math.floor(diffSec / 60);
+ const diffHour = Math.floor(diffMin / 60);
+ const diffDay = Math.floor(diffHour / 24);
+ const diffMonth = Math.floor(diffDay / 30);
+ const diffYear = Math.floor(diffDay / 365);
+
+ if (diffSec < 60) return "たった今";
+ if (diffMin < 60) return `${diffMin}分前`;
+ if (diffHour < 24) return `${diffHour}時間前`;
+ if (diffDay < 30) return `${diffDay}日前`;
+ if (diffMonth < 12) return `${diffMonth}ヶ月前`;
+ return `${diffYear}年前`;
+}
diff --git a/application/client/src/utils/fetchers.ts b/application/client/src/utils/fetchers.ts
index 92a14f408f..c4eb13ed80 100644
--- a/application/client/src/utils/fetchers.ts
+++ b/application/client/src/utils/fetchers.ts
@@ -1,40 +1,33 @@
-import $ from "jquery";
import { gzip } from "pako";
export async function fetchBinary(url: string): Promise {
- const result = await $.ajax({
- async: false,
- dataType: "binary",
- method: "GET",
- responseType: "arraybuffer",
- url,
- });
- return result;
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+ return response.arrayBuffer();
}
export async function fetchJSON(url: string): Promise {
- const result = await $.ajax({
- async: false,
- dataType: "json",
- method: "GET",
- url,
- });
- return result;
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+ return response.json() as Promise;
}
export async function sendFile(url: string, file: File): Promise {
- const result = await $.ajax({
- async: false,
- data: file,
- dataType: "json",
+ const response = await fetch(url, {
+ body: file,
headers: {
"Content-Type": "application/octet-stream",
},
method: "POST",
- processData: false,
- url,
});
- return result;
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+ return response.json() as Promise;
}
export async function sendJSON(url: string, data: object): Promise {
@@ -42,17 +35,16 @@ export async function sendJSON(url: string, data: object): Promise {
const uint8Array = new TextEncoder().encode(jsonString);
const compressed = gzip(uint8Array);
- const result = await $.ajax({
- async: false,
- data: compressed,
- dataType: "json",
+ const response = await fetch(url, {
+ body: compressed,
headers: {
"Content-Encoding": "gzip",
"Content-Type": "application/json",
},
method: "POST",
- processData: false,
- url,
});
- return result;
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+ return response.json() as Promise;
}
diff --git a/application/client/src/utils/negaposi_analyzer.ts b/application/client/src/utils/negaposi_analyzer.ts
index f81ed5f4ea..998e0a79c7 100644
--- a/application/client/src/utils/negaposi_analyzer.ts
+++ b/application/client/src/utils/negaposi_analyzer.ts
@@ -1,10 +1,13 @@
-import Bluebird from "bluebird";
import kuromoji, { type Tokenizer, type IpadicFeatures } from "kuromoji";
import analyze from "negaposi-analyzer-ja";
async function getTokenizer(): Promise> {
- const builder = Bluebird.promisifyAll(kuromoji.builder({ dicPath: "/dicts" }));
- return await builder.buildAsync();
+ return new Promise((resolve, reject) => {
+ kuromoji.builder({ dicPath: "/dicts" }).build((err, tokenizer) => {
+ if (err) reject(err);
+ else resolve(tokenizer);
+ });
+ });
}
type SentimentResult = {
diff --git a/application/client/webpack.config.js b/application/client/webpack.config.js
index 9fae72647f..08db75e763 100644
--- a/application/client/webpack.config.js
+++ b/application/client/webpack.config.js
@@ -25,18 +25,15 @@ const config = {
],
static: [PUBLIC_PATH, UPLOAD_PATH],
},
- devtool: "inline-source-map",
+ devtool: false,
entry: {
main: [
- "core-js",
- "regenerator-runtime/runtime",
- "jquery-binarytransport",
path.resolve(SRC_PATH, "./index.css"),
path.resolve(SRC_PATH, "./buildinfo.ts"),
path.resolve(SRC_PATH, "./index.tsx"),
],
},
- mode: "none",
+ mode: "production",
module: {
rules: [
{
@@ -60,7 +57,6 @@ const config = {
},
output: {
chunkFilename: "scripts/chunk-[contenthash].js",
- chunkFormat: false,
filename: "scripts/[name].js",
path: DIST_PATH,
publicPath: "auto",
@@ -68,16 +64,14 @@ const config = {
},
plugins: [
new webpack.ProvidePlugin({
- $: "jquery",
AudioContext: ["standardized-audio-context", "AudioContext"],
Buffer: ["buffer", "Buffer"],
- "window.jQuery": "jquery",
}),
new webpack.EnvironmentPlugin({
BUILD_DATE: new Date().toISOString(),
// Heroku では SOURCE_VERSION 環境変数から commit hash を参照できます
COMMIT_HASH: process.env.SOURCE_VERSION || "",
- NODE_ENV: "development",
+ NODE_ENV: "production",
}),
new MiniCssExtractPlugin({
filename: "styles/[name].css",
@@ -128,14 +122,29 @@ const config = {
},
},
optimization: {
- minimize: false,
- splitChunks: false,
- concatenateModules: false,
- usedExports: false,
- providedExports: false,
- sideEffects: false,
+ minimize: true,
+ splitChunks: {
+ chunks: "all",
+ maxSize: 500000, // Split chunks larger than 500KB
+ cacheGroups: {
+ // React and related libraries in separate chunk
+ reactVendor: {
+ test: /[\\/]node_modules[\\/](react|react-dom|react-router-dom|scheduler)[\\/]/,
+ name: "react-vendor",
+ priority: 20,
+ },
+ // Default vendor chunk for other node_modules
+ defaultVendors: {
+ test: /[\\/]node_modules[\\/]/,
+ priority: 10,
+ reuseExistingChunk: true,
+ },
+ },
+ },
+ concatenateModules: true,
+ usedExports: true,
+ sideEffects: true,
},
- cache: false,
ignoreWarnings: [
{
module: /@ffmpeg/,
diff --git a/application/pnpm-lock.yaml b/application/pnpm-lock.yaml
index 510570f5c9..4f73a010ad 100644
--- a/application/pnpm-lock.yaml
+++ b/application/pnpm-lock.yaml
@@ -39,9 +39,6 @@ importers:
bayesian-bm25:
specifier: 0.4.0
version: 0.4.0
- bluebird:
- specifier: 3.7.2
- version: 3.7.2
buffer:
specifier: 6.0.3
version: 6.0.3
@@ -63,15 +60,6 @@ importers:
gifler:
specifier: github:themadcreator/gifler#v0.3.0
version: https://codeload.github.com/themadcreator/gifler/tar.gz/c3259b071c7782f85d4928a5f03d0b378ed003b5
- image-size:
- specifier: 2.0.2
- version: 2.0.2
- jquery:
- specifier: 3.7.1
- version: 3.7.1
- jquery-binarytransport:
- specifier: 1.0.0
- version: 1.0.0
json-repair-js:
specifier: 1.0.0
version: 1.0.0
@@ -84,12 +72,6 @@ importers:
langs:
specifier: 2.0.0
version: 2.0.0
- lodash:
- specifier: 4.17.21
- version: 4.17.21
- moment:
- specifier: 2.30.1
- version: 2.30.1
negaposi-analyzer-ja:
specifier: 1.0.1
version: 1.0.1
@@ -102,9 +84,6 @@ importers:
pako:
specifier: 2.1.0
version: 2.1.0
- piexifjs:
- specifier: 1.0.6
- version: 1.0.6
react:
specifier: 19.2.0
version: 19.2.0
@@ -160,30 +139,24 @@ importers:
'@babel/preset-typescript':
specifier: 7.27.1
version: 7.27.1(@babel/core@7.28.4)
+ '@tailwindcss/postcss':
+ specifier: 4.2.1
+ version: 4.2.1
'@tsconfig/strictest':
specifier: 2.0.8
version: 2.0.8
- '@types/bluebird':
- specifier: 3.5.42
- version: 3.5.42
'@types/common-tags':
specifier: 1.8.4
version: 1.8.4
'@types/encoding-japanese':
specifier: 2.2.1
version: 2.2.1
- '@types/jquery':
- specifier: 3.5.33
- version: 3.5.33
'@types/kuromoji':
specifier: 0.1.3
version: 0.1.3
'@types/langs':
specifier: 2.0.5
version: 2.0.5
- '@types/lodash':
- specifier: 4.17.20
- version: 4.17.20
'@types/node':
specifier: 22.18.8
version: 22.18.8
@@ -193,9 +166,6 @@ importers:
'@types/pako':
specifier: 2.0.4
version: 2.0.4
- '@types/piexifjs':
- specifier: 1.0.0
- version: 1.0.0
'@types/react':
specifier: 19.2.2
version: 19.2.2
@@ -238,6 +208,9 @@ importers:
react-markdown:
specifier: 10.1.0
version: 10.1.0(@types/react@19.2.2)(react@19.2.0)
+ tailwindcss:
+ specifier: 4.2.1
+ version: 4.2.1
typescript:
specifier: 5.9.3
version: 5.9.3
@@ -268,6 +241,9 @@ importers:
'@tsconfig/strictest':
specifier: 2.0.8
version: 2.0.8
+ '@types/compression':
+ specifier: ^1.8.1
+ version: 1.8.1
'@web-speed-hackathon-2026/server':
specifier: workspace:*
version: 'link:'
@@ -277,6 +253,9 @@ importers:
body-parser:
specifier: 2.2.0
version: 2.2.0
+ compression:
+ specifier: ^1.8.1
+ version: 1.8.1
connect-history-api-fallback:
specifier: 2.0.0
version: 2.0.0
@@ -350,6 +329,10 @@ importers:
packages:
+ '@alloc/quick-lru@5.2.0':
+ resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
+ engines: {node: '>=10'}
+
'@babel/code-frame@7.27.1':
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
engines: {node: '>=6.9.0'}
@@ -1608,6 +1591,98 @@ packages:
engines: {node: '>=18'}
hasBin: true
+ '@tailwindcss/node@4.2.1':
+ resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==}
+
+ '@tailwindcss/oxide-android-arm64@4.2.1':
+ resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [android]
+
+ '@tailwindcss/oxide-darwin-arm64@4.2.1':
+ resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-darwin-x64@4.2.1':
+ resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-freebsd-x64@4.2.1':
+ resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1':
+ resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==}
+ engines: {node: '>= 20'}
+ cpu: [arm]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.2.1':
+ resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.2.1':
+ resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.2.1':
+ resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@tailwindcss/oxide-linux-x64-musl@4.2.1':
+ resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@tailwindcss/oxide-wasm32-wasi@4.2.1':
+ resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==}
+ engines: {node: '>=14.0.0'}
+ cpu: [wasm32]
+ bundledDependencies:
+ - '@napi-rs/wasm-runtime'
+ - '@emnapi/core'
+ - '@emnapi/runtime'
+ - '@tybys/wasm-util'
+ - '@emnapi/wasi-threads'
+ - tslib
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.2.1':
+ resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.2.1':
+ resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [win32]
+
+ '@tailwindcss/oxide@4.2.1':
+ resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==}
+ engines: {node: '>= 20'}
+
+ '@tailwindcss/postcss@4.2.1':
+ resolution: {integrity: sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==}
+
'@tokenizer/inflate@0.4.1':
resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==}
engines: {node: '>=18'}
@@ -1625,9 +1700,6 @@ packages:
'@types/bcrypt@6.0.0':
resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==}
- '@types/bluebird@3.5.42':
- resolution: {integrity: sha512-Jhy+MWRlro6UjVi578V/4ZGNfeCOcNCp0YaFNIUGFKlImowqwb1O/22wDVk3FDGMLqxdpOV3qQHD5fPEH4hK6A==}
-
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
@@ -1637,6 +1709,9 @@ packages:
'@types/common-tags@1.8.4':
resolution: {integrity: sha512-S+1hLDJPjWNDhcGxsxEbepzaxWqURP/o+3cP4aa2w7yBXgdcmKGQtZzP8JbyfOd0m+33nh+8+kvxYE2UJtBDkg==}
+ '@types/compression@1.8.1':
+ resolution: {integrity: sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==}
+
'@types/connect-history-api-fallback@1.5.4':
resolution: {integrity: sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==}
@@ -1691,9 +1766,6 @@ packages:
'@types/http-proxy@1.17.16':
resolution: {integrity: sha512-sdWoUajOB1cd0A8cRRQ1cfyWNbmFKLAqBB89Y8x5iYyG/mkJHc0YUH8pdWBy2omi9qtCpiIgGjuwO0dQST2l5w==}
- '@types/jquery@3.5.33':
- resolution: {integrity: sha512-SeyVJXlCZpEki5F0ghuYe+L+PprQta6nRZqhONt9F13dWBtR/ftoaIbdRQ7cis7womE+X2LKhsDdDtkkDhJS6g==}
-
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@@ -1706,9 +1778,6 @@ packages:
'@types/langs@2.0.5':
resolution: {integrity: sha512-DIUKT4mkbTBxSrX6lmnQR888ObeFVVo1uNEqBH5/ddQHpnG4CA24DibpK7aO8QAcJEZUTcIx0F96TWuzVT9Z4g==}
- '@types/lodash@4.17.20':
- resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==}
-
'@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
@@ -1730,9 +1799,6 @@ packages:
'@types/pako@2.0.4':
resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==}
- '@types/piexifjs@1.0.0':
- resolution: {integrity: sha512-PPiGeCkmkZQgYjvqtjD3kp4OkbCox2vEFVuK4DaLVOIazJLAXk+/ujbizkIPH5CN4AnN9Clo5ckzUlaj3+SzCA==}
-
'@types/prismjs@1.26.5':
resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==}
@@ -1771,9 +1837,6 @@ packages:
'@types/serve-static@1.15.9':
resolution: {integrity: sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==}
- '@types/sizzle@2.3.10':
- resolution: {integrity: sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww==}
-
'@types/sockjs@0.3.36':
resolution: {integrity: sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==}
@@ -2426,6 +2489,10 @@ packages:
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
engines: {node: '>=10.13.0'}
+ enhanced-resolve@5.20.1:
+ resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
+ engines: {node: '>=10.13.0'}
+
entities@2.2.0:
resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==}
@@ -2855,11 +2922,6 @@ packages:
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
- image-size@2.0.2:
- resolution: {integrity: sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==}
- engines: {node: '>=16.x'}
- hasBin: true
-
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@@ -3019,13 +3081,6 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
- jquery-binarytransport@1.0.0:
- resolution: {integrity: sha512-vqEyfGDHgZ/VrqOpfEuU+BCOl0QAF1EeWQjpk66PEwkErmuNCD2eNFnEn767PWCQ4mtvitFmjLes7ApyGe8byw==}
- engines: {node: '>=0.4.0'}
-
- jquery@3.7.1:
- resolution: {integrity: sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==}
-
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -3075,6 +3130,80 @@ packages:
launch-editor@2.11.1:
resolution: {integrity: sha512-SEET7oNfgSaB6Ym0jufAdCeo3meJVeCaaDyzRygy0xsp2BFKCprcfHljTq4QkzTLUxEKkFK6OK4811YM2oSrRg==}
+ lightningcss-android-arm64@1.31.1:
+ resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [android]
+
+ lightningcss-darwin-arm64@1.31.1:
+ resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ lightningcss-darwin-x64@1.31.1:
+ resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ lightningcss-freebsd-x64@1.31.1:
+ resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ lightningcss-linux-arm-gnueabihf@1.31.1:
+ resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm]
+ os: [linux]
+
+ lightningcss-linux-arm64-gnu@1.31.1:
+ resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ lightningcss-linux-arm64-musl@1.31.1:
+ resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ lightningcss-linux-x64-gnu@1.31.1:
+ resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ lightningcss-linux-x64-musl@1.31.1:
+ resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ lightningcss-win32-arm64-msvc@1.31.1:
+ resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ lightningcss-win32-x64-msvc@1.31.1:
+ resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [win32]
+
+ lightningcss@1.31.1:
+ resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==}
+ engines: {node: '>= 12.0.0'}
+
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
@@ -3120,6 +3249,9 @@ packages:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'}
+ magic-string@0.30.21:
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+
make-fetch-happen@9.1.0:
resolution: {integrity: sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==}
engines: {node: '>= 10'}
@@ -3620,9 +3752,6 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
- piexifjs@1.0.6:
- resolution: {integrity: sha512-0wVyH0cKohzBQ5Gi2V1BuxYpxWfxF3cSqfFXfPIpl5tl9XLS5z4ogqhUCD20AbHi0h9aJkqXNJnkVev6gwh2ag==}
-
pify@2.3.0:
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
engines: {node: '>=0.10.0'}
@@ -4343,6 +4472,9 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
+ tailwindcss@4.2.1:
+ resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==}
+
tapable@2.3.0:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
@@ -4691,6 +4823,8 @@ packages:
snapshots:
+ '@alloc/quick-lru@5.2.0': {}
+
'@babel/code-frame@7.27.1':
dependencies:
'@babel/helper-validator-identifier': 7.27.1
@@ -5972,6 +6106,75 @@ snapshots:
dependencies:
playwright: 1.50.1
+ '@tailwindcss/node@4.2.1':
+ dependencies:
+ '@jridgewell/remapping': 2.3.5
+ enhanced-resolve: 5.20.1
+ jiti: 2.6.1
+ lightningcss: 1.31.1
+ magic-string: 0.30.21
+ source-map-js: 1.2.1
+ tailwindcss: 4.2.1
+
+ '@tailwindcss/oxide-android-arm64@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-arm64@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-x64@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide-freebsd-x64@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-musl@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide-wasm32-wasi@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide@4.2.1':
+ optionalDependencies:
+ '@tailwindcss/oxide-android-arm64': 4.2.1
+ '@tailwindcss/oxide-darwin-arm64': 4.2.1
+ '@tailwindcss/oxide-darwin-x64': 4.2.1
+ '@tailwindcss/oxide-freebsd-x64': 4.2.1
+ '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1
+ '@tailwindcss/oxide-linux-arm64-gnu': 4.2.1
+ '@tailwindcss/oxide-linux-arm64-musl': 4.2.1
+ '@tailwindcss/oxide-linux-x64-gnu': 4.2.1
+ '@tailwindcss/oxide-linux-x64-musl': 4.2.1
+ '@tailwindcss/oxide-wasm32-wasi': 4.2.1
+ '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1
+ '@tailwindcss/oxide-win32-x64-msvc': 4.2.1
+
+ '@tailwindcss/postcss@4.2.1':
+ dependencies:
+ '@alloc/quick-lru': 5.2.0
+ '@tailwindcss/node': 4.2.1
+ '@tailwindcss/oxide': 4.2.1
+ postcss: 8.5.6
+ tailwindcss: 4.2.1
+
'@tokenizer/inflate@0.4.1':
dependencies:
debug: 4.4.3
@@ -5990,8 +6193,6 @@ snapshots:
dependencies:
'@types/node': 22.18.8
- '@types/bluebird@3.5.42': {}
-
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
@@ -6003,6 +6204,11 @@ snapshots:
'@types/common-tags@1.8.4': {}
+ '@types/compression@1.8.1':
+ dependencies:
+ '@types/express': 5.0.3
+ '@types/node': 22.18.8
+
'@types/connect-history-api-fallback@1.5.4':
dependencies:
'@types/express-serve-static-core': 5.1.0
@@ -6079,10 +6285,6 @@ snapshots:
dependencies:
'@types/node': 22.18.8
- '@types/jquery@3.5.33':
- dependencies:
- '@types/sizzle': 2.3.10
-
'@types/json-schema@7.0.15': {}
'@types/katex@0.16.7': {}
@@ -6093,8 +6295,6 @@ snapshots:
'@types/langs@2.0.5': {}
- '@types/lodash@4.17.20': {}
-
'@types/mdast@4.0.4':
dependencies:
'@types/unist': 3.0.3
@@ -6115,8 +6315,6 @@ snapshots:
'@types/pako@2.0.4': {}
- '@types/piexifjs@1.0.0': {}
-
'@types/prismjs@1.26.5': {}
'@types/qs@6.14.0': {}
@@ -6161,8 +6359,6 @@ snapshots:
'@types/node': 22.18.8
'@types/send': 0.17.5
- '@types/sizzle@2.3.10': {}
-
'@types/sockjs@0.3.36':
dependencies:
'@types/node': 22.18.8
@@ -6856,6 +7052,11 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.3.0
+ enhanced-resolve@5.20.1:
+ dependencies:
+ graceful-fs: 4.2.11
+ tapable: 2.3.0
+
entities@2.2.0: {}
entities@6.0.1: {}
@@ -7419,8 +7620,6 @@ snapshots:
ieee754@1.2.1: {}
- image-size@2.0.2: {}
-
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
@@ -7544,10 +7743,6 @@ snapshots:
jiti@2.6.1: {}
- jquery-binarytransport@1.0.0: {}
-
- jquery@3.7.1: {}
-
js-tokens@4.0.0: {}
js-yaml@4.1.0:
@@ -7593,6 +7788,55 @@ snapshots:
picocolors: 1.1.1
shell-quote: 1.8.3
+ lightningcss-android-arm64@1.31.1:
+ optional: true
+
+ lightningcss-darwin-arm64@1.31.1:
+ optional: true
+
+ lightningcss-darwin-x64@1.31.1:
+ optional: true
+
+ lightningcss-freebsd-x64@1.31.1:
+ optional: true
+
+ lightningcss-linux-arm-gnueabihf@1.31.1:
+ optional: true
+
+ lightningcss-linux-arm64-gnu@1.31.1:
+ optional: true
+
+ lightningcss-linux-arm64-musl@1.31.1:
+ optional: true
+
+ lightningcss-linux-x64-gnu@1.31.1:
+ optional: true
+
+ lightningcss-linux-x64-musl@1.31.1:
+ optional: true
+
+ lightningcss-win32-arm64-msvc@1.31.1:
+ optional: true
+
+ lightningcss-win32-x64-msvc@1.31.1:
+ optional: true
+
+ lightningcss@1.31.1:
+ dependencies:
+ detect-libc: 2.1.2
+ optionalDependencies:
+ lightningcss-android-arm64: 1.31.1
+ lightningcss-darwin-arm64: 1.31.1
+ lightningcss-darwin-x64: 1.31.1
+ lightningcss-freebsd-x64: 1.31.1
+ lightningcss-linux-arm-gnueabihf: 1.31.1
+ lightningcss-linux-arm64-gnu: 1.31.1
+ lightningcss-linux-arm64-musl: 1.31.1
+ lightningcss-linux-x64-gnu: 1.31.1
+ lightningcss-linux-x64-musl: 1.31.1
+ lightningcss-win32-arm64-msvc: 1.31.1
+ lightningcss-win32-x64-msvc: 1.31.1
+
lines-and-columns@1.2.4: {}
loader-runner@4.3.1: {}
@@ -7635,6 +7879,10 @@ snapshots:
yallist: 4.0.0
optional: true
+ magic-string@0.30.21:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+
make-fetch-happen@9.1.0:
dependencies:
agentkeepalive: 4.6.0
@@ -8401,8 +8649,6 @@ snapshots:
picomatch@4.0.3: {}
- piexifjs@1.0.6: {}
-
pify@2.3.0: {}
pkg-dir@4.2.0:
@@ -9320,6 +9566,8 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
+ tailwindcss@4.2.1: {}
+
tapable@2.3.0: {}
tar-fs@2.1.4:
diff --git a/application/public/fonts/ReiNoAreMincho-Heavy.otf b/application/public/fonts/ReiNoAreMincho-Heavy.otf
deleted file mode 100644
index 5634d00be9..0000000000
Binary files a/application/public/fonts/ReiNoAreMincho-Heavy.otf and /dev/null differ
diff --git a/application/public/fonts/ReiNoAreMincho-Regular.otf b/application/public/fonts/ReiNoAreMincho-Regular.otf
deleted file mode 100644
index c210e7a9ee..0000000000
Binary files a/application/public/fonts/ReiNoAreMincho-Regular.otf and /dev/null differ
diff --git a/application/public/fonts/ReiNoAreMincho-Subset.woff2 b/application/public/fonts/ReiNoAreMincho-Subset.woff2
new file mode 100644
index 0000000000..c3c3736da7
Binary files /dev/null and b/application/public/fonts/ReiNoAreMincho-Subset.woff2 differ
diff --git a/application/server/package.json b/application/server/package.json
index 9482575df7..39840023d4 100644
--- a/application/server/package.json
+++ b/application/server/package.json
@@ -14,9 +14,11 @@
},
"dependencies": {
"@tsconfig/strictest": "2.0.8",
+ "@types/compression": "^1.8.1",
"@web-speed-hackathon-2026/server": "workspace:*",
"bcrypt": "6.0.0",
"body-parser": "2.2.0",
+ "compression": "^1.8.1",
"connect-history-api-fallback": "2.0.0",
"express": "5.1.0",
"express-session": "1.18.2",
diff --git a/application/server/pnpm-lock.yaml b/application/server/pnpm-lock.yaml
new file mode 100644
index 0000000000..6ff5b36db8
--- /dev/null
+++ b/application/server/pnpm-lock.yaml
@@ -0,0 +1,197 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ '@types/compression':
+ specifier: ^1.8.1
+ version: 1.8.1
+ '@web-speed-hackathon-2026/server':
+ specifier: workspace:*
+ version: 'link:'
+ compression:
+ specifier: ^1.8.1
+ version: 1.8.1
+
+packages:
+
+ '@types/body-parser@1.19.6':
+ resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
+
+ '@types/compression@1.8.1':
+ resolution: {integrity: sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==}
+
+ '@types/connect@3.4.38':
+ resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
+
+ '@types/express-serve-static-core@5.1.1':
+ resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==}
+
+ '@types/express@5.0.3':
+ resolution: {integrity: sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==}
+
+ '@types/http-errors@2.0.5':
+ resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
+
+ '@types/mime@1.3.5':
+ resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
+
+ '@types/node@22.18.8':
+ resolution: {integrity: sha512-pAZSHMiagDR7cARo/cch1f3rXy0AEXwsVsVH09FcyeJVAzCnGgmYis7P3JidtTUjyadhTeSo8TgRPswstghDaw==}
+
+ '@types/qs@6.15.0':
+ resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==}
+
+ '@types/range-parser@1.2.7':
+ resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
+
+ '@types/send@0.17.6':
+ resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==}
+
+ '@types/send@1.2.1':
+ resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
+
+ '@types/serve-static@1.15.9':
+ resolution: {integrity: sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==}
+
+ bytes@3.1.2:
+ resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
+ engines: {node: '>= 0.8'}
+
+ compressible@2.0.18:
+ resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==}
+ engines: {node: '>= 0.6'}
+
+ compression@1.8.1:
+ resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==}
+ engines: {node: '>= 0.8.0'}
+
+ debug@2.6.9:
+ resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ mime-db@1.54.0:
+ resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
+ engines: {node: '>= 0.6'}
+
+ ms@2.0.0:
+ resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
+
+ negotiator@0.6.4:
+ resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==}
+ engines: {node: '>= 0.6'}
+
+ on-headers@1.1.0:
+ resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==}
+ engines: {node: '>= 0.8'}
+
+ safe-buffer@5.2.1:
+ resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
+
+ undici-types@6.21.0:
+ resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+
+ vary@1.1.2:
+ resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
+ engines: {node: '>= 0.8'}
+
+snapshots:
+
+ '@types/body-parser@1.19.6':
+ dependencies:
+ '@types/connect': 3.4.38
+ '@types/node': 22.18.8
+
+ '@types/compression@1.8.1':
+ dependencies:
+ '@types/express': 5.0.3
+ '@types/node': 22.18.8
+
+ '@types/connect@3.4.38':
+ dependencies:
+ '@types/node': 22.18.8
+
+ '@types/express-serve-static-core@5.1.1':
+ dependencies:
+ '@types/node': 22.18.8
+ '@types/qs': 6.15.0
+ '@types/range-parser': 1.2.7
+ '@types/send': 1.2.1
+
+ '@types/express@5.0.3':
+ dependencies:
+ '@types/body-parser': 1.19.6
+ '@types/express-serve-static-core': 5.1.1
+ '@types/serve-static': 1.15.9
+
+ '@types/http-errors@2.0.5': {}
+
+ '@types/mime@1.3.5': {}
+
+ '@types/node@22.18.8':
+ dependencies:
+ undici-types: 6.21.0
+
+ '@types/qs@6.15.0': {}
+
+ '@types/range-parser@1.2.7': {}
+
+ '@types/send@0.17.6':
+ dependencies:
+ '@types/mime': 1.3.5
+ '@types/node': 22.18.8
+
+ '@types/send@1.2.1':
+ dependencies:
+ '@types/node': 22.18.8
+
+ '@types/serve-static@1.15.9':
+ dependencies:
+ '@types/http-errors': 2.0.5
+ '@types/node': 22.18.8
+ '@types/send': 0.17.6
+
+ bytes@3.1.2: {}
+
+ compressible@2.0.18:
+ dependencies:
+ mime-db: 1.54.0
+
+ compression@1.8.1:
+ dependencies:
+ bytes: 3.1.2
+ compressible: 2.0.18
+ debug: 2.6.9
+ negotiator: 0.6.4
+ on-headers: 1.1.0
+ safe-buffer: 5.2.1
+ vary: 1.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ debug@2.6.9:
+ dependencies:
+ ms: 2.0.0
+
+ mime-db@1.54.0: {}
+
+ ms@2.0.0: {}
+
+ negotiator@0.6.4: {}
+
+ on-headers@1.1.0: {}
+
+ safe-buffer@5.2.1: {}
+
+ undici-types@6.21.0: {}
+
+ vary@1.1.2: {}
diff --git a/application/server/src/app.ts b/application/server/src/app.ts
index 671fb424cc..ba94584c24 100644
--- a/application/server/src/app.ts
+++ b/application/server/src/app.ts
@@ -1,4 +1,5 @@
import bodyParser from "body-parser";
+import compression from "compression";
import Express from "express";
import { apiRouter } from "@web-speed-hackathon-2026/server/src/routes/api";
@@ -9,15 +10,30 @@ export const app = Express();
app.set("trust proxy", true);
+app.use(
+ compression({
+ filter: (req, res) => {
+ if (req.path === "/api/v1/crok") return false;
+ if (res.getHeader("Content-Type") === "text/event-stream") return false;
+ return compression.filter(req, res);
+ },
+ }),
+);
app.use(sessionMiddleware);
app.use(bodyParser.json());
app.use(bodyParser.raw({ limit: "10mb" }));
-app.use((_req, res, next) => {
- res.header({
- "Cache-Control": "max-age=0, no-transform",
- Connection: "close",
- });
+app.use((req, res, next) => {
+ // Immutable cache for fonts (1 year)
+ if (req.path.startsWith("/fonts/")) {
+ res.header({
+ "Cache-Control": "public, max-age=31536000, immutable",
+ });
+ } else {
+ res.header({
+ "Cache-Control": "max-age=0, no-cache",
+ });
+ }
return next();
});
diff --git a/application/server/src/models/Comment.ts b/application/server/src/models/Comment.ts
index 41e9e4e094..4c0dc8106c 100644
--- a/application/server/src/models/Comment.ts
+++ b/application/server/src/models/Comment.ts
@@ -53,6 +53,10 @@ export function initComment(sequelize: Sequelize) {
],
order: [["createdAt", "ASC"]],
},
+ indexes: [
+ { fields: ["userId"] },
+ { fields: ["postId"] },
+ ],
},
);
}
diff --git a/application/server/src/models/DirectMessage.ts b/application/server/src/models/DirectMessage.ts
index e4565ba1c4..ddeda5195d 100644
--- a/application/server/src/models/DirectMessage.ts
+++ b/application/server/src/models/DirectMessage.ts
@@ -69,6 +69,11 @@ export function initDirectMessage(sequelize: Sequelize) {
],
order: [["createdAt", "ASC"]],
},
+ indexes: [
+ { fields: ["conversationId"] },
+ { fields: ["senderId"] },
+ { fields: ["conversationId", "isRead"] },
+ ],
},
);
diff --git a/application/server/src/models/DirectMessageConversation.ts b/application/server/src/models/DirectMessageConversation.ts
index 99ebb2425b..38d2c51c8e 100644
--- a/application/server/src/models/DirectMessageConversation.ts
+++ b/application/server/src/models/DirectMessageConversation.ts
@@ -50,14 +50,12 @@ export function initDirectMessageConversation(sequelize: Sequelize) {
include: [
{ association: "initiator", include: [{ association: "profileImage" }] },
{ association: "member", include: [{ association: "profileImage" }] },
- {
- association: "messages",
- include: [{ association: "sender", include: [{ association: "profileImage" }] }],
- order: [["createdAt", "ASC"]],
- required: false,
- },
],
},
+ indexes: [
+ { fields: ["initiatorId"] },
+ { fields: ["memberId"] },
+ ],
},
);
}
diff --git a/application/server/src/models/Post.ts b/application/server/src/models/Post.ts
index 6f86442eb1..b872a99c81 100644
--- a/application/server/src/models/Post.ts
+++ b/application/server/src/models/Post.ts
@@ -64,6 +64,11 @@ export function initPost(sequelize: Sequelize) {
["images", "createdAt", "ASC"],
],
},
+ indexes: [
+ { fields: ["userId"] },
+ { fields: ["movieId"] },
+ { fields: ["soundId"] },
+ ],
},
);
}
diff --git a/application/server/src/models/PostsImagesRelation.ts b/application/server/src/models/PostsImagesRelation.ts
index 0a5364b56f..649c9899bf 100644
--- a/application/server/src/models/PostsImagesRelation.ts
+++ b/application/server/src/models/PostsImagesRelation.ts
@@ -36,6 +36,10 @@ export function initPostsImagesRelation(sequelize: Sequelize) {
},
{
sequelize,
+ indexes: [
+ { fields: ["postId"] },
+ { fields: ["imageId"] },
+ ],
},
);
}
diff --git a/application/server/src/routes/api/crok.ts b/application/server/src/routes/api/crok.ts
index cfd6065951..82e772446a 100644
--- a/application/server/src/routes/api/crok.ts
+++ b/application/server/src/routes/api/crok.ts
@@ -34,7 +34,7 @@ crokRouter.get("/crok", async (req, res) => {
let messageId = 0;
// TTFT (Time to First Token)
- await sleep(3000);
+ await sleep(100);
for (const char of response) {
if (res.closed) break;
@@ -42,7 +42,7 @@ crokRouter.get("/crok", async (req, res) => {
const data = JSON.stringify({ text: char, done: false });
res.write(`event: message\nid: ${messageId++}\ndata: ${data}\n\n`);
- await sleep(10);
+ await sleep(1);
}
if (!res.closed) {
diff --git a/application/server/src/routes/api/direct_message.ts b/application/server/src/routes/api/direct_message.ts
index 2993a2d6be..4030fde083 100644
--- a/application/server/src/routes/api/direct_message.ts
+++ b/application/server/src/routes/api/direct_message.ts
@@ -23,6 +23,13 @@ directMessageRouter.get("/dm", async (req, res) => {
where(col("messages.id"), { [Op.not]: null }),
],
},
+ include: [
+ {
+ association: "messages",
+ include: [{ association: "sender", include: [{ association: "profileImage" }] }],
+ required: false,
+ },
+ ],
order: [[col("messages.createdAt"), "DESC"]],
});
@@ -105,6 +112,14 @@ directMessageRouter.get("/dm/:conversationId", async (req, res) => {
id: req.params.conversationId,
[Op.or]: [{ initiatorId: req.session.userId }, { memberId: req.session.userId }],
},
+ include: [
+ {
+ association: "messages",
+ include: [{ association: "sender", include: [{ association: "profileImage" }] }],
+ order: [["createdAt", "ASC"]],
+ required: false,
+ },
+ ],
});
if (conversation === null) {
throw new httpErrors.NotFound();
diff --git a/application/server/src/routes/static.ts b/application/server/src/routes/static.ts
index b5820c986e..b94f0a34b4 100644
--- a/application/server/src/routes/static.ts
+++ b/application/server/src/routes/static.ts
@@ -1,5 +1,5 @@
import history from "connect-history-api-fallback";
-import { Router } from "express";
+import { type Request, type Response, Router } from "express";
import serveStatic from "serve-static";
import {
@@ -13,23 +13,32 @@ export const staticRouter = Router();
// SPA 対応のため、ファイルが存在しないときに index.html を返す
staticRouter.use(history());
+// Hashed static assets (scripts/chunk-*, styles/) get long-term immutable cache
+staticRouter.use((req: Request, res: Response, next) => {
+ if (/^\/scripts\/chunk-[^/]+\.js$/.test(req.path) || /^\/styles\//.test(req.path)) {
+ res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
+ }
+ next();
+});
+
staticRouter.use(
serveStatic(UPLOAD_PATH, {
- etag: false,
- lastModified: false,
+ etag: true,
+ lastModified: true,
}),
);
staticRouter.use(
serveStatic(PUBLIC_PATH, {
- etag: false,
- lastModified: false,
+ etag: true,
+ lastModified: true,
+ maxAge: "1d",
}),
);
staticRouter.use(
serveStatic(CLIENT_DIST_PATH, {
- etag: false,
- lastModified: false,
+ etag: true,
+ lastModified: true,
}),
);