diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..518c827c76 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,165 @@ +# Web Speed Hackathon 2026 — CLAUDE.md + +## プロジェクト概要 + +SNS アプリ「CaX」のパフォーマンス改善競技。Lighthouse スコアを最大化する。 + +- **採点**: 1150点満点(ページ表示 900点 + ページ操作 250点) +- **採点ツール**: Lighthouse v10 Performance Scoring +- **デプロイ先**: Fly.io(GitHub Actions 経由) + +--- + +## ⚠️ レギュレーション違反に注意 + +以下は違反すると**順位対象外**になる。変更前に必ず確認すること。 + +### 絶対に変更してはいけないもの + +| 対象 | 理由 | +|------|------| +| `fly.toml` | Fly.io デプロイ設定。変更禁止 | +| `GET /api/v1/crok` の SSE プロトコル | ストリーミング仕様変更禁止 | +| `crok-response.md` と同等画面の構成情報 | SSE 以外での伝達禁止 | +| シードデータの各種 ID | `generateSeeds.ts` が生成した ID を変更してはならない | + +### 機能・デザインを壊してはいけない + +- VRT(Visual Regression Tests)が失敗してはいけない +- `docs/test_cases.md` の手動テスト項目が失敗してはいけない +- Google Chrome 最新版で著しい機能落ちやデザイン差異を発生させてはいけない + +### その他 + +- `POST /api/v1/initialize` で DB が初期値にリセットできること(採点サーバーの前提) +- 競技終了後にアプリを更新してはいけない + +### デプロイ前チェック + +```bash +make check # fly.toml 差分確認 + TypeScript型チェック + ビルド確認 +``` + +--- + +## 技術スタック + +| レイヤー | 技術 | +|---------|------| +| フロントエンド | React 19 + Redux + React Router 7 + Webpack 5 + Tailwind CSS 4 | +| バックエンド | Express 5 + Sequelize 6 + SQLite | +| 言語 | TypeScript 5.9 | +| パッケージマネージャー | pnpm 10(monorepo workspace) | +| デプロイ | Fly.io(GitHub Actions 自動デプロイ) | +| テスト | Playwright E2E + VRT | + +--- + +## よく使うコマンド + +```bash +make dev # ビルド + サーバー起動(localhost:3000) +make build # クライアントのみビルド +make start # サーバーのみ起動 +make check # デプロイ前チェック(必ず実行) +make score # ローカルで Lighthouse 計測 +make score-target TARGET="ホーム" # 特定ページのみ計測 +make score-prod PR=133 # デプロイ済み環境を計測 +make pr # upstream に "deploy" PR 作成 → Fly.io デプロイ +make open-board # スコアボードをブラウザで開く +``` + +--- + +## ディレクトリ構成 + +``` +web-speed-hackathon-2026/ +├── application/ +│ ├── client/ # フロントエンド(React + Webpack) +│ │ ├── src/ +│ │ ├── webpack.config.js # WASM alias・ProvidePlugin 等、複雑な設定あり +│ │ └── babel.config.js # targets: last 1 chrome +│ ├── server/ # バックエンド(Express + SQLite) +│ │ ├── src/ +│ │ └── database.sqlite # 98MB のシード DB(採点用、変更注意) +│ └── e2e/ # Playwright VRT +├── docs/ +│ ├── regulation.md # レギュレーション(必読) +│ ├── scoring.md # 採点方法 +│ └── test_cases.md # 手動テスト項目 +├── ../adr/ # 意思決定ログ(ADR-0001〜0023) +├── ../strategy/ # 戦略ドキュメント +├── fly.toml # ⚠️ 変更禁止 +└── Makefile +``` + +--- + +## 採点の仕組み + +### ページ表示(900点満点 = 9ページ × 最大100点) + +各ページのスコア = 以下の合計: +- FCP × 10 +- Speed Index × 10 +- **LCP × 25**(最重要) +- **TBT × 30**(最重要) +- CLS × 25 + +計測対象: ホーム・投稿詳細・写真投稿詳細・動画投稿詳細・音声投稿詳細・DM一覧・DM詳細・検索・利用規約 + +**ページ表示 300点以上の場合のみ、ページ操作の採点が行われる。** + +### ページ操作(250点満点 = 5シナリオ × 最大50点) + +各シナリオのスコア = TBT × 25 + INP × 25 + +計測対象: 認証・DM・検索・Crok(AI チャット)・投稿 + +--- + +## 実施済みの主要最適化(ADR-0001〜0023) + +| ADR | 施策 | 効果 | +|-----|------|------| +| 0001 | Webpack production mode 有効化 | minify・tree-shake | +| 0002 | React.lazy + route-based code splitting | 初期 JS 削減 | +| 0003 | 静的アセット Cache-Control: max-age=31536000 | キャッシュ活用 | +| 0006 | lodash・moment・jquery 除去 | ~940 KB 削減 | +| 0008 | core-js polyfill 除去(targets: last 1 chrome) | 大幅削減 | +| 0011 | brotli/gzip 事前圧縮 + WOFF2 + font-display | 転送量削減 | +| 0012 | サーバーサイドページネーション | TTFB 改善 | +| 0019 | GIF→WebM サーバーサイド変換 | LCP/TBT 改善 | +| 0020 | AspectRatioBox → CSS aspect-ratio | 500ms 遅延除去・CLS 修正 | +| 0021 | Terms ページ フォントプリロード | FCP 改善 | +| 0023 | InfiniteScroll passive・lazy loading・preload="metadata"・API キャッシュ | TBT/LCP/TTFB 改善 | + +--- + +## webpack.config.js の注意点 + +複雑な設定が入っている。壊すと VRT が全滅するため慎重に変更すること。 + +- WASM alias × 4(`@ffmpeg/core`・`@imagemagick/magick-wasm` 等) +- `resourceQuery: /binary/` — バイナリファイルの取り扱い +- `ProvidePlugin` — `standardized-audio-context` 用 +- `splitChunks` — vendor chunk 分割設定 + +--- + +## ADR の書き方 + +`../adr/` に `NNNN-施策名.md` で記録する。テンプレートは `../adr/template.md`。 +1 施策 = 1 ADR = 1 PR が基本。 + +--- + +## デプロイフロー + +``` +make check → make pr → GitHub Actions → Fly.io デプロイ → 採点サーバー計測 → リーダーボード更新 +``` + +PR URL: `https://pr--web-speed-hackathon-2026.fly.dev` +スコアボード: `https://web-speed-hackathon-scoring-board-2026.fly.dev/` diff --git a/Dockerfile b/Dockerfile index 2c95811428..9c99141d45 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,8 @@ LABEL fly_launch_runtime="Node.js" ENV PNPM_HOME=/pnpm +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/* + WORKDIR /app RUN --mount=type=cache,target=/root/.npm npm install -g pnpm@${PNPM_VERSION} @@ -23,6 +25,14 @@ COPY ./application . RUN NODE_OPTIONS="--max-old-space-size=4096" pnpm build +# シード GIF を WebM に変換(並列処理) +RUN find /app/public/movies -name "*.gif" | xargs -P4 -I{} sh -c \ + 'out="${1%.gif}.webm"; ffmpeg -y -i "$1" -c:v libvpx-vp9 -b:v 0 -crf 33 -an -deadline realtime -cpu-used 8 "$out" 2>/dev/null && echo "converted: $out"' _ {} + +# シード JPG を WebP に事前変換(並列処理) +RUN find /app/public/images -name "*.jpg" | xargs -P4 -I{} sh -c \ + 'out="${1%.jpg}.webp"; ffmpeg -y -i "$1" -vf "scale=min(1200\,iw):-2" -c:v libwebp -quality 80 "$out" 2>/dev/null && echo "converted: $out"' _ {} + RUN --mount=type=cache,target=/pnpm/store CI=true pnpm install --frozen-lockfile --prod --filter @web-speed-hackathon-2026/server FROM base diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000..870f88d41a --- /dev/null +++ b/Makefile @@ -0,0 +1,86 @@ +.PHONY: dev build start pr pr-fork check score score-prod score-target open-board sync + +UPSTREAM := CyberAgentHack/web-speed-hackathon-2026 +APP_URL := http://localhost:3000 +PR ?= 0 + +# ── ローカル開発 ──────────────────────────────────────────── + +# クライアントをビルドしてサーバーを起動 +dev: build start + +# クライアントのみビルド +build: + cd application && pnpm build + +# サーバーのみ起動(ビルド済みの dist を使う) +start: + cd application && pnpm start + +# ── デプロイ前チェック ─────────────────────────────────────── + +# PR を出す前に必ず実行する。自動チェック + 手動確認リストを表示。 +check: + @echo "=== [1/3] fly.toml が変更されていないか ===" + @git diff fork-upstream/main -- fly.toml | grep -q '.' \ + && echo "❌ fly.toml が変更されています(レギュレーション違反)" && exit 1 \ + || echo "✅ fly.toml 変更なし" + @echo "" + @echo "=== [2/3] TypeScript 型チェック ===" + cd application && pnpm --filter @web-speed-hackathon-2026/client typecheck \ + && pnpm --filter @web-speed-hackathon-2026/server typecheck + @echo "" + @echo "=== [3/3] ビルド成功確認 ===" + cd application && pnpm build + @echo "" + @echo "=== ✅ 自動チェック完了 ===" + @echo "" + @echo "以下を手動で確認してから make pr を実行してください:" + @echo " [ ] localhost:3000 で主要ページ(ホーム・投稿詳細・DM・検索)が表示される" + @echo " [ ] 新規投稿モーダルが開く" + @echo " [ ] make score でスコアが前回より改善している" + @echo " [ ] GET /api/v1/crok の SSE が正常に動作する(crok ページで確認)" + @echo "" + +# ── デプロイ ──────────────────────────────────────────────── + +# upstream に "deploy" PR を作成 → GitHub Actions が fly.io にデプロイ・採点 +# PR がすでに存在する場合は push だけで Actions が再トリガーされる +pr: + git push origin HEAD + gh pr create \ + --repo $(UPSTREAM) \ + --base main \ + --title "deploy" \ + --body "" \ + || echo "PR already exists – push triggered re-deploy" + +# 自分の fork の main に記録用 PR を作成(詳細な説明を書く用) +pr-fork: + git push origin HEAD + gh pr create --base main --title "" --body "" + +# upstream の最新を取り込んで fork に反映 +sync: + git fetch upstream + git merge fork-upstream/main --ff-only + git push origin main + +# ── 採点 ──────────────────────────────────────────────────── + +# ローカルのアプリをスコアリングツールで計測 +score: + cd scoring-tool && pnpm start --applicationUrl $(APP_URL) + +# 特定ページのみ計測(例: make score-target TARGET="ホーム") +score-target: + cd scoring-tool && pnpm start --applicationUrl $(APP_URL) --targetName "$(TARGET)" + +# デプロイ済みの fly.io アプリを計測(例: make score-prod PR=133) +score-prod: + @[ "$(PR)" != "0" ] || (echo "❌ PR番号を指定してください: make score-prod PR=" && exit 1) + cd scoring-tool && pnpm start --applicationUrl https://pr-$(PR)-web-speed-hackathon-2026.fly.dev + +# スコアボードをブラウザで開く +open-board: + open https://web-speed-hackathon-scoring-board-2026.fly.dev/ diff --git a/application/client/babel.config.js b/application/client/babel.config.js index c3c574591a..009d87251b 100644 --- a/application/client/babel.config.js +++ b/application/client/babel.config.js @@ -4,16 +4,15 @@ module.exports = { [ "@babel/preset-env", { - targets: "ie 11", - corejs: "3", - modules: "commonjs", + targets: "last 1 chrome version", + 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..bc95bad9ce 100644 --- a/application/client/package.json +++ b/application/client/package.json @@ -5,51 +5,35 @@ "license": "MPL-2.0", "author": "CyberAgent, Inc.", "scripts": { - "build": "NODE_ENV=development webpack", + "build": "webpack", "typecheck": "tsc" }, "dependencies": { - "@ffmpeg/core": "0.12.10", - "@ffmpeg/ffmpeg": "0.12.15", - "@imagemagick/magick-wasm": "0.0.37", "@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", - "core-js": "3.45.1", + "dayjs": "1.11.20", "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", - "react-redux": "9.2.0", "react-router": "7.9.4", "react-syntax-highlighter": "16.1.0", - "redux": "5.0.1", - "redux-form": "8.3.10", - "regenerator-runtime": "0.14.1", "rehype-katex": "7.0.1", "remark-gfm": "4.0.1", "remark-math": "6.0.0", - "standardized-audio-context": "25.3.77", "tiny-invariant": "1.3.3" }, "devDependencies": { @@ -57,24 +41,22 @@ "@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", "@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", - "@types/redux-form": "^8.3.11", "babel-loader": "10.0.0", + "compression-webpack-plugin": "12.0.0", "copy-webpack-plugin": "13.0.1", + "critters-webpack-plugin": "3.0.2", "css-loader": "7.1.2", "html-webpack-plugin": "5.6.4", "mini-css-extract-plugin": "2.9.4", @@ -83,6 +65,7 @@ "postcss-loader": "8.2.0", "postcss-preset-env": "10.4.0", "react-markdown": "10.1.0", + "tailwindcss": "4.2.2", "typescript": "5.9.3", "webpack": "5.102.1", "webpack-cli": "6.0.1", diff --git a/application/client/postcss.config.js b/application/client/postcss.config.js index d7ee920b94..bcb66acf3f 100644 --- a/application/client/postcss.config.js +++ b/application/client/postcss.config.js @@ -1,9 +1,11 @@ +const tailwindcss = require("@tailwindcss/postcss"); const postcssImport = require("postcss-import"); const postcssPresetEnv = require("postcss-preset-env"); module.exports = { plugins: [ postcssImport(), + tailwindcss(), postcssPresetEnv({ stage: 3, }), diff --git a/application/client/src/auth/validation.ts b/application/client/src/auth/validation.ts index 2a83bbfb15..101d73afd3 100644 --- a/application/client/src/auth/validation.ts +++ b/application/client/src/auth/validation.ts @@ -1,9 +1,7 @@ -import { FormErrors } from "redux-form"; - import { AuthFormData } from "@web-speed-hackathon-2026/client/src/auth/types"; -export const validate = (values: AuthFormData): FormErrors => { - const errors: FormErrors = {}; +export const validate = (values: AuthFormData): Partial> => { + const errors: Partial> = {}; const normalizedName = values.name?.trim() || ""; const normalizedPassword = values.password?.trim() || ""; @@ -13,7 +11,7 @@ export const validate = (values: AuthFormData): FormErrors => { errors.name = "名前を入力してください"; } - if (/^(?:[^\P{Letter}&&\P{Number}]*){16,}$/v.test(normalizedPassword)) { + if (normalizedPassword.length >= 16 && /^[A-Za-z0-9]*$/.test(normalizedPassword)) { errors.password = "パスワードには記号を含める必要があります"; } if (normalizedPassword.length === 0) { diff --git a/application/client/src/components/application/AccountMenu.tsx b/application/client/src/components/application/AccountMenu.tsx index b6df12bbab..2d7e6cca15 100644 --- a/application/client/src/components/application/AccountMenu.tsx +++ b/application/client/src/components/application/AccountMenu.tsx @@ -37,11 +37,15 @@ export const AccountMenu = ({ user, onLogout }: Props) => { className="hover:bg-cax-surface-subtle flex w-full items-center gap-3 rounded-full p-2 transition-colors" onClick={() => setOpen((prev) => !prev)} > - {user.profileImage.alt} + {user.profileImage != null ? ( + {user.profileImage.alt} + ) : ( +
+ )}
{user.name}
@{user.username}
diff --git a/application/client/src/components/application/SearchPage.tsx b/application/client/src/components/application/SearchPage.tsx index e99045de45..907c3bfb8f 100644 --- a/application/client/src/components/application/SearchPage.tsx +++ b/application/client/src/components/application/SearchPage.tsx @@ -1,13 +1,11 @@ import { useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router"; -import { Field, InjectedFormProps, reduxForm, WrappedFieldProps } from "redux-form"; import { Timeline } from "@web-speed-hackathon-2026/client/src/components/timeline/Timeline"; import { parseSearchQuery, sanitizeSearchText, } from "@web-speed-hackathon-2026/client/src/search/services"; -import { SearchFormData } from "@web-speed-hackathon-2026/client/src/search/types"; import { validate } from "@web-speed-hackathon-2026/client/src/search/validation"; import { analyzeSentiment } from "@web-speed-hackathon-2026/client/src/utils/negaposi_analyzer"; @@ -18,31 +16,16 @@ interface Props { results: Models.Post[]; } -const SearchInput = ({ input, meta }: WrappedFieldProps) => ( -
- - {meta.touched && meta.error && ( - {meta.error} - )} -
-); - -const SearchPageComponent = ({ - query, - results, - handleSubmit, -}: Props & InjectedFormProps) => { +export const SearchPage = ({ query, results }: Props) => { const navigate = useNavigate(); const [isNegative, setIsNegative] = useState(false); + const [searchText, setSearchText] = useState(query); + const [formError, setFormError] = useState(""); + + useEffect(() => { + setSearchText(query); + setFormError(""); + }, [query]); const parsed = parseSearchQuery(query); @@ -84,17 +67,39 @@ const SearchPageComponent = ({ return parts.join(" "); }, [parsed]); - const onSubmit = (values: SearchFormData) => { - const sanitizedText = sanitizeSearchText(values.searchText.trim()); + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const errors = validate({ searchText }); + if (errors.searchText) { + setFormError(errors.searchText as string); + return; + } + setFormError(""); + const sanitizedText = sanitizeSearchText(searchText.trim()); navigate(`/search?q=${encodeURIComponent(sanitizedText)}`); }; return (
-
+
- +
+ setSearchText(e.target.value)} + className={`flex-1 rounded border px-4 py-2 focus:outline-none ${ + formError + ? "border-cax-danger focus:border-cax-danger" + : "border-cax-border focus:border-cax-brand-strong" + }`} + placeholder="検索 (例: キーワード since:2025-01-01 until:2025-12-31)" + type="text" + /> + {formError && ( + {formError} + )} +
@@ -134,9 +139,3 @@ const SearchPageComponent = ({
); }; - -export const SearchPage = reduxForm({ - form: "search", - enableReinitialize: true, - validate, -})(SearchPageComponent); diff --git a/application/client/src/components/auth_modal/AuthModalPage.tsx b/application/client/src/components/auth_modal/AuthModalPage.tsx index 08996f9afd..bbc90a1795 100644 --- a/application/client/src/components/auth_modal/AuthModalPage.tsx +++ b/application/client/src/components/auth_modal/AuthModalPage.tsx @@ -1,5 +1,4 @@ -import { useSelector } from "react-redux"; -import { Field, formValueSelector, InjectedFormProps, reduxForm } from "redux-form"; +import { useState, type ChangeEvent, type FormEvent } from "react"; import { AuthFormData } from "@web-speed-hackathon-2026/client/src/auth/types"; import { validate } from "@web-speed-hackathon-2026/client/src/auth/validation"; @@ -10,22 +9,43 @@ import { ModalSubmitButton } from "@web-speed-hackathon-2026/client/src/componen interface Props { onRequestCloseModal: () => void; + onSubmit: (values: AuthFormData) => Promise; } -const AuthModalPageComponent = ({ - onRequestCloseModal, - handleSubmit, - error, - invalid, - submitting, - initialValues, - change, -}: Props & InjectedFormProps) => { - const currentType: "signin" | "signup" = useSelector((state) => - // @ts-ignore: formValueSelectorの型付けが弱いため、型に嘘をつく - formValueSelector("auth")(state, "type"), - ); - const type = currentType ?? initialValues.type; +export const AuthModalPage = ({ onRequestCloseModal, onSubmit }: Props) => { + const [values, setValues] = useState({ + type: "signin", + username: "", + name: "", + password: "", + }); + const [touched, setTouched] = useState>({}); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(); + + const errors = validate(values); + const hasErrors = Object.keys(errors).length > 0; + const type = values.type; + + const handleChange = (e: ChangeEvent) => { + const { name, value } = e.target; + setValues((prev) => ({ ...prev, [name]: value })); + }; + + const handleBlur = (e: React.FocusEvent) => { + setTouched((prev) => ({ ...prev, [e.target.name]: true })); + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setTouched({ username: true, name: true, password: true }); + if (hasErrors) return; + setSubmitting(true); + setSubmitError(undefined); + const error = await onSubmit(values); + setSubmitting(false); + if (error) setSubmitError(error); + }; return ( @@ -36,7 +56,7 @@ const AuthModalPageComponent = ({
- @, - autoComplete: "username", - }} + label="ユーザー名" + value={values.username} + onChange={handleChange} + onBlur={handleBlur} + error={errors["username"]} + touched={touched["username"]} + leftItem={@} + autoComplete="username" /> {type === "signup" && ( - )} -
@@ -85,19 +111,11 @@ const AuthModalPageComponent = ({

) : null} - + {type === "signin" ? "サインイン" : "登録する"} - {error} + {submitError ?? null} ); }; - -export const AuthModalPage = reduxForm({ - form: "auth", - validate, - initialValues: { - type: "signin", - }, -})(AuthModalPageComponent); diff --git a/application/client/src/components/crok/ChatInput.tsx b/application/client/src/components/crok/ChatInput.tsx index 6f8c17796b..931a96b343 100644 --- a/application/client/src/components/crok/ChatInput.tsx +++ b/application/client/src/components/crok/ChatInput.tsx @@ -1,5 +1,4 @@ -import Bluebird from "bluebird"; -import kuromoji, { type Tokenizer, type IpadicFeatures } from "kuromoji"; +import type { Tokenizer, IpadicFeatures } from "kuromoji"; import { useEffect, useLayoutEffect, @@ -97,8 +96,10 @@ 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 { default: kuromoji } = await import("kuromoji"); + const nextTokenizer = await new Promise>((resolve, reject) => { + kuromoji.builder({ dicPath: "/dicts" }).build((err, t) => err ? reject(err) : resolve(t)); + }); if (mounted) { setTokenizer(nextTokenizer); } @@ -113,37 +114,31 @@ export const ChatInput = ({ isStreaming, onSendMessage }: Props) => { useEffect(() => { let cancelled = false; - const updateSuggestions = async () => { - if (!tokenizer || !inputValue.trim()) { - setSuggestions([]); - setQueryTokens([]); - setShowSuggestions(false); - return; - } + if (!tokenizer || !inputValue.trim()) { + setSuggestions([]); + setQueryTokens([]); + setShowSuggestions(false); + return; + } + const timer = setTimeout(async () => { const { suggestions: candidates } = await fetchJSON<{ suggestions: string[] }>( "/api/v1/crok/suggestions", ); - if (cancelled) { - return; - } + if (cancelled) return; const tokens = extractTokens(tokenizer.tokenize(inputValue)); const results = filterSuggestionsBM25(tokenizer, candidates, tokens); - - if (cancelled) { - return; - } + if (cancelled) return; setQueryTokens(tokens); setSuggestions(results); setShowSuggestions(results.length > 0); - }; - - void updateSuggestions(); + }, 300); return () => { cancelled = true; + clearTimeout(timer); }; }, [inputValue, tokenizer]); diff --git a/application/client/src/components/crok/ChatMessage.tsx b/application/client/src/components/crok/ChatMessage.tsx index ea4a10d027..1a326bd37c 100644 --- a/application/client/src/components/crok/ChatMessage.tsx +++ b/application/client/src/components/crok/ChatMessage.tsx @@ -34,7 +34,6 @@ const AssistantMessage = ({ content }: { content: string }) => { {content ? ( diff --git a/application/client/src/components/crok/CodeBlock.tsx b/application/client/src/components/crok/CodeBlock.tsx index 358a6bbc15..16ec489eea 100644 --- a/application/client/src/components/crok/CodeBlock.tsx +++ b/application/client/src/components/crok/CodeBlock.tsx @@ -1,6 +1,25 @@ import { ComponentProps, isValidElement, ReactElement, ReactNode } from "react"; -import SyntaxHighlighter from "react-syntax-highlighter"; -import { atomOneLight } from "react-syntax-highlighter/dist/esm/styles/hljs"; +import SyntaxHighlighter from "react-syntax-highlighter/dist/esm/prism-light"; +import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism"; +import bash from "react-syntax-highlighter/dist/esm/languages/prism/bash"; +import css from "react-syntax-highlighter/dist/esm/languages/prism/css"; +import javascript from "react-syntax-highlighter/dist/esm/languages/prism/javascript"; +import json from "react-syntax-highlighter/dist/esm/languages/prism/json"; +import jsx from "react-syntax-highlighter/dist/esm/languages/prism/jsx"; +import markup from "react-syntax-highlighter/dist/esm/languages/prism/markup"; +import python from "react-syntax-highlighter/dist/esm/languages/prism/python"; +import typescript from "react-syntax-highlighter/dist/esm/languages/prism/typescript"; +import tsx from "react-syntax-highlighter/dist/esm/languages/prism/tsx"; + +SyntaxHighlighter.registerLanguage("bash", bash); +SyntaxHighlighter.registerLanguage("css", css); +SyntaxHighlighter.registerLanguage("javascript", javascript); +SyntaxHighlighter.registerLanguage("json", json); +SyntaxHighlighter.registerLanguage("jsx", jsx); +SyntaxHighlighter.registerLanguage("html", markup); +SyntaxHighlighter.registerLanguage("python", python); +SyntaxHighlighter.registerLanguage("typescript", typescript); +SyntaxHighlighter.registerLanguage("tsx", tsx); const getLanguage = (children: ReactElement>) => { const className = children.props.className; @@ -28,7 +47,7 @@ export const CodeBlock = ({ children }: ComponentProps<"pre">) => { border: "1px solid var(--color-cax-border)", }} language={language} - style={atomOneLight} + style={oneLight} > {code} diff --git a/application/client/src/components/crok/CrokPage.tsx b/application/client/src/components/crok/CrokPage.tsx index 0be7678f84..56c49d89b9 100644 --- a/application/client/src/components/crok/CrokPage.tsx +++ b/application/client/src/components/crok/CrokPage.tsx @@ -1,8 +1,14 @@ -import { useRef } from "react"; +import { Suspense, lazy, useRef } from "react"; import { ChatInput } from "@web-speed-hackathon-2026/client/src/components/crok/ChatInput"; -import { ChatMessage } from "@web-speed-hackathon-2026/client/src/components/crok/ChatMessage"; +const ChatMessage = lazy(() => + import("@web-speed-hackathon-2026/client/src/components/crok/ChatMessage").then((m) => ({ + default: m.ChatMessage, + })), +); +import { TypingIndicator } from "@web-speed-hackathon-2026/client/src/components/crok/TypingIndicator"; import { WelcomeScreen } from "@web-speed-hackathon-2026/client/src/components/crok/WelcomeScreen"; +import { CrokLogo } from "@web-speed-hackathon-2026/client/src/components/foundation/CrokLogo"; import { FontAwesomeIcon } from "@web-speed-hackathon-2026/client/src/components/foundation/FontAwesomeIcon"; import { useHasContentBelow } from "@web-speed-hackathon-2026/client/src/hooks/use_has_content_below"; @@ -27,9 +33,19 @@ export const CrokPage = ({ messages, isStreaming, onSendMessage }: Props) => {
{messages.length === 0 && } - {messages.map((message, index) => ( - - ))} + +
+
+
Crok
+
+
+
+ }> + {messages.map((message, index) => ( + + ))} +
diff --git a/application/client/src/components/direct_message/DirectMessageListPage.tsx b/application/client/src/components/direct_message/DirectMessageListPage.tsx index 5a373e918e..4ad0650d0d 100644 --- a/application/client/src/components/direct_message/DirectMessageListPage.tsx +++ b/application/client/src/components/direct_message/DirectMessageListPage.tsx @@ -1,4 +1,8 @@ -import moment from "moment"; +import dayjs from "dayjs"; +import "dayjs/locale/ja"; +import relativeTime from "dayjs/plugin/relativeTime"; + +dayjs.extend(relativeTime); import { useCallback, useEffect, useState } from "react"; import { Button } from "@web-speed-hackathon-2026/client/src/components/foundation/Button"; @@ -85,9 +89,9 @@ export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => {
{peer.profileImage.alt}
@@ -100,7 +104,7 @@ export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => { className="text-cax-text-subtle text-xs" dateTime={lastMessage.createdAt} > - {moment(lastMessage.createdAt).locale("ja").fromNow()} + {dayjs(lastMessage.createdAt).locale("ja").fromNow()} )}
diff --git a/application/client/src/components/direct_message/DirectMessagePage.tsx b/application/client/src/components/direct_message/DirectMessagePage.tsx index 098c7d2894..c57d14fa4b 100644 --- a/application/client/src/components/direct_message/DirectMessagePage.tsx +++ b/application/client/src/components/direct_message/DirectMessagePage.tsx @@ -1,5 +1,6 @@ import classNames from "classnames"; -import moment from "moment"; +import dayjs from "dayjs"; +import "dayjs/locale/ja"; import { ChangeEvent, useCallback, @@ -43,7 +44,7 @@ export const DirectMessagePage = ({ const [text, setText] = useState(""); const textAreaRows = Math.min((text || "").split("\n").length, 5); const isInvalid = text.trim().length === 0; - const scrollHeightRef = useRef(0); + const messagesEndRef = useRef(null); const handleChange = useCallback( (event: ChangeEvent) => { @@ -74,16 +75,8 @@ export const DirectMessagePage = ({ ); useEffect(() => { - const id = setInterval(() => { - const height = Number(window.getComputedStyle(document.body).height.replace("px", "")); - if (height !== scrollHeightRef.current) { - scrollHeightRef.current = height; - window.scrollTo(0, height); - } - }, 1); - - return () => clearInterval(id); - }, []); + messagesEndRef.current?.scrollIntoView({ behavior: "instant" }); + }, [conversation.messages.length]); if (conversationError != null) { return ( @@ -97,9 +90,9 @@ export const DirectMessagePage = ({
{peer.profileImage.alt}

@@ -124,6 +117,7 @@ export const DirectMessagePage = ({ return (
  • {isActiveUserSend && message.isRead && ( 既読 @@ -151,6 +145,7 @@ export const DirectMessagePage = ({ ); })} +
    diff --git a/application/client/src/components/direct_message/NewDirectMessageModalPage.tsx b/application/client/src/components/direct_message/NewDirectMessageModalPage.tsx index 7ada76bacf..974cb58bd5 100644 --- a/application/client/src/components/direct_message/NewDirectMessageModalPage.tsx +++ b/application/client/src/components/direct_message/NewDirectMessageModalPage.tsx @@ -1,4 +1,4 @@ -import { Field, InjectedFormProps, reduxForm } from "redux-form"; +import { useState, type ChangeEvent, type FormEvent } from "react"; import { Button } from "@web-speed-hackathon-2026/client/src/components/foundation/Button"; import { FormInputField } from "@web-speed-hackathon-2026/client/src/components/foundation/FormInputField"; @@ -9,32 +9,57 @@ import { validate } from "@web-speed-hackathon-2026/client/src/direct_message/va interface Props { id: string; + onSubmit: (values: NewDirectMessageFormData) => Promise; } -const NewDirectMessageModalPageComponent = ({ - id, - invalid, - error, - submitting, - handleSubmit, -}: Props & InjectedFormProps) => { +export const NewDirectMessageModalPage = ({ id, onSubmit }: Props) => { + const [values, setValues] = useState({ username: "" }); + const [touched, setTouched] = useState>({}); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(); + + const errors = validate(values); + const hasErrors = Object.keys(errors).length > 0; + + const handleChange = (e: ChangeEvent) => { + const { name, value } = e.target; + setValues((prev) => ({ ...prev, [name]: value })); + }; + + const handleBlur = (e: React.FocusEvent) => { + setTouched((prev) => ({ ...prev, [e.target.name]: true })); + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setTouched({ username: true }); + if (hasErrors) return; + setSubmitting(true); + setSubmitError(undefined); + const error = await onSubmit(values); + setSubmitting(false); + if (error) setSubmitError(error); + }; + return (

    新しくDMを始める

    - @, - }} + label="ユーザー名" + value={values.username} + onChange={handleChange} + onBlur={handleBlur} + error={errors["username"]} + touched={touched["username"]} + placeholder="username" + leftItem={@} />
    - + DMを開始
    - {error} + {submitError ?? null}
    ); }; - -export const NewDirectMessageModalPage = reduxForm({ - form: "newDirectMessage", - validate, - initialValues: { - username: "", - }, -})(NewDirectMessageModalPageComponent); diff --git a/application/client/src/components/foundation/AspectRatioBox.tsx b/application/client/src/components/foundation/AspectRatioBox.tsx index 0ae891963c..71785bf1d2 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,11 @@ 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} +
    +
    + {children} +
    ); }; diff --git a/application/client/src/components/foundation/CoveredImage.tsx b/application/client/src/components/foundation/CoveredImage.tsx index 8ad9cc1f7d..7b88b973a2 100644 --- a/application/client/src/components/foundation/CoveredImage.tsx +++ b/application/client/src/components/foundation/CoveredImage.tsx @@ -1,70 +1,33 @@ -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 { + alt: string; src: string; + loading?: "lazy" | "eager"; + fetchPriority?: "high" | "low" | "auto"; } /** * アスペクト比を維持したまま、要素のコンテンツボックス全体を埋めるように画像を拡大縮小します */ -export const CoveredImage = ({ src }: Props) => { +export const CoveredImage = ({ alt, src, loading = "lazy", fetchPriority }: 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" + loading={loading} + fetchPriority={fetchPriority} + src={src} />
    ); diff --git a/application/client/src/components/foundation/SoundWaveSVG.tsx b/application/client/src/components/foundation/SoundWaveSVG.tsx index d95e63164c..1353b2683f 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,28 +5,6 @@ interface ParsedData { peaks: number[]; } -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 rightData = _.map(buffer.getChannelData(1), Math.abs); - - // 左右の音声データの平均を取る - const normalized = _.map(_.zip(leftData, rightData), _.mean); - // 100 個の chunk に分ける - const chunks = _.chunk(normalized, Math.ceil(normalized.length / 100)); - // chunk ごとに平均を取る - const peaks = _.map(chunks, _.mean); - // chunk の平均の中から最大値を取る - const max = _.max(peaks) ?? 0; - - return { max, peaks }; -} - interface Props { soundData: ArrayBuffer; } @@ -40,9 +17,23 @@ export const SoundWaveSVG = ({ soundData }: Props) => { }); useEffect(() => { - calculate(soundData).then(({ max, peaks }) => { - setPeaks({ max, peaks }); - }); + let cancelled = false; + + const worker = new Worker(new URL("./sound_wave_worker", import.meta.url)); + worker.onmessage = (e: MessageEvent) => { + if (!cancelled) { + setPeaks(e.data); + } + worker.terminate(); + }; + + const copy = soundData.slice(0); + worker.postMessage({ soundData: copy }, [copy]); + + return () => { + cancelled = true; + worker.terminate(); + }; }, [soundData]); return ( diff --git a/application/client/src/components/foundation/sound_wave_worker.ts b/application/client/src/components/foundation/sound_wave_worker.ts new file mode 100644 index 0000000000..c779ed4cc2 --- /dev/null +++ b/application/client/src/components/foundation/sound_wave_worker.ts @@ -0,0 +1,47 @@ +interface WorkerInput { + soundData: ArrayBuffer; +} + +interface WorkerInputLegacy { + leftData: Float32Array; + rightData: Float32Array; +} + +interface WorkerOutput { + max: number; + peaks: number[]; +} + +function computePeaks(leftData: Float32Array, rightData: Float32Array): WorkerOutput { + const length = leftData.length; + const chunkSize = Math.ceil(length / 100); + const peaks: number[] = []; + + for (let i = 0; i < length; i += chunkSize) { + const end = Math.min(i + chunkSize, length); + let sum = 0; + for (let j = i; j < end; j++) { + sum += (Math.abs(leftData[j]!) + Math.abs(rightData[j]!)) / 2; + } + peaks.push(sum / (end - i)); + } + + const max = peaks.reduce((a, b) => Math.max(a, b), 0); + return { max, peaks }; +} + +self.onmessage = async (e: MessageEvent) => { + const data = e.data; + + if ("soundData" in data) { + const ctx = new OfflineAudioContext(1, 1, 44100); + const buffer = await ctx.decodeAudioData(data.soundData); + + const leftData = buffer.getChannelData(0); + const rightData = buffer.getChannelData(buffer.numberOfChannels > 1 ? 1 : 0); + + self.postMessage(computePeaks(leftData, rightData)); + } else { + self.postMessage(computePeaks(data.leftData, data.rightData)); + } +}; diff --git a/application/client/src/components/new_post_modal/NewPostModalPage.tsx b/application/client/src/components/new_post_modal/NewPostModalPage.tsx index e337c46b74..c4dd489d93 100644 --- a/application/client/src/components/new_post_modal/NewPostModalPage.tsx +++ b/application/client/src/components/new_post_modal/NewPostModalPage.tsx @@ -1,4 +1,3 @@ -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"; @@ -55,7 +54,7 @@ export const NewPostModalPage = ({ id, hasError, isLoading, onResetError, onSubm Promise.all( files.map((file) => - convertImage(file, { extension: MagickFormat.Jpg }).then( + convertImage(file).then( (blob) => new File([blob], "converted.jpg", { type: "image/jpeg" }), ), ), @@ -70,7 +69,16 @@ export const NewPostModalPage = ({ id, hasError, isLoading, onResetError, onSubm setIsConverting(false); }) - .catch(console.error); + .catch((err) => { + console.error(err); + setParams((params) => ({ + ...params, + images: files, + movie: undefined, + sound: undefined, + })); + setIsConverting(false); + }); } }, []); @@ -82,16 +90,27 @@ 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" }), - })); + 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); + }) + .catch((err) => { + console.error(err); + setParams((params) => ({ + ...params, + images: [], + movie: undefined, + sound: file, + })); + setIsConverting(false); + }); } }, []); @@ -116,7 +135,16 @@ export const NewPostModalPage = ({ id, hasError, isLoading, onResetError, onSubm setIsConverting(false); }) - .catch(console.error); + .catch((err) => { + console.error(err); + setParams((params) => ({ + ...params, + images: [], + movie: file, + sound: undefined, + })); + setIsConverting(false); + }); } }, []); diff --git a/application/client/src/components/post/CommentItem.tsx b/application/client/src/components/post/CommentItem.tsx index cb5bd38bda..ea1a92ca2f 100644 --- a/application/client/src/components/post/CommentItem.tsx +++ b/application/client/src/components/post/CommentItem.tsx @@ -1,4 +1,8 @@ -import moment from "moment"; +import dayjs from "dayjs"; +import "dayjs/locale/ja"; +import localizedFormat from "dayjs/plugin/localizedFormat"; + +dayjs.extend(localizedFormat); import { Link } from "@web-speed-hackathon-2026/client/src/components/foundation/Link"; import { TranslatableText } from "@web-speed-hackathon-2026/client/src/components/post/TranslatableText"; @@ -18,8 +22,10 @@ export const CommentItem = ({ comment }: Props) => { to={`/users/${comment.user.username}`} > {comment.user.profileImage.alt}
    @@ -42,8 +48,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..f8d83d97fa 100644 --- a/application/client/src/components/post/ImageArea.tsx +++ b/application/client/src/components/post/ImageArea.tsx @@ -6,9 +6,10 @@ import { getImagePath } from "@web-speed-hackathon-2026/client/src/utils/get_pat interface Props { images: Models.Image[]; + eager?: boolean; } -export const ImageArea = ({ images }: Props) => { +export const ImageArea = ({ images, eager }: Props) => { return (
    @@ -24,7 +25,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/MovieArea.tsx b/application/client/src/components/post/MovieArea.tsx index f9fc54907c..91c9415396 100644 --- a/application/client/src/components/post/MovieArea.tsx +++ b/application/client/src/components/post/MovieArea.tsx @@ -3,15 +3,16 @@ import { getMoviePath } from "@web-speed-hackathon-2026/client/src/utils/get_pat interface Props { movie: Models.Movie; + lazy?: boolean; } -export const MovieArea = ({ movie }: Props) => { +export const MovieArea = ({ movie, lazy }: Props) => { return (
    - +
    ); }; diff --git a/application/client/src/components/post/PostItem.tsx b/application/client/src/components/post/PostItem.tsx index 5fa904c91a..9d37a9e3ae 100644 --- a/application/client/src/components/post/PostItem.tsx +++ b/application/client/src/components/post/PostItem.tsx @@ -1,4 +1,8 @@ -import moment from "moment"; +import dayjs from "dayjs"; +import "dayjs/locale/ja"; +import localizedFormat from "dayjs/plugin/localizedFormat"; + +dayjs.extend(localizedFormat); import { Link } from "@web-speed-hackathon-2026/client/src/components/foundation/Link"; import { ImageArea } from "@web-speed-hackathon-2026/client/src/components/post/ImageArea"; @@ -9,9 +13,10 @@ import { getProfileImagePath } from "@web-speed-hackathon-2026/client/src/utils/ interface Props { post: Models.Post; + eagerImages?: boolean; } -export const PostItem = ({ post }: Props) => { +export const PostItem = ({ post, eagerImages }: Props) => { return (
    @@ -22,8 +27,10 @@ export const PostItem = ({ post }: Props) => { to={`/users/${post.user.username}`} > {post.user.profileImage.alt}
    @@ -52,7 +59,7 @@ export const PostItem = ({ post }: Props) => {
    {post.images?.length > 0 ? (
    - +
    ) : null} {post.movie ? ( @@ -67,8 +74,8 @@ export const PostItem = ({ post }: Props) => { ) : null}

    -

    diff --git a/application/client/src/components/post/PostPage.tsx b/application/client/src/components/post/PostPage.tsx index 5ecd38e95d..4b9d481554 100644 --- a/application/client/src/components/post/PostPage.tsx +++ b/application/client/src/components/post/PostPage.tsx @@ -9,7 +9,7 @@ interface Props { export const PostPage = ({ comments, post }: Props) => { return ( <> - + ); diff --git a/application/client/src/components/post/SoundArea.tsx b/application/client/src/components/post/SoundArea.tsx index 96aab2d5ca..69e16ac1de 100644 --- a/application/client/src/components/post/SoundArea.tsx +++ b/application/client/src/components/post/SoundArea.tsx @@ -2,15 +2,16 @@ import { SoundPlayer } from "@web-speed-hackathon-2026/client/src/components/fou interface Props { sound: Models.Sound; + lazy?: boolean; } -export const SoundArea = ({ sound }: Props) => { +export const SoundArea = ({ sound, lazy }: Props) => { return (
    - +
    ); }; diff --git a/application/client/src/components/timeline/TimelineItem.tsx b/application/client/src/components/timeline/TimelineItem.tsx index 21b88980f8..d7c207dc0b 100644 --- a/application/client/src/components/timeline/TimelineItem.tsx +++ b/application/client/src/components/timeline/TimelineItem.tsx @@ -1,4 +1,8 @@ -import moment from "moment"; +import dayjs from "dayjs"; +import "dayjs/locale/ja"; +import localizedFormat from "dayjs/plugin/localizedFormat"; + +dayjs.extend(localizedFormat); import { MouseEventHandler, useCallback } from "react"; import { Link, useNavigate } from "react-router"; @@ -55,8 +59,10 @@ export const TimelineItem = ({ post }: Props) => { to={`/users/${post.user.username}`} > {post.user.profileImage.alt}
  • @@ -76,8 +82,8 @@ export const TimelineItem = ({ post }: Props) => { - -
    diff --git a/application/client/src/components/user_profile/UserProfileHeader.tsx b/application/client/src/components/user_profile/UserProfileHeader.tsx index c1c3355e19..41397e724e 100644 --- a/application/client/src/components/user_profile/UserProfileHeader.tsx +++ b/application/client/src/components/user_profile/UserProfileHeader.tsx @@ -1,5 +1,9 @@ import { FastAverageColor } from "fast-average-color"; -import moment from "moment"; +import dayjs from "dayjs"; +import "dayjs/locale/ja"; +import localizedFormat from "dayjs/plugin/localizedFormat"; + +dayjs.extend(localizedFormat); import { ReactEventHandler, useCallback, useState } from "react"; import { FontAwesomeIcon } from "@web-speed-hackathon-2026/client/src/components/foundation/FontAwesomeIcon"; @@ -16,7 +20,7 @@ export const UserProfileHeader = ({ user }: Props) => { /** @type {React.ReactEventHandler} */ const handleLoadImage = useCallback>((ev) => { const fac = new FastAverageColor(); - const { rgb } = fac.getColor(ev.currentTarget, { mode: "precision" }); + const { rgb } = fac.getColor(ev.currentTarget, { mode: "speed" }); setAverageColor(rgb); fac.destroy(); }, []); @@ -30,8 +34,10 @@ export const UserProfileHeader = ({ user }: Props) => {
    @@ -43,8 +49,8 @@ export const UserProfileHeader = ({ user }: Props) => { - diff --git a/application/client/src/containers/AppContainer.tsx b/application/client/src/containers/AppContainer.tsx index d66858a949..37f4691c5e 100644 --- a/application/client/src/containers/AppContainer.tsx +++ b/application/client/src/containers/AppContainer.tsx @@ -1,20 +1,56 @@ -import { useCallback, useEffect, useId, useState } from "react"; -import { Helmet, HelmetProvider } from "react-helmet"; +import { Suspense, lazy, 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 { fetchJSON, sendJSON } from "@web-speed-hackathon-2026/client/src/utils/fetchers"; + 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"; + +import { NewPostModalContainer } from "@web-speed-hackathon-2026/client/src/containers/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 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 +60,13 @@ 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]); + .catch(() => {}); + }, []); const handleLogout = useCallback(async () => { await sendJSON("/api/v1/signout", {}); setActiveUser(null); @@ -43,16 +76,6 @@ export const AppContainer = () => { const authModalId = useId(); const newPostModalId = useId(); - if (isLoadingActiveUser) { - return ( - - - 読込中 - CaX - - - ); - } - return ( { newPostModalId={newPostModalId} onLogout={handleLogout} > - - } path="/" /> - - } - path="/dm" - /> - } - path="/dm/:conversationId" - /> - } path="/search" /> - } path="/users/:username" /> - } path="/posts/:postId" /> - } path="/terms" /> - } - path="/crok" - /> - } path="*" /> - + }> + + } path="/" /> + + } + path="/dm" + /> + + } + path="/dm/:conversationId" + /> + } path="/search" /> + } path="/users/:username" /> + } path="/posts/:postId" /> + } path="/terms" /> + } + path="/crok" + /> + } path="*" /> + + diff --git a/application/client/src/containers/AuthModalContainer.tsx b/application/client/src/containers/AuthModalContainer.tsx index 8d159f3528..99b4286214 100644 --- a/application/client/src/containers/AuthModalContainer.tsx +++ b/application/client/src/containers/AuthModalContainer.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { SubmissionError } from "redux-form"; import { AuthFormData } from "@web-speed-hackathon-2026/client/src/auth/types"; import { AuthModalPage } from "@web-speed-hackathon-2026/client/src/components/auth_modal/AuthModalPage"; @@ -16,7 +15,7 @@ const ERROR_MESSAGES: Record = { USERNAME_TAKEN: "ユーザー名が使われています", }; -function getErrorCode(err: JQuery.jqXHR, type: "signin" | "signup"): string { +function getErrorCode(err: { responseJSON?: unknown }, type: "signin" | "signup"): string { const responseJSON = err.responseJSON; if ( typeof responseJSON !== "object" || @@ -43,8 +42,9 @@ export const AuthModalContainer = ({ id, onUpdateActiveUser }: Props) => { const element = ref.current; const handleToggle = () => { - // モーダル開閉時にkeyを更新することでフォームの状態をリセットする - setResetKey((key) => key + 1); + if (!element.open) { + setResetKey((key) => key + 1); + } }; element.addEventListener("toggle", handleToggle); return () => { @@ -57,7 +57,7 @@ export const AuthModalContainer = ({ id, onUpdateActiveUser }: Props) => { }, [ref]); const handleSubmit = useCallback( - async (values: AuthFormData) => { + async (values: AuthFormData): Promise => { try { if (values.type === "signup") { const user = await sendJSON("/api/v1/signup", values); @@ -67,11 +67,9 @@ export const AuthModalContainer = ({ id, onUpdateActiveUser }: Props) => { onUpdateActiveUser(user); } handleRequestCloseModal(); + return undefined; } catch (err: unknown) { - const error = getErrorCode(err as JQuery.jqXHR, values.type); - throw new SubmissionError({ - _error: error, - }); + return getErrorCode(err as { responseJSON?: unknown }, values.type); } }, [handleRequestCloseModal, onUpdateActiveUser], diff --git a/application/client/src/containers/DirectMessageContainer.tsx b/application/client/src/containers/DirectMessageContainer.tsx index 245deac8a6..d052712cb0 100644 --- a/application/client/src/containers/DirectMessageContainer.tsx +++ b/application/client/src/containers/DirectMessageContainer.tsx @@ -13,6 +13,10 @@ interface DmUpdateEvent { type: "dm:conversation:message"; payload: Models.DirectMessage; } +interface DmReadEvent { + type: "dm:conversation:read"; + payload: { senderId: string }; +} interface DmTypingEvent { type: "dm:conversation:typing"; payload: {}; @@ -65,32 +69,56 @@ export const DirectMessageContainer = ({ activeUser, authModalId }: Props) => { async (params: DirectMessageFormData) => { setIsSubmitting(true); try { - await sendJSON(`/api/v1/dm/${conversationId}/messages`, { + const message = await sendJSON(`/api/v1/dm/${conversationId}/messages`, { body: params.body, }); - loadConversation(); + setConversation((prev) => { + if (prev == null) return prev; + return { ...prev, messages: [...(prev.messages ?? []), message] }; + }); } finally { setIsSubmitting(false); } }, - [conversationId, loadConversation], + [conversationId], ); - const handleTyping = useCallback(async () => { + const typingTimeoutRef = useRef | null>(null); + const handleTyping = useCallback(() => { + if (typingTimeoutRef.current !== null) return; void sendJSON(`/api/v1/dm/${conversationId}/typing`, {}); + typingTimeoutRef.current = setTimeout(() => { + typingTimeoutRef.current = null; + }, 2000); }, [conversationId]); - useWs(`/api/v1/dm/${conversationId}`, (event: DmUpdateEvent | DmTypingEvent) => { - if (event.type === "dm:conversation:message") { - void loadConversation().then(() => { - if (event.payload.sender.id !== activeUser?.id) { - setIsPeerTyping(false); - if (peerTypingTimeoutRef.current !== null) { - clearTimeout(peerTypingTimeoutRef.current); - } - peerTypingTimeoutRef.current = null; - } + useWs(`/api/v1/dm/${conversationId}`, (event: DmUpdateEvent | DmReadEvent | DmTypingEvent) => { + if (event.type === "dm:conversation:read") { + setConversation((prev) => { + if (prev == null) return prev; + return { + ...prev, + messages: prev.messages.map((m) => + m.sender.id === event.payload.senderId && !m.isRead + ? { ...m, isRead: true } + : m, + ), + }; + }); + } else if (event.type === "dm:conversation:message") { + setConversation((prev) => { + if (prev == null) return prev; + const exists = prev.messages.some((m) => m.id === event.payload.id); + if (exists) return prev; + return { ...prev, messages: [...prev.messages, event.payload] }; }); + if (event.payload.sender.id !== activeUser?.id) { + setIsPeerTyping(false); + if (peerTypingTimeoutRef.current !== null) { + clearTimeout(peerTypingTimeoutRef.current); + } + peerTypingTimeoutRef.current = null; + } void sendRead(); } else if (event.type === "dm:conversation:typing") { setIsPeerTyping(true); diff --git a/application/client/src/containers/NewDirectMessageModalContainer.tsx b/application/client/src/containers/NewDirectMessageModalContainer.tsx index fb3f6b168c..cc6d724de5 100644 --- a/application/client/src/containers/NewDirectMessageModalContainer.tsx +++ b/application/client/src/containers/NewDirectMessageModalContainer.tsx @@ -1,6 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router"; -import { SubmissionError } from "redux-form"; import { NewDirectMessageModalPage } from "@web-speed-hackathon-2026/client/src/components/direct_message/NewDirectMessageModalPage"; import { Modal } from "@web-speed-hackathon-2026/client/src/components/modal/Modal"; @@ -19,7 +18,9 @@ export const NewDirectMessageModalContainer = ({ id }: Props) => { const element = ref.current; const handleToggle = () => { - setResetKey((key) => key + 1); + if (!element.open) { + setResetKey((key) => key + 1); + } }; element.addEventListener("toggle", handleToggle); return () => { @@ -30,17 +31,16 @@ export const NewDirectMessageModalContainer = ({ id }: Props) => { const navigate = useNavigate(); const handleSubmit = useCallback( - async (values: NewDirectMessageFormData) => { + async (values: NewDirectMessageFormData): Promise => { try { const user = await fetchJSON(`/api/v1/users/${values.username}`); const conversation = await sendJSON(`/api/v1/dm`, { peerId: user.id, }); navigate(`/dm/${conversation.id}`); + return undefined; } catch { - throw new SubmissionError({ - _error: "ユーザーが見つかりませんでした", - }); + return "ユーザーが見つかりませんでした"; } }, [navigate], diff --git a/application/client/src/containers/NewPostModalContainer.tsx b/application/client/src/containers/NewPostModalContainer.tsx index ae9484e878..0d543f8d8a 100644 --- a/application/client/src/containers/NewPostModalContainer.tsx +++ b/application/client/src/containers/NewPostModalContainer.tsx @@ -40,8 +40,10 @@ export const NewPostModalContainer = ({ id }: Props) => { } const handleToggle = () => { - // モーダル開閉時にkeyを更新することでフォームの状態をリセットする - setResetKey((key) => key + 1); + // 閉じた時だけkeyを更新することでフォームの状態をリセットする + if (!element.open) { + setResetKey((key) => key + 1); + } }; element.addEventListener("toggle", handleToggle); return () => { diff --git a/application/client/src/containers/SearchContainer.tsx b/application/client/src/containers/SearchContainer.tsx index f5cdd4148f..4a59d1ce82 100644 --- a/application/client/src/containers/SearchContainer.tsx +++ b/application/client/src/containers/SearchContainer.tsx @@ -3,7 +3,7 @@ import { Helmet } from "react-helmet"; import { SearchPage } from "@web-speed-hackathon-2026/client/src/components/application/SearchPage"; import { InfiniteScroll } from "@web-speed-hackathon-2026/client/src/components/foundation/InfiniteScroll"; import { useInfiniteFetch } from "@web-speed-hackathon-2026/client/src/hooks/use_infinite_fetch"; -import { useSearchParams } from "@web-speed-hackathon-2026/client/src/hooks/use_search_params"; +import { useSearchParams } from "react-router"; import { fetchJSON } from "@web-speed-hackathon-2026/client/src/utils/fetchers"; export const SearchContainer = () => { @@ -20,7 +20,7 @@ export const SearchContainer = () => { 検索 - CaX - + ); }; diff --git a/application/client/src/direct_message/validation.ts b/application/client/src/direct_message/validation.ts index 83220aeffc..53ff44ca9e 100644 --- a/application/client/src/direct_message/validation.ts +++ b/application/client/src/direct_message/validation.ts @@ -1,11 +1,9 @@ -import { FormErrors } from "redux-form"; - import { NewDirectMessageFormData } from "@web-speed-hackathon-2026/client/src/direct_message/types"; export const validate = ( values: NewDirectMessageFormData, -): FormErrors => { - const errors: FormErrors = {}; +): Partial> => { + const errors: Partial> = {}; const normalizedUsername = values.username?.trim().replace(/^@/, "") || ""; diff --git a/application/client/src/hooks/use_has_content_below.ts b/application/client/src/hooks/use_has_content_below.ts index 97795778b8..905a2b5881 100644 --- a/application/client/src/hooks/use_has_content_below.ts +++ b/application/client/src/hooks/use_has_content_below.ts @@ -14,21 +14,26 @@ export function useHasContentBelow( const [hasContentBelow, setHasContentBelow] = useState(false); useEffect(() => { - let active = true; - const check = () => { - if (!active) return; - const endEl = contentEndRef.current; - const barEl = boundaryRef.current; - if (endEl && barEl) { - const endRect = endEl.getBoundingClientRect(); - const barRect = barEl.getBoundingClientRect(); - setHasContentBelow(endRect.top > barRect.top); - } - scheduler.postTask(check, { priority: "user-blocking", delay: 1 }); - }; - scheduler.postTask(check, { priority: "user-blocking", delay: 1 }); + const endEl = contentEndRef.current; + const barEl = boundaryRef.current; + if (!endEl || !barEl) return; + + const observer = new IntersectionObserver( + ([entry]) => { + // entry が画面内に見えていない=コンテンツ末尾がバーより下にある + setHasContentBelow(!entry!.isIntersecting); + }, + { + root: null, + // boundaryRef の高さ分だけ下方向のマージンを縮めて、バーの上端を境界にする + rootMargin: `0px 0px -${barEl.offsetHeight}px 0px`, + threshold: 0, + }, + ); + + observer.observe(endEl); return () => { - active = false; + observer.disconnect(); }; }, [contentEndRef, boundaryRef]); diff --git a/application/client/src/hooks/use_infinite_fetch.ts b/application/client/src/hooks/use_infinite_fetch.ts index 394fccd9ea..7d6b9b7ea0 100644 --- a/application/client/src/hooks/use_infinite_fetch.ts +++ b/application/client/src/hooks/use_infinite_fetch.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; -const LIMIT = 30; +const LIMIT = 10; interface ReturnValues { data: Array; @@ -36,11 +36,14 @@ export function useInfiniteFetch( offset, }; - void fetcher(apiPath).then( - (allData) => { + const separator = apiPath.includes("?") ? "&" : "?"; + const pagedPath = apiPath ? `${apiPath}${separator}limit=${LIMIT}&offset=${offset}` : ""; + + void fetcher(pagedPath).then( + (newData) => { setResult((cur) => ({ ...cur, - data: [...cur.data, ...allData.slice(offset, offset + LIMIT)], + data: [...cur.data, ...newData], isLoading: false, })); internalRef.current = { diff --git a/application/client/src/hooks/use_search_params.ts b/application/client/src/hooks/use_search_params.ts deleted file mode 100644 index 5c7ec50f19..0000000000 --- a/application/client/src/hooks/use_search_params.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useEffect, useRef, useState } from "react"; - -export function useSearchParams(): [URLSearchParams] { - const [searchParams, setSearchParams] = useState( - () => new URLSearchParams(window.location.search), - ); - const lastSearchRef = useRef(window.location.search); - - useEffect(() => { - let active = true; - - const poll = () => { - if (!active) return; - const currentSearch = window.location.search; - if (currentSearch !== lastSearchRef.current) { - lastSearchRef.current = currentSearch; - setSearchParams(new URLSearchParams(currentSearch)); - } - scheduler.postTask(poll, { priority: "user-blocking", delay: 1 }); - }; - - scheduler.postTask(poll, { priority: "user-blocking", delay: 1 }); - - return () => { - active = false; - }; - }, []); - - return [searchParams]; -} diff --git a/application/client/src/hooks/use_sse.ts b/application/client/src/hooks/use_sse.ts index 24532a9c5a..4db5f9ef07 100644 --- a/application/client/src/hooks/use_sse.ts +++ b/application/client/src/hooks/use_sse.ts @@ -19,6 +19,7 @@ export function useSSE(options: SSEOptions): ReturnValues { const [isStreaming, setIsStreaming] = useState(false); const eventSourceRef = useRef(null); const contentRef = useRef(""); + const rafPendingRef = useRef(false); const stop = useCallback(() => { if (eventSourceRef.current) { @@ -56,7 +57,13 @@ export function useSSE(options: SSEOptions): ReturnValues { const newContent = options.onMessage(data, contentRef.current); contentRef.current = newContent; - setContent(newContent); + if (!rafPendingRef.current) { + rafPendingRef.current = true; + requestAnimationFrame(() => { + setContent(contentRef.current); + rafPendingRef.current = false; + }); + } }; eventSource.onerror = (error) => { diff --git a/application/client/src/index.css b/application/client/src/index.css index 8612ebcdd2..a2e61cfde1 100644 --- a/application/client/src/index.css +++ b/application/client/src/index.css @@ -6,7 +6,8 @@ /* 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"); + src: url(/fonts/ReiNoAreMincho-Regular-subset.woff2) format("woff2"), + url(/fonts/ReiNoAreMincho-Regular.otf) format("opentype"); font-weight: normal; } @@ -14,7 +15,8 @@ /* Source Han Serif JP Heavy の Y 軸を 1/1.43 に縮小した改変フォント */ font-family: "Rei no Are Mincho"; font-display: block; - src: url(/fonts/ReiNoAreMincho-Heavy.otf) format("opentype"); + src: url(/fonts/ReiNoAreMincho-Heavy-subset.woff2) format("woff2"), + url(/fonts/ReiNoAreMincho-Heavy.otf) format("opentype"); font-weight: bold; } diff --git a/application/client/src/index.html b/application/client/src/index.html index 3d949e7473..18368fc240 100644 --- a/application/client/src/index.html +++ b/application/client/src/index.html @@ -3,175 +3,11 @@ + CaX - - - - + + +
    diff --git a/application/client/src/index.tsx b/application/client/src/index.tsx index b1833b0af3..b5f932613a 100644 --- a/application/client/src/index.tsx +++ b/application/client/src/index.tsx @@ -1,16 +1,10 @@ import { createRoot } from "react-dom/client"; -import { Provider } from "react-redux"; 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/search/services.ts b/application/client/src/search/services.ts index 77f9de5de9..1ad3a6ecd2 100644 --- a/application/client/src/search/services.ts +++ b/application/client/src/search/services.ts @@ -10,30 +10,18 @@ export const sanitizeSearchText = (input: string): string => { }; export const parseSearchQuery = (query: string) => { - const sincePattern = /since:((\d|\d\d|\d\d\d\d-\d\d-\d\d)+)+$/; - const untilPattern = /until:((\d|\d\d|\d\d\d\d-\d\d-\d\d)+)+$/; - - const sincePart = query.match(/since:[^\s]*/)?.[0] || ""; - const untilPart = query.match(/until:[^\s]*/)?.[0] || ""; - - const sinceMatch = sincePattern.exec(sincePart); - const untilMatch = untilPattern.exec(untilPart); + const sinceDateMatch = /since:(\d{4}-\d{2}-\d{2})/.exec(query); + const untilDateMatch = /until:(\d{4}-\d{2}-\d{2})/.exec(query); const keywords = query - .replace(/since:.*(\d{4}-\d{2}-\d{2}).*/g, "") - .replace(/until:.*(\d{4}-\d{2}-\d{2}).*/g, "") + .replace(/since:\S*/g, "") + .replace(/until:\S*/g, "") .trim(); - const extractDate = (s: string | null) => { - if (!s) return null; - const m = /(\d{4}-\d{2}-\d{2})/.exec(s); - return m ? m[1] : null; - }; - return { keywords, - sinceDate: extractDate(sinceMatch ? sinceMatch[1]! : null), - untilDate: extractDate(untilMatch ? untilMatch[1]! : null), + sinceDate: sinceDateMatch ? sinceDateMatch[1]! : null, + untilDate: untilDateMatch ? untilDateMatch[1]! : null, }; }; diff --git a/application/client/src/search/validation.ts b/application/client/src/search/validation.ts index 1da46e6d8d..f66877cf85 100644 --- a/application/client/src/search/validation.ts +++ b/application/client/src/search/validation.ts @@ -1,13 +1,11 @@ -import { FormErrors } from "redux-form"; - import { parseSearchQuery, isValidDate, } from "@web-speed-hackathon-2026/client/src/search/services"; import { SearchFormData } from "@web-speed-hackathon-2026/client/src/search/types"; -export const validate = (values: SearchFormData): FormErrors => { - const errors: FormErrors = {}; +export const validate = (values: SearchFormData): Partial> => { + const errors: Partial> = {}; const raw = values.searchText?.trim() || ""; if (!raw) { diff --git a/application/client/src/store/index.ts b/application/client/src/store/index.ts deleted file mode 100644 index 694becb2ed..0000000000 --- a/application/client/src/store/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { combineReducers, legacy_createStore as createStore, Dispatch, UnknownAction } from "redux"; -import { reducer as formReducer, FormAction } from "redux-form"; - -const rootReducer = combineReducers({ - form: formReducer, -}); - -export type RootState = ReturnType; -export type AppDispatch = Dispatch; - -export const store = createStore(rootReducer); diff --git a/application/client/src/tailwind.css b/application/client/src/tailwind.css new file mode 100644 index 0000000000..1986d2cf42 --- /dev/null +++ b/application/client/src/tailwind.css @@ -0,0 +1,165 @@ +@import "tailwindcss"; + +@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/utils/bm25_search.ts b/application/client/src/utils/bm25_search.ts index c590d12c09..e2210219b8 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,11 @@ 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 }; - }); - - // スコアが高い(=類似度が高い)ものが下に来るように、上位10件を取得する - return _(results) + const scores = bm25.getScores(queryTokens); + return candidates + .map((text, i) => ({ text, score: scores[i] ?? 0 })) .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..dc8a088345 100644 --- a/application/client/src/utils/convert_image.ts +++ b/application/client/src/utils/convert_image.ts @@ -1,42 +1,31 @@ -import { initializeImageMagick, ImageMagick, MagickFormat } from "@imagemagick/magick-wasm"; -import magickWasm from "@imagemagick/magick-wasm/magick.wasm?binary"; -import { dump, insert, ImageIFD } from "piexifjs"; +export async function convertImage(file: File): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + const url = URL.createObjectURL(file); -interface Options { - extension: MagickFormat; -} - -export async function convertImage(file: File, options: Options): Promise { - await initializeImageMagick(magickWasm); - - const byteArray = new Uint8Array(await file.arrayBuffer()); - - return new Promise((resolve) => { - ImageMagick.read(byteArray, (img) => { - img.format = options.extension; + img.onload = () => { + const canvas = document.createElement("canvas"); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + const ctx = canvas.getContext("2d")!; + ctx.drawImage(img, 0, 0); - const comment = img.comment; + canvas.toBlob( + (blob) => { + URL.revokeObjectURL(url); + if (blob) resolve(blob); + else reject(new Error("Failed to convert image")); + }, + "image/jpeg", + 0.9, + ); + }; - img.write((output) => { - if (comment == null) { - resolve(new Blob([output as Uint8Array])); - return; - } + img.onerror = (e) => { + URL.revokeObjectURL(url); + reject(e); + }; - // 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])); - }); - }); + img.src = url; }); } diff --git a/application/client/src/utils/convert_movie.ts b/application/client/src/utils/convert_movie.ts index fa08b4a003..ca2512aaaf 100644 --- a/application/client/src/utils/convert_movie.ts +++ b/application/client/src/utils/convert_movie.ts @@ -1,43 +1,6 @@ -import { loadFFmpeg } from "@web-speed-hackathon-2026/client/src/utils/load_ffmpeg"; - -interface Options { - extension: string; - size?: number | undefined; -} - /** - * 先頭 5 秒のみ、正方形にくり抜かれた無音動画を作成します + * 動画ファイルをそのままサーバーに送信する(変換はサーバー側で実施) */ -export async function convertMovie(file: File, options: Options): Promise { - const ffmpeg = await loadFFmpeg(); - - const cropOptions = [ - "'min(iw,ih)':'min(iw,ih)'", - options.size ? `scale=${options.size}:${options.size}` : undefined, - ] - .filter(Boolean) - .join(","); - const exportFile = `export.${options.extension}`; - - await ffmpeg.writeFile("file", new Uint8Array(await file.arrayBuffer())); - - await ffmpeg.exec([ - "-i", - "file", - "-t", - "5", - "-r", - "10", - "-vf", - `crop=${cropOptions}`, - "-an", - exportFile, - ]); - - const output = (await ffmpeg.readFile(exportFile)) as Uint8Array; - - ffmpeg.terminate(); - - const blob = new Blob([output]); - return blob; +export async function convertMovie(file: File, _options: { extension: string; size?: number }): Promise { + return file; } diff --git a/application/client/src/utils/convert_sound.ts b/application/client/src/utils/convert_sound.ts index 79cc37ab2d..59485a85cd 100644 --- a/application/client/src/utils/convert_sound.ts +++ b/application/client/src/utils/convert_sound.ts @@ -1,35 +1,6 @@ -import { extractMetadataFromSound } from "@web-speed-hackathon-2026/client/src/utils/extract_metadata_from_sound"; -import { loadFFmpeg } from "@web-speed-hackathon-2026/client/src/utils/load_ffmpeg"; - -interface Options { - extension: string; -} - -export async function convertSound(file: File, options: Options): Promise { - const ffmpeg = await loadFFmpeg(); - - const exportFile = `export.${options.extension}`; - - await ffmpeg.writeFile("file", new Uint8Array(await file.arrayBuffer())); - - // 文字化けを防ぐためにメタデータを抽出して付与し直す - const metadata = await extractMetadataFromSound(file); - - await ffmpeg.exec([ - "-i", - "file", - "-metadata", - `artist=${metadata.artist}`, - "-metadata", - `title=${metadata.title}`, - "-vn", - exportFile, - ]); - - const output = (await ffmpeg.readFile(exportFile)) as Uint8Array; - - ffmpeg.terminate(); - - const blob = new Blob([output]); - return blob; +/** + * 音声ファイルをそのままサーバーに送信する(変換・メタデータ処理はサーバー側で実施) + */ +export async function convertSound(file: File, _options: { extension: string }): Promise { + return file; } diff --git a/application/client/src/utils/create_translator.ts b/application/client/src/utils/create_translator.ts index ad1dabad22..4d006555b3 100644 --- a/application/client/src/utils/create_translator.ts +++ b/application/client/src/utils/create_translator.ts @@ -1,9 +1,3 @@ -import { CreateMLCEngine } from "@mlc-ai/web-llm"; -import { stripIndents } from "common-tags"; -import * as JSONRepairJS from "json-repair-js"; -import langs from "langs"; -import invariant from "tiny-invariant"; - interface Translator { translate(text: string): Promise; [Symbol.dispose](): void; @@ -15,12 +9,23 @@ interface Params { } export async function createTranslator(params: Params): Promise { + const [{ stripIndents }, JSONRepairJS, langsModule, invariantModule] = await Promise.all([ + import("common-tags"), + import("json-repair-js"), + import("langs"), + import("tiny-invariant"), + ]); + + const invariant: typeof import("tiny-invariant").default = invariantModule.default; + const langs = langsModule.default; + const sourceLang = langs.where("1", params.sourceLanguage); invariant(sourceLang, `Unsupported source language code: ${params.sourceLanguage}`); const targetLang = langs.where("1", params.targetLanguage); invariant(targetLang, `Unsupported target language code: ${params.targetLanguage}`); + const { CreateMLCEngine } = await import("@mlc-ai/web-llm"); const engine = await CreateMLCEngine("gemma-2-2b-jpn-it-q4f16_1-MLC"); return { @@ -52,7 +57,7 @@ export async function createTranslator(params: Params): Promise { "The translation result is missing in the reply.", ); - return String(parsed.result); + return String((parsed as { result: unknown }).result); }, [Symbol.dispose]: () => { engine.unload(); diff --git a/application/client/src/utils/extract_metadata_from_sound.ts b/application/client/src/utils/extract_metadata_from_sound.ts deleted file mode 100644 index 5e3ee41fe1..0000000000 --- a/application/client/src/utils/extract_metadata_from_sound.ts +++ /dev/null @@ -1,56 +0,0 @@ -import Encoding from "encoding-japanese"; - -import { loadFFmpeg } from "@web-speed-hackathon-2026/client/src/utils/load_ffmpeg"; - -interface SoundMetadata { - artist: string; - title: string; - [key: string]: string; -} - -const UNKNOWN_ARTIST = "Unknown Artist"; -const UNKNOWN_TITLE = "Unknown Title"; - -export async function extractMetadataFromSound(data: File): Promise { - try { - const ffmpeg = await loadFFmpeg(); - - const exportFile = "meta.txt"; - - await ffmpeg.writeFile("file", new Uint8Array(await data.arrayBuffer())); - - await ffmpeg.exec(["-i", "file", "-f", "ffmetadata", exportFile]); - - const output = (await ffmpeg.readFile(exportFile)) as Uint8Array; - - ffmpeg.terminate(); - - const outputUtf8 = Encoding.convert(output, { - to: "UNICODE", - from: "AUTO", - type: "string", - }); - - const meta = parseFFmetadata(outputUtf8); - - return { - artist: meta.artist ?? UNKNOWN_ARTIST, - title: meta.title ?? UNKNOWN_TITLE, - }; - } catch { - return { - artist: UNKNOWN_ARTIST, - title: UNKNOWN_TITLE, - }; - } -} - -function parseFFmetadata(ffmetadata: string): Partial { - return Object.fromEntries( - ffmetadata - .split("\n") - .filter((line) => !line.startsWith(";") && line.includes("=")) - .map((line) => line.split("=")) - .map(([key, value]) => [key!.trim(), value!.trim()]), - ) as Partial; -} diff --git a/application/client/src/utils/fetchers.ts b/application/client/src/utils/fetchers.ts index 92a14f408f..d0fb3422f7 100644 --- a/application/client/src/utils/fetchers.ts +++ b/application/client/src/utils/fetchers.ts @@ -1,58 +1,44 @@ -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); + 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) { + const json = await response.json().catch(() => null); + throw Object.assign(new Error(`HTTP ${response.status}`), { responseJSON: json }); + } + 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, { + method: "POST", headers: { "Content-Type": "application/octet-stream", }, - method: "POST", - processData: false, - url, + body: file, }); - return result; + if (!response.ok) { + const json = await response.json().catch(() => null); + throw Object.assign(new Error(`HTTP ${response.status}`), { responseJSON: json }); + } + return response.json() as Promise; } export async function sendJSON(url: string, data: object): Promise { - const jsonString = JSON.stringify(data); - 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, { + method: "POST", headers: { - "Content-Encoding": "gzip", "Content-Type": "application/json", }, - method: "POST", - processData: false, - url, + body: JSON.stringify(data), }); - return result; + if (!response.ok) { + const json = await response.json().catch(() => null); + throw Object.assign(new Error(`HTTP ${response.status}`), { responseJSON: json }); + } + return response.json() as Promise; } diff --git a/application/client/src/utils/get_path.ts b/application/client/src/utils/get_path.ts index 0e3497f56c..d7459cad57 100644 --- a/application/client/src/utils/get_path.ts +++ b/application/client/src/utils/get_path.ts @@ -3,7 +3,7 @@ export function getImagePath(imageId: string): string { } export function getMoviePath(movieId: string): string { - return `/movies/${movieId}.gif`; + return `/movies/${movieId}.webm`; } export function getSoundPath(soundId: string): string { diff --git a/application/client/src/utils/load_ffmpeg.ts b/application/client/src/utils/load_ffmpeg.ts deleted file mode 100644 index f923a3d5a4..0000000000 --- a/application/client/src/utils/load_ffmpeg.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { FFmpeg } from "@ffmpeg/ffmpeg"; - -export async function loadFFmpeg(): Promise { - const ffmpeg = new FFmpeg(); - - await ffmpeg.load({ - coreURL: await import("@ffmpeg/core?binary").then(({ default: b }) => { - return URL.createObjectURL(new Blob([b], { type: "text/javascript" })); - }), - wasmURL: await import("@ffmpeg/core/wasm?binary").then(({ default: b }) => { - return URL.createObjectURL(new Blob([b], { type: "application/wasm" })); - }), - }); - - return ffmpeg; -} diff --git a/application/client/src/utils/negaposi_analyzer.ts b/application/client/src/utils/negaposi_analyzer.ts index f81ed5f4ea..25c7a2b8cb 100644 --- a/application/client/src/utils/negaposi_analyzer.ts +++ b/application/client/src/utils/negaposi_analyzer.ts @@ -1,31 +1,16 @@ -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(); -} - type SentimentResult = { score: number; label: "positive" | "negative" | "neutral"; }; export async function analyzeSentiment(text: string): Promise { - const tokenizer = await getTokenizer(); - const tokens = tokenizer.tokenize(text); - - const score = analyze(tokens); - - let label: SentimentResult["label"]; - if (score > 0.1) { - label = "positive"; - } else if (score < -0.1) { - label = "negative"; - } else { - label = "neutral"; + try { + const res = await fetch(`/api/v1/sentiment?text=${encodeURIComponent(text)}`); + if (!res.ok) { + return { score: 0, label: "neutral" }; + } + return (await res.json()) as SentimentResult; + } catch { + return { score: 0, label: "neutral" }; } - - return { score, label }; } diff --git a/application/client/types/models.d.ts b/application/client/types/models.d.ts index 64b36da653..6901ba3764 100644 --- a/application/client/types/models.d.ts +++ b/application/client/types/models.d.ts @@ -6,7 +6,7 @@ declare namespace Models { name: string; password: string; posts: Array; - profileImage: Models.ProfileImage; + profileImage: Models.ProfileImage | null; username: string; } diff --git a/application/client/types/react-augment.d.ts b/application/client/types/react-augment.d.ts new file mode 100644 index 0000000000..9a2965d351 --- /dev/null +++ b/application/client/types/react-augment.d.ts @@ -0,0 +1,7 @@ +import "react"; + +declare module "react" { + interface VideoHTMLAttributes { + fetchPriority?: "high" | "low" | "auto"; + } +} diff --git a/application/client/types/webpack.d.ts b/application/client/types/webpack.d.ts index 4fef94cb2a..2af4ef4f00 100644 --- a/application/client/types/webpack.d.ts +++ b/application/client/types/webpack.d.ts @@ -1,4 +1,4 @@ declare module "*?binary" { - const value: Uint8Array; + const value: string; export default value; } diff --git a/application/client/webpack.config.js b/application/client/webpack.config.js index 9fae72647f..5f34376241 100644 --- a/application/client/webpack.config.js +++ b/application/client/webpack.config.js @@ -1,10 +1,13 @@ /// const path = require("path"); +const CompressionPlugin = require("compression-webpack-plugin"); const CopyWebpackPlugin = require("copy-webpack-plugin"); const HtmlWebpackPlugin = require("html-webpack-plugin"); const MiniCssExtractPlugin = require("mini-css-extract-plugin"); +const Critters = require("critters-webpack-plugin"); const webpack = require("webpack"); +const zlib = require("zlib"); const SRC_PATH = path.resolve(__dirname, "./src"); const PUBLIC_PATH = path.resolve(__dirname, "../public"); @@ -25,18 +28,16 @@ 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, "./tailwind.css"), path.resolve(SRC_PATH, "./index.css"), path.resolve(SRC_PATH, "./buildinfo.ts"), path.resolve(SRC_PATH, "./index.tsx"), ], }, - mode: "none", + mode: "production", module: { rules: [ { @@ -54,30 +55,26 @@ const config = { }, { resourceQuery: /binary/, - type: "asset/bytes", + type: "asset/resource", + generator: { + filename: "assets/[contenthash][ext]", + }, }, ], }, output: { chunkFilename: "scripts/chunk-[contenthash].js", - chunkFormat: false, filename: "scripts/[name].js", path: DIST_PATH, - publicPath: "auto", + publicPath: "/", clean: true, }, 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", @@ -91,35 +88,39 @@ const config = { ], }), new HtmlWebpackPlugin({ - inject: false, + inject: true, + scriptLoading: "defer", template: path.resolve(SRC_PATH, "./index.html"), }), + new Critters({ + preload: "swap", + inlineFonts: false, + pruneSource: false, + }), + new CompressionPlugin({ + filename: "[path][base].gz", + algorithm: "gzip", + test: /\.(js|css)$/, + threshold: 1024, + minRatio: 0.8, + }), + new CompressionPlugin({ + filename: "[path][base].br", + algorithm: "brotliCompress", + compressionOptions: { + params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 }, + }, + test: /\.(js|css)$/, + threshold: 1024, + minRatio: 0.8, + }), ], resolve: { extensions: [".tsx", ".ts", ".mjs", ".cjs", ".jsx", ".js"], alias: { "bayesian-bm25$": path.resolve(__dirname, "node_modules", "bayesian-bm25/dist/index.js"), ["kuromoji$"]: path.resolve(__dirname, "node_modules", "kuromoji/build/kuromoji.js"), - "@ffmpeg/ffmpeg$": path.resolve( - __dirname, - "node_modules", - "@ffmpeg/ffmpeg/dist/esm/index.js", - ), - "@ffmpeg/core$": path.resolve( - __dirname, - "node_modules", - "@ffmpeg/core/dist/umd/ffmpeg-core.js", - ), - "@ffmpeg/core/wasm$": path.resolve( - __dirname, - "node_modules", - "@ffmpeg/core/dist/umd/ffmpeg-core.wasm", - ), - "@imagemagick/magick-wasm/magick.wasm$": path.resolve( - __dirname, - "node_modules", - "@imagemagick/magick-wasm/dist/magick.wasm", - ), + }, fallback: { fs: false, @@ -128,20 +129,33 @@ const config = { }, }, optimization: { - minimize: false, - splitChunks: false, - concatenateModules: false, - usedExports: false, - providedExports: false, - sideEffects: false, + minimize: true, + splitChunks: { + chunks: "all", + cacheGroups: { + react: { + test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/, + name: "vendor-react", + chunks: "all", + priority: 30, + enforce: true, + }, + router: { + test: /[\\/]node_modules[\\/](react-router|react-router-dom)[\\/]/, + name: "vendor-router", + chunks: "all", + priority: 20, + enforce: true, + }, + }, + }, + concatenateModules: true, + usedExports: true, + providedExports: true, + sideEffects: true, }, cache: false, - ignoreWarnings: [ - { - module: /@ffmpeg/, - message: /Critical dependency: the request of a dependency is an expression/, - }, - ], + ignoreWarnings: [], }; module.exports = config; diff --git a/application/e2e/src/home.test.ts b/application/e2e/src/home.test.ts index f6adc82e02..6bcdb1cbb5 100644 --- a/application/e2e/src/home.test.ts +++ b/application/e2e/src/home.test.ts @@ -53,7 +53,7 @@ test.describe("ホーム", () => { test("投稿クリック → 投稿詳細に遷移する", async ({ page }) => { const firstArticle = page.locator("article").first(); await expect(firstArticle).toBeVisible({ timeout: 30_000 }); - await firstArticle.click(); + await firstArticle.locator("time").first().click(); await page.waitForURL("**/posts/*", { timeout: 30_000 }); expect(page.url()).toMatch(/\/posts\/[a-zA-Z0-9-]+/); }); diff --git a/application/e2e/src/post-detail.test.ts b/application/e2e/src/post-detail.test.ts index 7263e67b65..2f9d1bd91a 100644 --- a/application/e2e/src/post-detail.test.ts +++ b/application/e2e/src/post-detail.test.ts @@ -11,7 +11,7 @@ test.describe("投稿詳細", () => { await page.goto("/"); const firstArticle = page.locator("article").first(); await expect(firstArticle).toBeVisible({ timeout: 30_000 }); - await firstArticle.click(); + await firstArticle.locator("time").first().click(); await page.waitForURL("**/posts/*", { timeout: 30_000 }); const article = page.locator("article").first(); @@ -29,7 +29,7 @@ test.describe("投稿詳細", () => { await page.goto("/"); const firstArticle = page.locator("article").first(); await expect(firstArticle).toBeVisible({ timeout: 30_000 }); - await firstArticle.click(); + await firstArticle.locator("time").first().click(); await page.waitForURL("**/posts/*", { timeout: 30_000 }); await expect(page).toHaveTitle(/さんのつぶやき - CaX/, { timeout: 30_000 }); @@ -72,9 +72,9 @@ test.describe("投稿詳細 - 音声", () => { }); test("音声の波形が表示され、再生ボタンで切り替えられる", async ({ page }) => { - await page.goto("/"); + await page.goto("/", { waitUntil: "networkidle" }); const soundArticle = page.locator('article:has(svg[viewBox="0 0 100 1"])').first(); - await expect(soundArticle).toBeVisible({ timeout: 30_000 }); + await expect(soundArticle).toBeVisible({ timeout: 60_000 }); await soundArticle.locator("time").first().click(); await page.waitForURL("**/posts/*", { timeout: 30_000 }); @@ -107,7 +107,7 @@ test.describe("投稿詳細 - 写真", () => { await page.goto("/"); const imageArticle = page.locator("article:has(.grid img)").first(); await expect(imageArticle).toBeVisible({ timeout: 30_000 }); - await imageArticle.click(); + await imageArticle.locator("time").first().click(); await page.waitForURL("**/posts/*", { timeout: 30_000 }); const coveredImage = page.locator(".grid img").first(); diff --git a/application/pnpm-lock.yaml b/application/pnpm-lock.yaml index 510570f5c9..a1500f0b3f 100644 --- a/application/pnpm-lock.yaml +++ b/application/pnpm-lock.yaml @@ -21,15 +21,6 @@ importers: client: dependencies: - '@ffmpeg/core': - specifier: 0.12.10 - version: 0.12.10 - '@ffmpeg/ffmpeg': - specifier: 0.12.15 - version: 0.12.15 - '@imagemagick/magick-wasm': - specifier: 0.0.37 - version: 0.0.37 '@mlc-ai/web-llm': specifier: 0.2.80 version: 0.2.80 @@ -39,21 +30,15 @@ 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 classnames: specifier: 2.5.1 version: 2.5.1 common-tags: specifier: 1.8.2 version: 1.8.2 - core-js: - specifier: 3.45.1 - version: 3.45.1 + dayjs: + specifier: 1.11.20 + version: 1.11.20 encoding-japanese: specifier: 2.2.0 version: 2.2.0 @@ -62,16 +47,10 @@ importers: version: 9.5.0 gifler: specifier: github:themadcreator/gifler#v0.3.0 - version: https://codeload.github.com/themadcreator/gifler/tar.gz/c3259b071c7782f85d4928a5f03d0b378ed003b5 + version: https://codeload.github.com/themadcreator/gifler/tar.gz/89484cb3db174c584a3138e89664f0167a7760c1 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,24 +63,12 @@ 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 normalize.css: specifier: 8.0.1 version: 8.0.1 omggif: specifier: 1.0.10 version: 1.0.10 - pako: - specifier: 2.1.0 - version: 2.1.0 piexifjs: specifier: 1.0.6 version: 1.0.6 @@ -114,24 +81,12 @@ importers: react-helmet: specifier: npm:@dr.pogodin/react-helmet@3.0.4 version: '@dr.pogodin/react-helmet@3.0.4(react@19.2.0)' - react-redux: - specifier: 9.2.0 - version: 9.2.0(@types/react@19.2.2)(react@19.2.0)(redux@5.0.1) react-router: specifier: 7.9.4 version: 7.9.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react-syntax-highlighter: specifier: 16.1.0 version: 16.1.0(react@19.2.0) - redux: - specifier: 5.0.1 - version: 5.0.1 - redux-form: - specifier: 8.3.10 - version: 8.3.10(react-redux@9.2.0(@types/react@19.2.2)(react@19.2.0)(redux@5.0.1))(react@19.2.0)(redux@5.0.1) - regenerator-runtime: - specifier: 0.14.1 - version: 0.14.1 rehype-katex: specifier: 7.0.1 version: 7.0.1 @@ -141,9 +96,6 @@ importers: remark-math: specifier: 6.0.0 version: 6.0.0 - standardized-audio-context: - specifier: 25.3.77 - version: 25.3.77 tiny-invariant: specifier: 1.3.3 version: 1.3.3 @@ -160,39 +112,30 @@ importers: '@babel/preset-typescript': specifier: 7.27.1 version: 7.27.1(@babel/core@7.28.4) + '@tailwindcss/postcss': + specifier: 4.2.2 + version: 4.2.2 '@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 '@types/omggif': specifier: 1.0.5 version: 1.0.5 - '@types/pako': - specifier: 2.0.4 - version: 2.0.4 '@types/piexifjs': specifier: 1.0.0 version: 1.0.0 @@ -205,15 +148,18 @@ importers: '@types/react-syntax-highlighter': specifier: 15.5.13 version: 15.5.13 - '@types/redux-form': - specifier: ^8.3.11 - version: 8.3.11 babel-loader: specifier: 10.0.0 version: 10.0.0(@babel/core@7.28.4)(webpack@5.102.1) + compression-webpack-plugin: + specifier: 12.0.0 + version: 12.0.0(webpack@5.102.1) copy-webpack-plugin: specifier: 13.0.1 version: 13.0.1(webpack@5.102.1) + critters-webpack-plugin: + specifier: 3.0.2 + version: 3.0.2(html-webpack-plugin@5.6.4(webpack@5.102.1)) css-loader: specifier: 7.1.2 version: 7.1.2(webpack@5.102.1) @@ -238,6 +184,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.2 + version: 4.2.2 typescript: specifier: 5.9.3 version: 5.9.3 @@ -277,6 +226,9 @@ importers: body-parser: specifier: 2.2.0 version: 2.2.0 + compression: + specifier: 1.8.0 + version: 1.8.0 connect-history-api-fallback: specifier: 2.0.0 version: 2.0.0 @@ -292,9 +244,15 @@ importers: http-errors: specifier: 2.0.0 version: 2.0.0 + kuromoji: + specifier: 0.1.2 + version: 0.1.2 music-metadata: specifier: 11.10.3 version: 11.10.3 + negaposi-analyzer-ja: + specifier: 1.0.1 + version: 1.0.1 sequelize: specifier: 6.37.7 version: 6.37.7(sqlite3@5.1.7) @@ -323,6 +281,9 @@ importers: '@types/body-parser': specifier: 1.19.6 version: 1.19.6 + '@types/compression': + specifier: 1.7.5 + version: 1.7.5 '@types/connect-history-api-fallback': specifier: 1.5.4 version: 1.5.4 @@ -335,6 +296,9 @@ importers: '@types/http-errors': specifier: 2.0.5 version: 2.0.5 + '@types/kuromoji': + specifier: 0.1.3 + version: 0.1.3 '@types/node': specifier: 22.18.8 version: 22.18.8 @@ -350,6 +314,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'} @@ -1350,24 +1318,9 @@ packages: resolution: {integrity: sha512-rTXwAsIxpCqzUnZvrxVh3L0QA0NzToqWBLAhV+zDV3MIIwiQhAZHMdPCIaj5n/yADu/tyk12wIPgL6YHGXJP+g==} engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'} - '@ffmpeg/core@0.12.10': - resolution: {integrity: sha512-dzNplnn2Nxle2c2i2rrDhqcB19q9cglCkWnoMTDN9Q9l3PvdjZWd1HfSPjCNWc/p8Q3CT+Es9fWOR0UhAeYQZA==} - engines: {node: '>=16.x'} - - '@ffmpeg/ffmpeg@0.12.15': - resolution: {integrity: sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw==} - engines: {node: '>=18.x'} - - '@ffmpeg/types@0.12.4': - resolution: {integrity: sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A==} - engines: {node: '>=16.x'} - '@gar/promisify@1.1.3': resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} - '@imagemagick/magick-wasm@0.0.37': - resolution: {integrity: sha512-tVs9hcWu9u7I3Jz/XvUYVvCEniuxAR+JjZEzI+yKtQmYAtNsLF1WjoH1HZGCKPumaB9jAHZlcf2RGT9+1l3nxQ==} - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1608,6 +1561,98 @@ packages: engines: {node: '>=18'} hasBin: true + '@tailwindcss/node@4.2.2': + resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} + + '@tailwindcss/oxide-android-arm64@4.2.2': + resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.2': + resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} + 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.2': + resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.2': + resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==} + engines: {node: '>= 20'} + + '@tailwindcss/postcss@4.2.2': + resolution: {integrity: sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==} + '@tokenizer/inflate@0.4.1': resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} engines: {node: '>=18'} @@ -1625,9 +1670,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 +1679,9 @@ packages: '@types/common-tags@1.8.4': resolution: {integrity: sha512-S+1hLDJPjWNDhcGxsxEbepzaxWqURP/o+3cP4aa2w7yBXgdcmKGQtZzP8JbyfOd0m+33nh+8+kvxYE2UJtBDkg==} + '@types/compression@1.7.5': + resolution: {integrity: sha512-AAQvK5pxMpaT+nDvhHrsBhLSYG5yQdtkaJE1WYieSNY2mVFKAgmU4ks65rkZD5oqnGCFLyQpUr1CqI4DmUMyDg==} + '@types/connect-history-api-fallback@1.5.4': resolution: {integrity: sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==} @@ -1691,9 +1736,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 +1748,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==} @@ -1727,9 +1766,6 @@ packages: '@types/omggif@1.0.5': resolution: {integrity: sha512-gDQJflz1rOgEcUXkMAl80bDGN46f5mp8GbcM5dyvq+zsFV6YRBRtmNxlJJ5mjY77T7BRkRFzdIBVmK90QYhCxA==} - '@types/pako@2.0.4': - resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} - '@types/piexifjs@1.0.0': resolution: {integrity: sha512-PPiGeCkmkZQgYjvqtjD3kp4OkbCox2vEFVuK4DaLVOIazJLAXk+/ujbizkIPH5CN4AnN9Clo5ckzUlaj3+SzCA==} @@ -1753,9 +1789,6 @@ packages: '@types/react@19.2.2': resolution: {integrity: sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==} - '@types/redux-form@8.3.11': - resolution: {integrity: sha512-aHZgvDt9rg6C/8IHVgooCABxrWzQxJlVP47b6draiMLXKfWbyhYOVf8AV8+UPujRTnVxp1mWb57kL+r/Rl3wNQ==} - '@types/retry@0.12.2': resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==} @@ -1771,9 +1804,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==} @@ -1783,9 +1813,6 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - '@types/use-sync-external-store@0.0.6': - resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} - '@types/validator@13.15.3': resolution: {integrity: sha512-7bcUmDyS6PN3EuD9SlGGOxM77F8WLVsrwkxyWxKnxzmXoequ6c7741QBrANq6htVRGOITJ7z72mTP6Z4XyuG+Q==} @@ -1930,6 +1957,14 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -1951,10 +1986,6 @@ packages: async@2.6.4: resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==} - automation-events@7.1.13: - resolution: {integrity: sha512-1Hay5TQPzxsskSqPTH3YXyzE9Iirz82zZDse2vr3+kOR7Sc7om17qIEPsESchlNX0EgKxANwR40i2g/O3GM1Tw==} - engines: {node: '>=18.2.0'} - autoprefixer@10.4.21: resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==} engines: {node: ^10 || ^12 || >=14} @@ -2052,9 +2083,6 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - buffer@6.0.3: - resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -2088,6 +2116,14 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -2130,6 +2166,19 @@ packages: resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==} engines: {node: '>=6'} + color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-support@1.1.3: resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} hasBin: true @@ -2159,6 +2208,16 @@ packages: resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} engines: {node: '>= 0.6'} + compression-webpack-plugin@12.0.0: + resolution: {integrity: sha512-LR4mS19Jqq41XfA3xVMLrtzVNzqJbUHdzPeLRfQoLiAS9s87f0021fDuU89xxVQFcB6d20ufBkv4j1rQ4OowHw==} + engines: {node: '>= 20.9.0'} + peerDependencies: + webpack: ^5.1.0 + + compression@1.8.0: + resolution: {integrity: sha512-k6WLKfunuqCYD3t6AsuPGvQWaKwuLLh2/xHNcX4qE+vIfDNXpSqnrhwA7O53R7WVQUnt8dVAIW+YHr7xTgOgGA==} + engines: {node: '>= 0.8.0'} + compression@1.8.1: resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} engines: {node: '>= 0.8.0'} @@ -2223,9 +2282,6 @@ packages: core-js-compat@3.45.1: resolution: {integrity: sha512-tqTt5T4PzsMIZ430XGviK4vzYSoeNJ6CXODi6c/voxOT6IZqBht5/EKaSNnYiEjjRYxjVz7DQIsOsY0XNi8PIA==} - core-js@3.45.1: - resolution: {integrity: sha512-L4NPsJlCfZsPeXukyzHFlg/i7IIVwHSItR0wg0FLNqYClJ4MQYTYLbC7EkjKYRLZF2iof2MUgN0EGy7MdQFChg==} - core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -2238,6 +2294,17 @@ packages: typescript: optional: true + critters-webpack-plugin@3.0.2: + resolution: {integrity: sha512-WdGtrjfzTGTuLL1qR9yIrPvrF+r4Fq5MsI+hfjuujLRVzh5UOl1TPCdX4kJU12iK5LFHtbNtezcAJCaXpvozHA==} + peerDependencies: + html-webpack-plugin: ^4.5.2 + peerDependenciesMeta: + html-webpack-plugin: + optional: true + + critters@0.0.16: + resolution: {integrity: sha512-JwjgmO6i3y6RWtLYmXwO5jMd+maZt8Tnfu7VVISmEWyQqfLpB8soBswf8/2bu6SBXxtKA68Al3c+qIG1ApT68A==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2293,6 +2360,9 @@ packages: csv-parse@1.3.3: resolution: {integrity: sha512-byxnDBxM1AVF3YfmsK7Smop9/usNz7gAZYSo9eYp61TGcNXraJby1rAiLyJSt1/8Iho2qaxZOtZCOvQMXogPtg==} + dayjs@1.11.20: + resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} + debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -2426,6 +2496,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==} @@ -2463,9 +2537,6 @@ packages: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} - es6-error@4.1.1: - resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} - esbuild@0.25.11: resolution: {integrity: sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==} engines: {node: '>=18'} @@ -2478,6 +2549,10 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + escape-string-regexp@5.0.0: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} @@ -2667,8 +2742,8 @@ packages: get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} - gifler@https://codeload.github.com/themadcreator/gifler/tar.gz/c3259b071c7782f85d4928a5f03d0b378ed003b5: - resolution: {tarball: https://codeload.github.com/themadcreator/gifler/tar.gz/c3259b071c7782f85d4928a5f03d0b378ed003b5} + gifler@https://codeload.github.com/themadcreator/gifler/tar.gz/89484cb3db174c584a3138e89664f0167a7760c1: + resolution: {tarball: https://codeload.github.com/themadcreator/gifler/tar.gz/89484cb3db174c584a3138e89664f0167a7760c1} version: 0.3.0 github-from-package@0.0.0: @@ -2705,6 +2780,10 @@ packages: handle-thing@2.0.1: resolution: {integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==} + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -2760,9 +2839,6 @@ packages: highlightjs-vue@1.0.0: resolution: {integrity: sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==} - hoist-non-react-statics@3.3.2: - resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} - hpack.js@2.1.6: resolution: {integrity: sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==} @@ -2904,9 +2980,6 @@ packages: resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==} engines: {node: '>=10.13.0'} - invariant@2.2.4: - resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} - ip-address@10.0.1: resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} engines: {node: '>= 12'} @@ -2987,9 +3060,6 @@ packages: resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} engines: {node: '>=0.10.0'} - is-promise@2.2.2: - resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} - is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -3019,13 +3089,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 +3138,80 @@ packages: launch-editor@2.11.1: resolution: {integrity: sha512-SEET7oNfgSaB6Ym0jufAdCeo3meJVeCaaDyzRygy0xsp2BFKCprcfHljTq4QkzTLUxEKkFK6OK4811YM2oSrRg==} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -3100,13 +3237,13 @@ packages: resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} engines: {node: '>= 0.6.0'} + loglevelnext@3.0.1: + resolution: {integrity: sha512-JpjaJhIN1reaSb26SIxDGtE0uc67gPl19OMVHrr+Ggt6b/Vy60jmCtKgQBrygAH0bhRA2nkxgDvM+8QvR8r0YA==} + engines: {node: '>= 6.14.4'} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} - loose-envify@1.4.0: - resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} - hasBin: true - lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} @@ -3120,6 +3257,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'} @@ -3398,6 +3538,9 @@ packages: resolution: {integrity: sha512-j0g/x4cNNZW6I5gdcPAY+GFkJY9WHTpkFDMBJKQLxJQyvSfQbXm57fTE3haGFFuOzCgtsTd4Plwc49Sn9RacDQ==} engines: {node: '>=18'} + nanoid@2.1.11: + resolution: {integrity: sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -3486,10 +3629,6 @@ packages: resolution: {integrity: sha512-jHP15vXVGeVh1HuaA2wY6lxk+whK/x4KBG88VXeRma7CCun7iGD5qPc4eYykQ9sdQvg8jkwFKsSxHln2ybW3xQ==} engines: {node: '>=0.10.0'} - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -3504,6 +3643,10 @@ packages: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} + on-headers@1.0.2: + resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} + engines: {node: '>= 0.8'} + on-headers@1.1.0: resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} engines: {node: '>= 0.8'} @@ -3558,9 +3701,6 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} - pako@2.1.0: - resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} - param-case@3.0.4: resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} @@ -3575,6 +3715,12 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse5-htmlparser2-tree-adapter@6.0.1: + resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} + + parse5@6.0.1: + resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -3853,6 +3999,10 @@ packages: engines: {node: '>=10'} hasBin: true + pretty-bytes@5.6.0: + resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} + engines: {node: '>=6'} + pretty-error@4.0.0: resolution: {integrity: sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==} @@ -3875,9 +4025,6 @@ packages: resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} engines: {node: '>=10'} - prop-types@15.8.1: - resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} - property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} @@ -3924,27 +4071,12 @@ packages: peerDependencies: react: ^19.2.0 - react-is@16.13.1: - resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - react-markdown@10.1.0: resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} peerDependencies: '@types/react': '>=18' react: '>=18' - react-redux@9.2.0: - resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} - peerDependencies: - '@types/react': ^18.2.25 || ^19 - react: ^18.0 || ^19 - redux: ^5.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - redux: - optional: true - react-router@7.9.4: resolution: {integrity: sha512-SD3G8HKviFHg9xj7dNODUKDFgpG4xqD5nhyd0mYoB5iISepuZAvzSr8ywxgxKJ52yRzf/HWtVHc9AWwoTbljvA==} engines: {node: '>=20.0.0'} @@ -3983,24 +4115,6 @@ packages: resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} engines: {node: '>= 10.13.0'} - redux-form@8.3.10: - resolution: {integrity: sha512-Eeog8dJYUxCSZI/oBoy7VkprvMjj1lpUnHa3LwjVNZvYDNeiRZMoZoaAT+6nlK2YQ4aiBopKUMiLe4ihUOHCGg==} - engines: {node: '>=8.10'} - peerDependencies: - immutable: ^3.8.2 || ^4.0.0 - react: ^16.4.2 || ^17.0.0 || ^18.0.0 - react-redux: ^6.0.1 || ^7.0.0 || ^8.0.0 - redux: ^3.7.2 || ^4.0.0 - peerDependenciesMeta: - immutable: - optional: true - - redux@4.2.1: - resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==} - - redux@5.0.1: - resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} - refractor@5.0.0: resolution: {integrity: sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==} @@ -4011,9 +4125,6 @@ packages: regenerate@1.4.2: resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} - regenerator-runtime@0.14.1: - resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} - regexpu-core@6.4.0: resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==} engines: {node: '>=4'} @@ -4181,6 +4292,10 @@ packages: serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + serialize-javascript@7.0.4: + resolution: {integrity: sha512-DuGdB+Po43Q5Jxwpzt1lhyFSYKryqoNjQSA9M92tyw0lyHIOur+XCalOUe0KTJpyqzT8+fQ5A0Jf7vCx/NKmIg==} + engines: {node: '>=20.0.0'} + serve-index@1.9.1: resolution: {integrity: sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==} engines: {node: '>= 0.8.0'} @@ -4261,6 +4376,9 @@ packages: resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + source-list-map@2.0.1: + resolution: {integrity: sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -4289,9 +4407,6 @@ packages: resolution: {integrity: sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==} engines: {node: '>= 8'} - standardized-audio-context@25.3.77: - resolution: {integrity: sha512-Ki9zNz6pKcC5Pi+QPjPyVsD9GwJIJWgryji0XL9cAJXMGyn+dPOf6Qik1AHei0+UNVcc4BOCa0hWLBzlwqsW/A==} - statuses@1.5.0: resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} engines: {node: '>= 0.6'} @@ -4335,6 +4450,14 @@ packages: style-to-object@1.0.14: resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + supports-color@8.1.1: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} @@ -4343,6 +4466,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + tailwindcss@4.2.2: + resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} + tapable@2.3.0: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} @@ -4525,11 +4651,6 @@ packages: peerDependencies: browserslist: '>= 4.21.0' - use-sync-external-store@1.6.0: - resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -4611,10 +4732,17 @@ packages: webpack-cli: optional: true + webpack-log@3.0.2: + resolution: {integrity: sha512-ijm2zgqTY2omtlxRNrtDqxAQOrfAGMxWg9fQB/kuFSeZjx/OkYnfYLqsjf/JkrWOHINMzqxaJDXaog6Mx9KaHg==} + engines: {node: '>= 8.0.0'} + webpack-merge@6.0.1: resolution: {integrity: sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg==} engines: {node: '>=18.0.0'} + webpack-sources@1.4.3: + resolution: {integrity: sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==} + webpack-sources@3.3.3: resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==} engines: {node: '>=10.13.0'} @@ -4691,6 +4819,8 @@ packages: snapshots: + '@alloc/quick-lru@5.2.0': {} + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.27.1 @@ -5797,19 +5927,9 @@ snapshots: '@faker-js/faker@10.2.0': {} - '@ffmpeg/core@0.12.10': {} - - '@ffmpeg/ffmpeg@0.12.15': - dependencies: - '@ffmpeg/types': 0.12.4 - - '@ffmpeg/types@0.12.4': {} - '@gar/promisify@1.1.3': optional: true - '@imagemagick/magick-wasm@0.0.37': {} - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5972,6 +6092,75 @@ snapshots: dependencies: playwright: 1.50.1 + '@tailwindcss/node@4.2.2': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.20.1 + jiti: 2.6.1 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.2 + + '@tailwindcss/oxide-android-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide@4.2.2': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-x64': 4.2.2 + '@tailwindcss/oxide-freebsd-x64': 4.2.2 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.2 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-x64-musl': 4.2.2 + '@tailwindcss/oxide-wasm32-wasi': 4.2.2 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 + + '@tailwindcss/postcss@4.2.2': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.2.2 + '@tailwindcss/oxide': 4.2.2 + postcss: 8.5.6 + tailwindcss: 4.2.2 + '@tokenizer/inflate@0.4.1': dependencies: debug: 4.4.3 @@ -5990,8 +6179,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 +6190,10 @@ snapshots: '@types/common-tags@1.8.4': {} + '@types/compression@1.7.5': + dependencies: + '@types/express': 5.0.3 + '@types/connect-history-api-fallback@1.5.4': dependencies: '@types/express-serve-static-core': 5.1.0 @@ -6079,10 +6270,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 +6280,6 @@ snapshots: '@types/langs@2.0.5': {} - '@types/lodash@4.17.20': {} - '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -6113,8 +6298,6 @@ snapshots: '@types/omggif@1.0.5': {} - '@types/pako@2.0.4': {} - '@types/piexifjs@1.0.0': {} '@types/prismjs@1.26.5': {} @@ -6135,11 +6318,6 @@ snapshots: dependencies: csstype: 3.1.3 - '@types/redux-form@8.3.11': - dependencies: - '@types/react': 19.2.2 - redux: 4.2.1 - '@types/retry@0.12.2': {} '@types/send@0.17.5': @@ -6161,8 +6339,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 @@ -6171,8 +6347,6 @@ snapshots: '@types/unist@3.0.3': {} - '@types/use-sync-external-store@0.0.6': {} - '@types/validator@13.15.3': {} '@types/ws@8.18.1': @@ -6335,6 +6509,14 @@ snapshots: ansi-regex@5.0.1: {} + ansi-styles@3.2.1: + dependencies: + color-convert: 1.9.3 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -6357,11 +6539,6 @@ snapshots: dependencies: lodash: 4.17.21 - automation-events@7.1.13: - dependencies: - '@babel/runtime': 7.28.4 - tslib: 2.8.1 - autoprefixer@10.4.21(postcss@8.5.6): dependencies: browserslist: 4.26.3 @@ -6404,8 +6581,7 @@ snapshots: bail@2.0.2: {} - balanced-match@1.0.2: - optional: true + balanced-match@1.0.2: {} base64-js@1.5.1: {} @@ -6476,7 +6652,6 @@ snapshots: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - optional: true braces@3.0.3: dependencies: @@ -6497,11 +6672,6 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 - buffer@6.0.3: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 @@ -6553,6 +6723,17 @@ snapshots: ccount@2.0.1: {} + chalk@2.4.2: + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -6594,6 +6775,18 @@ snapshots: kind-of: 6.0.3 shallow-clone: 3.0.1 + color-convert@1.9.3: + dependencies: + color-name: 1.1.3 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.3: {} + + color-name@1.1.4: {} + color-support@1.1.3: optional: true @@ -6613,6 +6806,24 @@ snapshots: dependencies: mime-db: 1.54.0 + compression-webpack-plugin@12.0.0(webpack@5.102.1): + dependencies: + schema-utils: 4.3.3 + serialize-javascript: 7.0.4 + webpack: 5.102.1(webpack-cli@6.0.1) + + compression@1.8.0: + dependencies: + bytes: 3.1.2 + compressible: 2.0.18 + debug: 2.6.9 + negotiator: 0.6.4 + on-headers: 1.0.2 + safe-buffer: 5.2.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + compression@1.8.1: dependencies: bytes: 3.1.2 @@ -6625,8 +6836,7 @@ snapshots: transitivePeerDependencies: - supports-color - concat-map@0.0.1: - optional: true + concat-map@0.0.1: {} concat-stream@1.6.2: dependencies: @@ -6677,8 +6887,6 @@ snapshots: dependencies: browserslist: 4.26.3 - core-js@3.45.1: {} - core-util-is@1.0.3: {} cosmiconfig@9.0.0(typescript@5.9.3): @@ -6690,6 +6898,24 @@ snapshots: optionalDependencies: typescript: 5.9.3 + critters-webpack-plugin@3.0.2(html-webpack-plugin@5.6.4(webpack@5.102.1)): + dependencies: + critters: 0.0.16 + minimatch: 3.1.2 + webpack-log: 3.0.2 + webpack-sources: 1.4.3 + optionalDependencies: + html-webpack-plugin: 5.6.4(webpack@5.102.1) + + critters@0.0.16: + dependencies: + chalk: 4.1.2 + css-select: 4.3.0 + parse5: 6.0.1 + parse5-htmlparser2-tree-adapter: 6.0.1 + postcss: 8.5.6 + pretty-bytes: 5.6.0 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -6743,6 +6969,8 @@ snapshots: csv-parse@1.3.3: {} + dayjs@1.11.20: {} + debug@2.6.9: dependencies: ms: 2.0.0 @@ -6856,6 +7084,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: {} @@ -6881,8 +7114,6 @@ snapshots: dependencies: es-errors: 1.3.0 - es6-error@4.1.1: {} - esbuild@0.25.11: optionalDependencies: '@esbuild/aix-ppc64': 0.25.11 @@ -6916,6 +7147,8 @@ snapshots: escape-html@1.0.3: {} + escape-string-regexp@1.0.5: {} + escape-string-regexp@5.0.0: {} eslint-scope@5.1.1: @@ -7161,7 +7394,7 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - gifler@https://codeload.github.com/themadcreator/gifler/tar.gz/c3259b071c7782f85d4928a5f03d0b378ed003b5: + gifler@https://codeload.github.com/themadcreator/gifler/tar.gz/89484cb3db174c584a3138e89664f0167a7760c1: dependencies: bluebird: 3.7.2 omggif: 1.0.10 @@ -7198,6 +7431,8 @@ snapshots: handle-thing@2.0.1: {} + has-flag@3.0.0: {} + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -7295,10 +7530,6 @@ snapshots: highlightjs-vue@1.0.0: {} - hoist-non-react-statics@3.3.2: - dependencies: - react-is: 16.13.1 - hpack.js@2.1.6: dependencies: inherits: 2.0.4 @@ -7458,10 +7689,6 @@ snapshots: interpret@3.1.1: {} - invariant@2.2.4: - dependencies: - loose-envify: 1.4.0 - ip-address@10.0.1: optional: true @@ -7520,8 +7747,6 @@ snapshots: dependencies: isobject: 3.0.1 - is-promise@2.2.2: {} - is-promise@4.0.0: {} is-stream@1.1.0: {} @@ -7544,10 +7769,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 +7814,55 @@ snapshots: picocolors: 1.1.1 shell-quote: 1.8.3 + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + lines-and-columns@1.2.4: {} loader-runner@4.3.1: {} @@ -7611,11 +7881,9 @@ snapshots: loglevel@1.9.2: {} - longest-streak@3.1.0: {} + loglevelnext@3.0.1: {} - loose-envify@1.4.0: - dependencies: - js-tokens: 4.0.0 + longest-streak@3.1.0: {} lower-case@2.0.2: dependencies: @@ -7635,6 +7903,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 @@ -8081,7 +8353,6 @@ snapshots: minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 - optional: true minimist@1.2.8: {} @@ -8162,6 +8433,8 @@ snapshots: transitivePeerDependencies: - supports-color + nanoid@2.1.11: {} + nanoid@3.3.11: {} napi-build-utils@2.0.0: {} @@ -8252,8 +8525,6 @@ snapshots: object-assign@3.0.0: {} - object-assign@4.1.1: {} - object-inspect@1.13.4: {} obuf@1.1.2: {} @@ -8264,6 +8535,8 @@ snapshots: dependencies: ee-first: 1.1.1 + on-headers@1.0.2: {} + on-headers@1.1.0: {} once@1.4.0: @@ -8341,8 +8614,6 @@ snapshots: p-try@2.2.0: {} - pako@2.1.0: {} - param-case@3.0.4: dependencies: dot-case: 3.0.4 @@ -8369,6 +8640,12 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse5-htmlparser2-tree-adapter@6.0.1: + dependencies: + parse5: 6.0.1 + + parse5@6.0.1: {} + parse5@7.3.0: dependencies: entities: 6.0.1 @@ -8700,6 +8977,8 @@ snapshots: tar-fs: 2.1.4 tunnel-agent: 0.6.0 + pretty-bytes@5.6.0: {} + pretty-error@4.0.0: dependencies: lodash: 4.17.21 @@ -8718,12 +8997,6 @@ snapshots: retry: 0.12.0 optional: true - prop-types@15.8.1: - dependencies: - loose-envify: 1.4.0 - object-assign: 4.1.1 - react-is: 16.13.1 - property-information@7.1.0: {} proxy-addr@2.0.7: @@ -8778,8 +9051,6 @@ snapshots: react: 19.2.0 scheduler: 0.27.0 - react-is@16.13.1: {} - react-markdown@10.1.0(@types/react@19.2.2)(react@19.2.0): dependencies: '@types/hast': 3.0.4 @@ -8798,15 +9069,6 @@ snapshots: transitivePeerDependencies: - supports-color - react-redux@9.2.0(@types/react@19.2.2)(react@19.2.0)(redux@5.0.1): - dependencies: - '@types/use-sync-external-store': 0.0.6 - react: 19.2.0 - use-sync-external-store: 1.6.0(react@19.2.0) - optionalDependencies: - '@types/react': 19.2.2 - redux: 5.0.1 - react-router@7.9.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: cookie: 1.0.2 @@ -8855,26 +9117,6 @@ snapshots: dependencies: resolve: 1.22.10 - redux-form@8.3.10(react-redux@9.2.0(@types/react@19.2.2)(react@19.2.0)(redux@5.0.1))(react@19.2.0)(redux@5.0.1): - dependencies: - '@babel/runtime': 7.28.4 - es6-error: 4.1.1 - hoist-non-react-statics: 3.3.2 - invariant: 2.2.4 - is-promise: 2.2.2 - lodash: 4.17.21 - prop-types: 15.8.1 - react: 19.2.0 - react-is: 16.13.1 - react-redux: 9.2.0(@types/react@19.2.2)(react@19.2.0)(redux@5.0.1) - redux: 5.0.1 - - redux@4.2.1: - dependencies: - '@babel/runtime': 7.28.4 - - redux@5.0.1: {} - refractor@5.0.0: dependencies: '@types/hast': 3.0.4 @@ -8888,8 +9130,6 @@ snapshots: regenerate@1.4.2: {} - regenerator-runtime@0.14.1: {} - regexpu-core@6.4.0: dependencies: regenerate: 1.4.2 @@ -9101,6 +9341,8 @@ snapshots: dependencies: randombytes: 2.1.0 + serialize-javascript@7.0.4: {} + serve-index@1.9.1: dependencies: accepts: 1.3.8 @@ -9215,6 +9457,8 @@ snapshots: smart-buffer: 4.2.0 optional: true + source-list-map@2.0.1: {} + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -9264,12 +9508,6 @@ snapshots: minipass: 3.3.6 optional: true - standardized-audio-context@25.3.77: - dependencies: - '@babel/runtime': 7.28.4 - automation-events: 7.1.13 - tslib: 2.8.1 - statuses@1.5.0: {} statuses@2.0.1: {} @@ -9314,12 +9552,22 @@ snapshots: dependencies: inline-style-parser: 0.2.7 + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + supports-color@8.1.1: dependencies: has-flag: 4.0.0 supports-preserve-symlinks-flag@1.0.0: {} + tailwindcss@4.2.2: {} + tapable@2.3.0: {} tar-fs@2.1.4: @@ -9509,10 +9757,6 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - use-sync-external-store@1.6.0(react@19.2.0): - dependencies: - react: 19.2.0 - util-deprecate@1.0.2: {} utila@0.4.0: {} @@ -9622,12 +9866,23 @@ snapshots: - supports-color - utf-8-validate + webpack-log@3.0.2: + dependencies: + chalk: 2.4.2 + loglevelnext: 3.0.1 + nanoid: 2.1.11 + webpack-merge@6.0.1: dependencies: clone-deep: 4.0.1 flat: 5.0.2 wildcard: 2.0.1 + webpack-sources@1.4.3: + dependencies: + source-list-map: 2.0.1 + source-map: 0.6.1 + webpack-sources@3.3.3: {} webpack@5.102.1(webpack-cli@6.0.1): diff --git a/application/public/fonts/ReiNoAreMincho-Heavy-subset.woff2 b/application/public/fonts/ReiNoAreMincho-Heavy-subset.woff2 new file mode 100644 index 0000000000..e24e89bf5b Binary files /dev/null and b/application/public/fonts/ReiNoAreMincho-Heavy-subset.woff2 differ diff --git a/application/public/fonts/ReiNoAreMincho-Heavy.woff2 b/application/public/fonts/ReiNoAreMincho-Heavy.woff2 new file mode 100644 index 0000000000..fd37a1e249 Binary files /dev/null and b/application/public/fonts/ReiNoAreMincho-Heavy.woff2 differ diff --git a/application/public/fonts/ReiNoAreMincho-Regular-subset.woff2 b/application/public/fonts/ReiNoAreMincho-Regular-subset.woff2 new file mode 100644 index 0000000000..b898d1f83d Binary files /dev/null and b/application/public/fonts/ReiNoAreMincho-Regular-subset.woff2 differ diff --git a/application/public/fonts/ReiNoAreMincho-Regular.woff2 b/application/public/fonts/ReiNoAreMincho-Regular.woff2 new file mode 100644 index 0000000000..fb4f1daf4f Binary files /dev/null and b/application/public/fonts/ReiNoAreMincho-Regular.woff2 differ diff --git a/application/public/images/029b4b75-bbcc-4aa5-8bd7-e4bb12a33cd3.webp b/application/public/images/029b4b75-bbcc-4aa5-8bd7-e4bb12a33cd3.webp new file mode 100644 index 0000000000..106cdf39bc Binary files /dev/null and b/application/public/images/029b4b75-bbcc-4aa5-8bd7-e4bb12a33cd3.webp differ diff --git a/application/public/images/078c4d42-12e3-4c1d-823c-9ba552f6b066.webp b/application/public/images/078c4d42-12e3-4c1d-823c-9ba552f6b066.webp new file mode 100644 index 0000000000..0275fe5b8d Binary files /dev/null and b/application/public/images/078c4d42-12e3-4c1d-823c-9ba552f6b066.webp differ diff --git a/application/public/images/083258be-3e8c-4537-ac9c-fd5fd9cd943b.webp b/application/public/images/083258be-3e8c-4537-ac9c-fd5fd9cd943b.webp new file mode 100644 index 0000000000..e8d3bafd15 Binary files /dev/null and b/application/public/images/083258be-3e8c-4537-ac9c-fd5fd9cd943b.webp differ diff --git a/application/public/images/18358ca6-0aa7-4592-9926-1ec522b9aacb.webp b/application/public/images/18358ca6-0aa7-4592-9926-1ec522b9aacb.webp new file mode 100644 index 0000000000..bbd84f0f15 Binary files /dev/null and b/application/public/images/18358ca6-0aa7-4592-9926-1ec522b9aacb.webp differ diff --git a/application/public/images/19b3516f-ccfc-4d76-a45c-fc2aade43afe.webp b/application/public/images/19b3516f-ccfc-4d76-a45c-fc2aade43afe.webp new file mode 100644 index 0000000000..9544693beb Binary files /dev/null and b/application/public/images/19b3516f-ccfc-4d76-a45c-fc2aade43afe.webp differ diff --git a/application/public/images/26117ade-f330-46a2-8c48-767b6f472613.webp b/application/public/images/26117ade-f330-46a2-8c48-767b6f472613.webp new file mode 100644 index 0000000000..98c4d05861 Binary files /dev/null and b/application/public/images/26117ade-f330-46a2-8c48-767b6f472613.webp differ diff --git a/application/public/images/3a5915dc-6ef0-4c66-ad4b-bba9c724cfbc.webp b/application/public/images/3a5915dc-6ef0-4c66-ad4b-bba9c724cfbc.webp new file mode 100644 index 0000000000..55d3a37ccf Binary files /dev/null and b/application/public/images/3a5915dc-6ef0-4c66-ad4b-bba9c724cfbc.webp differ diff --git a/application/public/images/4685b32a-43d2-4478-bb79-2cdb56f8ecf0.webp b/application/public/images/4685b32a-43d2-4478-bb79-2cdb56f8ecf0.webp new file mode 100644 index 0000000000..bf6365819f Binary files /dev/null and b/application/public/images/4685b32a-43d2-4478-bb79-2cdb56f8ecf0.webp differ diff --git a/application/public/images/49b8af97-9536-4a23-86f6-21526ff2715b.webp b/application/public/images/49b8af97-9536-4a23-86f6-21526ff2715b.webp new file mode 100644 index 0000000000..b1f66c2c4b Binary files /dev/null and b/application/public/images/49b8af97-9536-4a23-86f6-21526ff2715b.webp differ diff --git a/application/public/images/5be3fce7-0365-4aa3-a1b6-cdeb553e8dfb.webp b/application/public/images/5be3fce7-0365-4aa3-a1b6-cdeb553e8dfb.webp new file mode 100644 index 0000000000..0c69fd1377 Binary files /dev/null and b/application/public/images/5be3fce7-0365-4aa3-a1b6-cdeb553e8dfb.webp differ diff --git a/application/public/images/5e7212da-6b4c-4eb2-b828-b0bc35bfbc1c.webp b/application/public/images/5e7212da-6b4c-4eb2-b828-b0bc35bfbc1c.webp new file mode 100644 index 0000000000..9b2dc36eef Binary files /dev/null and b/application/public/images/5e7212da-6b4c-4eb2-b828-b0bc35bfbc1c.webp differ diff --git a/application/public/images/6d532fa5-daff-4876-a26f-b5c8669d1176.webp b/application/public/images/6d532fa5-daff-4876-a26f-b5c8669d1176.webp new file mode 100644 index 0000000000..387e39c575 Binary files /dev/null and b/application/public/images/6d532fa5-daff-4876-a26f-b5c8669d1176.webp differ diff --git a/application/public/images/737f764e-f495-4104-b6d6-8434681718d5.webp b/application/public/images/737f764e-f495-4104-b6d6-8434681718d5.webp new file mode 100644 index 0000000000..9dcdf332c6 Binary files /dev/null and b/application/public/images/737f764e-f495-4104-b6d6-8434681718d5.webp differ diff --git a/application/public/images/77284ba9-06c0-4c66-92a9-4d2513336e24.webp b/application/public/images/77284ba9-06c0-4c66-92a9-4d2513336e24.webp new file mode 100644 index 0000000000..9a2c805c18 Binary files /dev/null and b/application/public/images/77284ba9-06c0-4c66-92a9-4d2513336e24.webp differ diff --git a/application/public/images/824ddc65-8afc-4cd5-8176-1a8053758e72.webp b/application/public/images/824ddc65-8afc-4cd5-8176-1a8053758e72.webp new file mode 100644 index 0000000000..ab31185b4c Binary files /dev/null and b/application/public/images/824ddc65-8afc-4cd5-8176-1a8053758e72.webp differ diff --git a/application/public/images/85946f86-c0bd-4d6b-83b7-94eb32dcbcf4.webp b/application/public/images/85946f86-c0bd-4d6b-83b7-94eb32dcbcf4.webp new file mode 100644 index 0000000000..634fd107b2 Binary files /dev/null and b/application/public/images/85946f86-c0bd-4d6b-83b7-94eb32dcbcf4.webp differ diff --git a/application/public/images/9bb2f5c0-0f7c-4b9d-8e6a-aa87ebe7efc5.webp b/application/public/images/9bb2f5c0-0f7c-4b9d-8e6a-aa87ebe7efc5.webp new file mode 100644 index 0000000000..9ca4eef0ab Binary files /dev/null and b/application/public/images/9bb2f5c0-0f7c-4b9d-8e6a-aa87ebe7efc5.webp differ diff --git a/application/public/images/9c8c5258-f659-4890-8b7f-0485097d957b.webp b/application/public/images/9c8c5258-f659-4890-8b7f-0485097d957b.webp new file mode 100644 index 0000000000..bab8f28126 Binary files /dev/null and b/application/public/images/9c8c5258-f659-4890-8b7f-0485097d957b.webp differ diff --git a/application/public/images/a21c9b2c-9fc7-4d3c-8488-a465150f7b1c.webp b/application/public/images/a21c9b2c-9fc7-4d3c-8488-a465150f7b1c.webp new file mode 100644 index 0000000000..e75073c114 Binary files /dev/null and b/application/public/images/a21c9b2c-9fc7-4d3c-8488-a465150f7b1c.webp differ diff --git a/application/public/images/af15685e-2e43-4453-bc8f-55e386bd5963.webp b/application/public/images/af15685e-2e43-4453-bc8f-55e386bd5963.webp new file mode 100644 index 0000000000..2b9e720394 Binary files /dev/null and b/application/public/images/af15685e-2e43-4453-bc8f-55e386bd5963.webp differ diff --git a/application/public/images/af15f1d0-8350-46f4-9652-e02eb31469da.webp b/application/public/images/af15f1d0-8350-46f4-9652-e02eb31469da.webp new file mode 100644 index 0000000000..425425983d Binary files /dev/null and b/application/public/images/af15f1d0-8350-46f4-9652-e02eb31469da.webp differ diff --git a/application/public/images/c095fdc4-eb78-4ae1-9efa-4b8e360177ce.webp b/application/public/images/c095fdc4-eb78-4ae1-9efa-4b8e360177ce.webp new file mode 100644 index 0000000000..1538ce91fb Binary files /dev/null and b/application/public/images/c095fdc4-eb78-4ae1-9efa-4b8e360177ce.webp differ diff --git a/application/public/images/da2bfcde-14fd-473c-ae79-572d95152b61.webp b/application/public/images/da2bfcde-14fd-473c-ae79-572d95152b61.webp new file mode 100644 index 0000000000..6199d6e332 Binary files /dev/null and b/application/public/images/da2bfcde-14fd-473c-ae79-572d95152b61.webp differ diff --git a/application/public/images/ddc7053e-0f2f-49b1-9c07-e1060e2fa4aa.webp b/application/public/images/ddc7053e-0f2f-49b1-9c07-e1060e2fa4aa.webp new file mode 100644 index 0000000000..f60a16f9d7 Binary files /dev/null and b/application/public/images/ddc7053e-0f2f-49b1-9c07-e1060e2fa4aa.webp differ diff --git a/application/public/images/e40ff559-d0d3-4eb0-8792-21cb171b815c.webp b/application/public/images/e40ff559-d0d3-4eb0-8792-21cb171b815c.webp new file mode 100644 index 0000000000..420fcd909c Binary files /dev/null and b/application/public/images/e40ff559-d0d3-4eb0-8792-21cb171b815c.webp differ diff --git a/application/public/images/eb487309-79ed-40d0-9fee-382ed8486b70.webp b/application/public/images/eb487309-79ed-40d0-9fee-382ed8486b70.webp new file mode 100644 index 0000000000..bf54a8eb4d Binary files /dev/null and b/application/public/images/eb487309-79ed-40d0-9fee-382ed8486b70.webp differ diff --git a/application/public/images/ec098438-5fac-44a8-bd5a-84c575a32790.webp b/application/public/images/ec098438-5fac-44a8-bd5a-84c575a32790.webp new file mode 100644 index 0000000000..575d228434 Binary files /dev/null and b/application/public/images/ec098438-5fac-44a8-bd5a-84c575a32790.webp differ diff --git a/application/public/images/ee6d7cb7-3c05-4bde-92e5-aebef3785904.webp b/application/public/images/ee6d7cb7-3c05-4bde-92e5-aebef3785904.webp new file mode 100644 index 0000000000..fc698b5ba5 Binary files /dev/null and b/application/public/images/ee6d7cb7-3c05-4bde-92e5-aebef3785904.webp differ diff --git a/application/public/images/f046441d-b837-4dc7-b0ae-5cf2604eab4c.webp b/application/public/images/f046441d-b837-4dc7-b0ae-5cf2604eab4c.webp new file mode 100644 index 0000000000..c9b6a9eedd Binary files /dev/null and b/application/public/images/f046441d-b837-4dc7-b0ae-5cf2604eab4c.webp differ diff --git a/application/public/images/f478a152-02f8-46a3-91ce-d1d7944d303a.webp b/application/public/images/f478a152-02f8-46a3-91ce-d1d7944d303a.webp new file mode 100644 index 0000000000..f0e9333490 Binary files /dev/null and b/application/public/images/f478a152-02f8-46a3-91ce-d1d7944d303a.webp differ diff --git a/application/public/images/profiles/09d52cbb-28a2-4413-b220-1f8c9e80a440.webp b/application/public/images/profiles/09d52cbb-28a2-4413-b220-1f8c9e80a440.webp new file mode 100644 index 0000000000..0c18300a4c Binary files /dev/null and b/application/public/images/profiles/09d52cbb-28a2-4413-b220-1f8c9e80a440.webp differ diff --git a/application/public/images/profiles/0aba06a6-1b56-4ebd-8218-951aaba173af.webp b/application/public/images/profiles/0aba06a6-1b56-4ebd-8218-951aaba173af.webp new file mode 100644 index 0000000000..ec2d182fcd Binary files /dev/null and b/application/public/images/profiles/0aba06a6-1b56-4ebd-8218-951aaba173af.webp differ diff --git a/application/public/images/profiles/0ccabdd2-4601-4c2f-88f5-1848b06ef035.webp b/application/public/images/profiles/0ccabdd2-4601-4c2f-88f5-1848b06ef035.webp new file mode 100644 index 0000000000..38bd831adc Binary files /dev/null and b/application/public/images/profiles/0ccabdd2-4601-4c2f-88f5-1848b06ef035.webp differ diff --git a/application/public/images/profiles/25dde9ae-1dd3-4d23-bfd3-90a94b59816c.webp b/application/public/images/profiles/25dde9ae-1dd3-4d23-bfd3-90a94b59816c.webp new file mode 100644 index 0000000000..4180b241bf Binary files /dev/null and b/application/public/images/profiles/25dde9ae-1dd3-4d23-bfd3-90a94b59816c.webp differ diff --git a/application/public/images/profiles/2d5ef610-a9e5-426c-9eeb-916a9b753d55.webp b/application/public/images/profiles/2d5ef610-a9e5-426c-9eeb-916a9b753d55.webp new file mode 100644 index 0000000000..30ea66f75e Binary files /dev/null and b/application/public/images/profiles/2d5ef610-a9e5-426c-9eeb-916a9b753d55.webp differ diff --git a/application/public/images/profiles/36079dc7-dd73-4073-aceb-7d5c1f0dab4e.webp b/application/public/images/profiles/36079dc7-dd73-4073-aceb-7d5c1f0dab4e.webp new file mode 100644 index 0000000000..1a9e24c1cc Binary files /dev/null and b/application/public/images/profiles/36079dc7-dd73-4073-aceb-7d5c1f0dab4e.webp differ diff --git a/application/public/images/profiles/37812068-9ef8-4429-b219-8d9c9b91c89c.webp b/application/public/images/profiles/37812068-9ef8-4429-b219-8d9c9b91c89c.webp new file mode 100644 index 0000000000..d6b7cdd40b Binary files /dev/null and b/application/public/images/profiles/37812068-9ef8-4429-b219-8d9c9b91c89c.webp differ diff --git a/application/public/images/profiles/396fe4ce-aa36-4d96-b54e-6db40bae2eed.webp b/application/public/images/profiles/396fe4ce-aa36-4d96-b54e-6db40bae2eed.webp new file mode 100644 index 0000000000..8d944ff6fc Binary files /dev/null and b/application/public/images/profiles/396fe4ce-aa36-4d96-b54e-6db40bae2eed.webp differ diff --git a/application/public/images/profiles/3d43c4e2-6eaf-4bb9-bff3-cb955440c891.webp b/application/public/images/profiles/3d43c4e2-6eaf-4bb9-bff3-cb955440c891.webp new file mode 100644 index 0000000000..f731c4d432 Binary files /dev/null and b/application/public/images/profiles/3d43c4e2-6eaf-4bb9-bff3-cb955440c891.webp differ diff --git a/application/public/images/profiles/3dd3640a-5f9e-40d0-8daf-bfdb473b129e.webp b/application/public/images/profiles/3dd3640a-5f9e-40d0-8daf-bfdb473b129e.webp new file mode 100644 index 0000000000..6dd8a68842 Binary files /dev/null and b/application/public/images/profiles/3dd3640a-5f9e-40d0-8daf-bfdb473b129e.webp differ diff --git a/application/public/images/profiles/51874337-0b42-4b03-8e3d-fbd4960a9947.webp b/application/public/images/profiles/51874337-0b42-4b03-8e3d-fbd4960a9947.webp new file mode 100644 index 0000000000..a1577ffc6e Binary files /dev/null and b/application/public/images/profiles/51874337-0b42-4b03-8e3d-fbd4960a9947.webp differ diff --git a/application/public/images/profiles/52c82d1c-b455-4572-aef1-0dd61b50b1d2.webp b/application/public/images/profiles/52c82d1c-b455-4572-aef1-0dd61b50b1d2.webp new file mode 100644 index 0000000000..0d89d2b4ae Binary files /dev/null and b/application/public/images/profiles/52c82d1c-b455-4572-aef1-0dd61b50b1d2.webp differ diff --git a/application/public/images/profiles/538dbca6-85d6-434e-a1f4-b370d03dbb85.webp b/application/public/images/profiles/538dbca6-85d6-434e-a1f4-b370d03dbb85.webp new file mode 100644 index 0000000000..684d31b344 Binary files /dev/null and b/application/public/images/profiles/538dbca6-85d6-434e-a1f4-b370d03dbb85.webp differ diff --git a/application/public/images/profiles/5506d25e-f03b-497a-a883-6434aa160d0f.webp b/application/public/images/profiles/5506d25e-f03b-497a-a883-6434aa160d0f.webp new file mode 100644 index 0000000000..890d982e85 Binary files /dev/null and b/application/public/images/profiles/5506d25e-f03b-497a-a883-6434aa160d0f.webp differ diff --git a/application/public/images/profiles/5e071af0-e9a1-4c5c-859f-464c18bb7da9.webp b/application/public/images/profiles/5e071af0-e9a1-4c5c-859f-464c18bb7da9.webp new file mode 100644 index 0000000000..edd877b8d4 Binary files /dev/null and b/application/public/images/profiles/5e071af0-e9a1-4c5c-859f-464c18bb7da9.webp differ diff --git a/application/public/images/profiles/6931b54d-f07b-405d-80dc-17c09acebfa9.webp b/application/public/images/profiles/6931b54d-f07b-405d-80dc-17c09acebfa9.webp new file mode 100644 index 0000000000..62648f22ee Binary files /dev/null and b/application/public/images/profiles/6931b54d-f07b-405d-80dc-17c09acebfa9.webp differ diff --git a/application/public/images/profiles/7d7bf516-e05e-4a4f-95fa-0c73e7bd3f93.webp b/application/public/images/profiles/7d7bf516-e05e-4a4f-95fa-0c73e7bd3f93.webp new file mode 100644 index 0000000000..01aa6be96f Binary files /dev/null and b/application/public/images/profiles/7d7bf516-e05e-4a4f-95fa-0c73e7bd3f93.webp differ diff --git a/application/public/images/profiles/84ba6fee-d167-43c4-8b10-d94caa923f48.webp b/application/public/images/profiles/84ba6fee-d167-43c4-8b10-d94caa923f48.webp new file mode 100644 index 0000000000..1422aafde7 Binary files /dev/null and b/application/public/images/profiles/84ba6fee-d167-43c4-8b10-d94caa923f48.webp differ diff --git a/application/public/images/profiles/a99e1112-f0a0-46a3-8e23-5d34c27898c0.webp b/application/public/images/profiles/a99e1112-f0a0-46a3-8e23-5d34c27898c0.webp new file mode 100644 index 0000000000..9248add6f2 Binary files /dev/null and b/application/public/images/profiles/a99e1112-f0a0-46a3-8e23-5d34c27898c0.webp differ diff --git a/application/public/images/profiles/af98cd5f-b1a6-408c-a455-0970b3247e4c.webp b/application/public/images/profiles/af98cd5f-b1a6-408c-a455-0970b3247e4c.webp new file mode 100644 index 0000000000..096667d97c Binary files /dev/null and b/application/public/images/profiles/af98cd5f-b1a6-408c-a455-0970b3247e4c.webp differ diff --git a/application/public/images/profiles/b2c256a3-296f-49e0-ba8b-101b55146956.webp b/application/public/images/profiles/b2c256a3-296f-49e0-ba8b-101b55146956.webp new file mode 100644 index 0000000000..2fb198fb9c Binary files /dev/null and b/application/public/images/profiles/b2c256a3-296f-49e0-ba8b-101b55146956.webp differ diff --git a/application/public/images/profiles/c8939885-5dca-4132-b234-64a12c1861a5.webp b/application/public/images/profiles/c8939885-5dca-4132-b234-64a12c1861a5.webp new file mode 100644 index 0000000000..2ef8976482 Binary files /dev/null and b/application/public/images/profiles/c8939885-5dca-4132-b234-64a12c1861a5.webp differ diff --git a/application/public/images/profiles/ca81e02a-11aa-4218-971d-c8bd8d9e67cf.webp b/application/public/images/profiles/ca81e02a-11aa-4218-971d-c8bd8d9e67cf.webp new file mode 100644 index 0000000000..a7de1eaae5 Binary files /dev/null and b/application/public/images/profiles/ca81e02a-11aa-4218-971d-c8bd8d9e67cf.webp differ diff --git a/application/public/images/profiles/cd5b31e5-0fb4-4b40-830d-3a22058b30cc.webp b/application/public/images/profiles/cd5b31e5-0fb4-4b40-830d-3a22058b30cc.webp new file mode 100644 index 0000000000..39edbfb785 Binary files /dev/null and b/application/public/images/profiles/cd5b31e5-0fb4-4b40-830d-3a22058b30cc.webp differ diff --git a/application/public/images/profiles/cf145991-b2ff-4ef5-aeb5-dbc9d9eb51a0.webp b/application/public/images/profiles/cf145991-b2ff-4ef5-aeb5-dbc9d9eb51a0.webp new file mode 100644 index 0000000000..4917cc5aba Binary files /dev/null and b/application/public/images/profiles/cf145991-b2ff-4ef5-aeb5-dbc9d9eb51a0.webp differ diff --git a/application/public/images/profiles/dbe9b1f0-9822-4f77-9635-f9fd64e2b4e5.webp b/application/public/images/profiles/dbe9b1f0-9822-4f77-9635-f9fd64e2b4e5.webp new file mode 100644 index 0000000000..1f95879bc1 Binary files /dev/null and b/application/public/images/profiles/dbe9b1f0-9822-4f77-9635-f9fd64e2b4e5.webp differ diff --git a/application/public/images/profiles/ed0d327c-2ba5-4b23-8284-3e31f7a51d16.webp b/application/public/images/profiles/ed0d327c-2ba5-4b23-8284-3e31f7a51d16.webp new file mode 100644 index 0000000000..77be7f8227 Binary files /dev/null and b/application/public/images/profiles/ed0d327c-2ba5-4b23-8284-3e31f7a51d16.webp differ diff --git a/application/public/images/profiles/f1f4c2c2-bf06-44b5-b43e-02a00d770242.webp b/application/public/images/profiles/f1f4c2c2-bf06-44b5-b43e-02a00d770242.webp new file mode 100644 index 0000000000..03d148bb8b Binary files /dev/null and b/application/public/images/profiles/f1f4c2c2-bf06-44b5-b43e-02a00d770242.webp differ diff --git a/application/public/images/profiles/f4619909-0f90-45dd-ada0-6c6305453a74.webp b/application/public/images/profiles/f4619909-0f90-45dd-ada0-6c6305453a74.webp new file mode 100644 index 0000000000..8405751434 Binary files /dev/null and b/application/public/images/profiles/f4619909-0f90-45dd-ada0-6c6305453a74.webp differ diff --git a/application/public/images/profiles/fd571d42-c471-47fd-846f-d3c1325685fd.webp b/application/public/images/profiles/fd571d42-c471-47fd-846f-d3c1325685fd.webp new file mode 100644 index 0000000000..1be69f53df Binary files /dev/null and b/application/public/images/profiles/fd571d42-c471-47fd-846f-d3c1325685fd.webp differ diff --git a/application/public/movies/090e7491-5cdb-4a1b-88b1-1e036a45e296.webm b/application/public/movies/090e7491-5cdb-4a1b-88b1-1e036a45e296.webm new file mode 100644 index 0000000000..643eb4f4af Binary files /dev/null and b/application/public/movies/090e7491-5cdb-4a1b-88b1-1e036a45e296.webm differ diff --git a/application/public/movies/0c4b66bc-091e-4f76-85a3-288567cfdc12.webm b/application/public/movies/0c4b66bc-091e-4f76-85a3-288567cfdc12.webm new file mode 100644 index 0000000000..de3eb5e6cd Binary files /dev/null and b/application/public/movies/0c4b66bc-091e-4f76-85a3-288567cfdc12.webm differ diff --git a/application/public/movies/1b558288-6ec6-4ece-a9b8-4259379b7489.webm b/application/public/movies/1b558288-6ec6-4ece-a9b8-4259379b7489.webm new file mode 100644 index 0000000000..056911b35d Binary files /dev/null and b/application/public/movies/1b558288-6ec6-4ece-a9b8-4259379b7489.webm differ diff --git a/application/public/movies/241b7993-f7c4-49e5-84f0-bbaf6a144634.webm b/application/public/movies/241b7993-f7c4-49e5-84f0-bbaf6a144634.webm new file mode 100644 index 0000000000..9d42998eab Binary files /dev/null and b/application/public/movies/241b7993-f7c4-49e5-84f0-bbaf6a144634.webm differ diff --git a/application/public/movies/3cb50e48-535b-4e5f-bbde-455c01def021.webm b/application/public/movies/3cb50e48-535b-4e5f-bbde-455c01def021.webm new file mode 100644 index 0000000000..013bdcfacb Binary files /dev/null and b/application/public/movies/3cb50e48-535b-4e5f-bbde-455c01def021.webm differ diff --git a/application/public/movies/51a14d70-9dd6-45ad-9f87-64af91ec2779.webm b/application/public/movies/51a14d70-9dd6-45ad-9f87-64af91ec2779.webm new file mode 100644 index 0000000000..af2166a334 Binary files /dev/null and b/application/public/movies/51a14d70-9dd6-45ad-9f87-64af91ec2779.webm differ diff --git a/application/public/movies/6ccc437c-253d-4e6f-baa2-2f4f2419f830.webm b/application/public/movies/6ccc437c-253d-4e6f-baa2-2f4f2419f830.webm new file mode 100644 index 0000000000..c7ed256424 Binary files /dev/null and b/application/public/movies/6ccc437c-253d-4e6f-baa2-2f4f2419f830.webm differ diff --git a/application/public/movies/74eb4b82-601d-40ec-9aa5-70c4ac5d9799.webm b/application/public/movies/74eb4b82-601d-40ec-9aa5-70c4ac5d9799.webm new file mode 100644 index 0000000000..16087a62ff Binary files /dev/null and b/application/public/movies/74eb4b82-601d-40ec-9aa5-70c4ac5d9799.webm differ diff --git a/application/public/movies/7518b1ae-3bc5-4b42-b82b-0013a3a74b16.webm b/application/public/movies/7518b1ae-3bc5-4b42-b82b-0013a3a74b16.webm new file mode 100644 index 0000000000..3c018a1834 Binary files /dev/null and b/application/public/movies/7518b1ae-3bc5-4b42-b82b-0013a3a74b16.webm differ diff --git a/application/public/movies/826f0b4d-0f4b-408c-9560-a82798116255.webm b/application/public/movies/826f0b4d-0f4b-408c-9560-a82798116255.webm new file mode 100644 index 0000000000..85b1fbdafb Binary files /dev/null and b/application/public/movies/826f0b4d-0f4b-408c-9560-a82798116255.webm differ diff --git a/application/public/movies/b3998a47-ee87-483e-acf1-8e5b69c8527a.webm b/application/public/movies/b3998a47-ee87-483e-acf1-8e5b69c8527a.webm new file mode 100644 index 0000000000..1e0aab7334 Binary files /dev/null and b/application/public/movies/b3998a47-ee87-483e-acf1-8e5b69c8527a.webm differ diff --git a/application/public/movies/b44e6ef6-fb30-4f59-9c86-70fe0f1edf08.webm b/application/public/movies/b44e6ef6-fb30-4f59-9c86-70fe0f1edf08.webm new file mode 100644 index 0000000000..bffaee33d4 Binary files /dev/null and b/application/public/movies/b44e6ef6-fb30-4f59-9c86-70fe0f1edf08.webm differ diff --git a/application/public/movies/c8f1d48d-d831-4d69-9477-0112152f95b9.webm b/application/public/movies/c8f1d48d-d831-4d69-9477-0112152f95b9.webm new file mode 100644 index 0000000000..50e894c450 Binary files /dev/null and b/application/public/movies/c8f1d48d-d831-4d69-9477-0112152f95b9.webm differ diff --git a/application/public/movies/db504945-d122-4c70-8c2d-fe636282ca00.webm b/application/public/movies/db504945-d122-4c70-8c2d-fe636282ca00.webm new file mode 100644 index 0000000000..54abf3304a Binary files /dev/null and b/application/public/movies/db504945-d122-4c70-8c2d-fe636282ca00.webm differ diff --git a/application/public/movies/fafa6ec6-1572-4def-aa16-4a9fbf28aa41.webm b/application/public/movies/fafa6ec6-1572-4def-aa16-4a9fbf28aa41.webm new file mode 100644 index 0000000000..fd40ea2062 Binary files /dev/null and b/application/public/movies/fafa6ec6-1572-4def-aa16-4a9fbf28aa41.webm differ diff --git a/application/server/package.json b/application/server/package.json index 9482575df7..4b99294411 100644 --- a/application/server/package.json +++ b/application/server/package.json @@ -17,8 +17,11 @@ "@web-speed-hackathon-2026/server": "workspace:*", "bcrypt": "6.0.0", "body-parser": "2.2.0", + "compression": "1.8.0", "connect-history-api-fallback": "2.0.0", "express": "5.1.0", + "kuromoji": "0.1.2", + "negaposi-analyzer-ja": "1.0.1", "express-session": "1.18.2", "file-type": "21.1.1", "http-errors": "2.0.0", @@ -33,6 +36,8 @@ "devDependencies": { "@faker-js/faker": "10.2.0", "@types/bcrypt": "6.0.0", + "@types/compression": "1.7.5", + "@types/kuromoji": "0.1.3", "@types/body-parser": "1.19.6", "@types/connect-history-api-fallback": "1.5.4", "@types/express": "5.0.3", diff --git a/application/server/src/app.ts b/application/server/src/app.ts index 671fb424cc..73c5b0c253 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,20 @@ export const app = Express(); app.set("trust proxy", true); +const compressionMiddleware = compression(); +app.use((req, res, next) => { + // SSE エンドポイントは圧縮バッファリングすると一括送信になるためスキップ + if (req.url.startsWith("/api/v1/crok") && !req.url.includes("suggestions")) { + return next(); + } + compressionMiddleware(req, res, next); +}); app.use(sessionMiddleware); app.use(bodyParser.json()); -app.use(bodyParser.raw({ limit: "10mb" })); +app.use(bodyParser.raw({ limit: "50mb" })); -app.use((_req, res, next) => { - res.header({ - "Cache-Control": "max-age=0, no-transform", - Connection: "close", - }); +app.use("/api", (_req, res, next) => { + res.header("Cache-Control", "no-store"); return next(); }); diff --git a/application/server/src/models/DirectMessage.ts b/application/server/src/models/DirectMessage.ts index e4565ba1c4..5add1695ad 100644 --- a/application/server/src/models/DirectMessage.ts +++ b/application/server/src/models/DirectMessage.ts @@ -73,20 +73,19 @@ export function initDirectMessage(sequelize: Sequelize) { ); DirectMessage.addHook("afterSave", "onDmSaved", async (message) => { - const directMessage = await DirectMessage.findByPk(message.get().id); - const conversation = await DirectMessageConversation.findByPk(directMessage?.conversationId); + const dm = await DirectMessage.unscoped().findByPk(message.get().id); + const conv = await DirectMessageConversation.unscoped().findByPk(dm?.conversationId); - if (directMessage == null || conversation == null) { + if (dm == null || conv == null) { return; } const receiverId = - conversation.initiatorId === directMessage.senderId - ? conversation.memberId - : conversation.initiatorId; + conv.initiatorId === dm.senderId + ? conv.memberId + : conv.initiatorId; - const unreadCount = await DirectMessage.count({ - distinct: true, + const unreadCount = await DirectMessage.unscoped().count({ where: { senderId: { [Op.ne]: receiverId }, isRead: false, @@ -102,7 +101,8 @@ export function initDirectMessage(sequelize: Sequelize) { ], }); - eventhub.emit(`dm:conversation/${conversation.id}:message`, directMessage); + const fullMessage = await DirectMessage.findByPk(message.get().id); + eventhub.emit(`dm:conversation/${conv.id}:message`, fullMessage); eventhub.emit(`dm:unread/${receiverId}`, { unreadCount }); }); } diff --git a/application/server/src/models/DirectMessageConversation.ts b/application/server/src/models/DirectMessageConversation.ts index 99ebb2425b..43dae2d0cc 100644 --- a/application/server/src/models/DirectMessageConversation.ts +++ b/application/server/src/models/DirectMessageConversation.ts @@ -54,6 +54,8 @@ export function initDirectMessageConversation(sequelize: Sequelize) { association: "messages", include: [{ association: "sender", include: [{ association: "profileImage" }] }], order: [["createdAt", "ASC"]], + limit: 50, + separate: true, required: false, }, ], diff --git a/application/server/src/models/User.ts b/application/server/src/models/User.ts index 9085a287a3..d4fcdd355e 100644 --- a/application/server/src/models/User.ts +++ b/application/server/src/models/User.ts @@ -29,8 +29,8 @@ export class User extends Model, InferCreationAttributes { + return bcrypt.compare(password, this.getDataValue("password")); } } @@ -78,7 +78,6 @@ export function initUser(sequelize: Sequelize) { { sequelize, defaultScope: { - attributes: { exclude: ["profileImageId"] }, include: { association: "profileImage" }, }, }, diff --git a/application/server/src/routes/api.ts b/application/server/src/routes/api.ts index e6a57a3b16..f9183f9f94 100644 --- a/application/server/src/routes/api.ts +++ b/application/server/src/routes/api.ts @@ -10,6 +10,7 @@ import { initializeRouter } from "@web-speed-hackathon-2026/server/src/routes/ap import { movieRouter } from "@web-speed-hackathon-2026/server/src/routes/api/movie"; import { postRouter } from "@web-speed-hackathon-2026/server/src/routes/api/post"; import { searchRouter } from "@web-speed-hackathon-2026/server/src/routes/api/search"; +import { sentimentRouter } from "@web-speed-hackathon-2026/server/src/routes/api/sentiment"; import { soundRouter } from "@web-speed-hackathon-2026/server/src/routes/api/sound"; import { userRouter } from "@web-speed-hackathon-2026/server/src/routes/api/user"; @@ -20,6 +21,7 @@ apiRouter.use(userRouter); apiRouter.use(postRouter); apiRouter.use(directMessageRouter); apiRouter.use(searchRouter); +apiRouter.use(sentimentRouter); apiRouter.use(movieRouter); apiRouter.use(imageRouter); apiRouter.use(soundRouter); diff --git a/application/server/src/routes/api/auth.ts b/application/server/src/routes/api/auth.ts index f313e87bbb..58c9ec82fb 100644 --- a/application/server/src/routes/api/auth.ts +++ b/application/server/src/routes/api/auth.ts @@ -34,7 +34,7 @@ authRouter.post("/signin", async (req, res) => { if (user === null) { throw new httpErrors.BadRequest(); } - if (!user.validPassword(req.body.password)) { + if (!(await user.validPassword(req.body.password))) { throw new httpErrors.BadRequest(); } diff --git a/application/server/src/routes/api/crok.ts b/application/server/src/routes/api/crok.ts index cfd6065951..bcd0fc7e83 100644 --- a/application/server/src/routes/api/crok.ts +++ b/application/server/src/routes/api/crok.ts @@ -13,13 +13,13 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const response = fs.readFileSync(path.join(__dirname, "crok-response.md"), "utf-8"); crokRouter.get("/crok/suggestions", async (_req, res) => { - const suggestions = await QaSuggestion.findAll({ logging: false }); + res.setHeader("Cache-Control", "public, max-age=3600"); + const suggestions = await QaSuggestion.findAll({ attributes: ["question"], logging: false }); res.json({ suggestions: suggestions.map((s) => s.question) }); }); -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} + +const CHUNK_SIZE = 50; crokRouter.get("/crok", async (req, res) => { if (req.session.userId === undefined) { @@ -33,16 +33,21 @@ crokRouter.get("/crok", async (req, res) => { let messageId = 0; - // TTFT (Time to First Token) - await sleep(3000); + // TypingIndicator の描画を保証するため、最初に空チャンクを送信 + { + const data = JSON.stringify({ text: "", done: false }); + res.write(`event: message\nid: ${messageId++}\ndata: ${data}\n\n`); + await new Promise((resolve) => setTimeout(resolve, 100)); + } - for (const char of response) { + for (let i = 0; i < response.length; i += CHUNK_SIZE) { if (res.closed) break; - const data = JSON.stringify({ text: char, done: false }); + const text = response.slice(i, i + CHUNK_SIZE); + const data = JSON.stringify({ text, done: false }); res.write(`event: message\nid: ${messageId++}\ndata: ${data}\n\n`); - await sleep(10); + await new Promise((resolve) => setTimeout(resolve, 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..67b04096a3 100644 --- a/application/server/src/routes/api/direct_message.ts +++ b/application/server/src/routes/api/direct_message.ts @@ -1,6 +1,6 @@ import { Router } from "express"; import httpErrors from "http-errors"; -import { col, where, Op } from "sequelize"; +import { Op } from "sequelize"; import { eventhub } from "@web-speed-hackathon-2026/server/src/eventhub"; import { @@ -16,22 +16,35 @@ directMessageRouter.get("/dm", async (req, res) => { throw new httpErrors.Unauthorized(); } - const conversations = await DirectMessageConversation.findAll({ + const conversations = await DirectMessageConversation.unscoped().findAll({ + include: [ + { association: "initiator", include: [{ association: "profileImage" }] }, + { association: "member", include: [{ association: "profileImage" }] }, + { + association: "messages", + include: [{ association: "sender", include: [{ association: "profileImage" }] }], + limit: 1, + separate: true, + order: [["createdAt", "DESC"]], + required: false, + }, + ], where: { - [Op.and]: [ - { [Op.or]: [{ initiatorId: req.session.userId }, { memberId: req.session.userId }] }, - where(col("messages.id"), { [Op.not]: null }), - ], + [Op.or]: [{ initiatorId: req.session.userId }, { memberId: req.session.userId }], }, - order: [[col("messages.createdAt"), "DESC"]], + order: [ + [ + DirectMessageConversation.sequelize!.literal( + `(SELECT MAX("createdAt") FROM "DirectMessages" WHERE "conversationId" = "DirectMessageConversation"."id")`, + ), + "DESC", + ], + ], }); - const sorted = conversations.map((c) => ({ - ...c.toJSON(), - messages: c.messages?.reverse(), - })); + const withMessages = conversations.filter((c) => c.messages && c.messages.length > 0); - return res.status(200).type("application/json").send(sorted); + return res.status(200).type("application/json").send(withMessages); }); directMessageRouter.post("/dm", async (req, res) => { @@ -56,9 +69,14 @@ directMessageRouter.post("/dm", async (req, res) => { memberId: peer.id, }, }); - await conversation.reload(); + const loaded = await DirectMessageConversation.unscoped().findByPk(conversation.id, { + include: [ + { association: "initiator", include: [{ association: "profileImage" }] }, + { association: "member", include: [{ association: "profileImage" }] }, + ], + }); - return res.status(200).type("application/json").send(conversation); + return res.status(200).type("application/json").send(loaded); }); directMessageRouter.ws("/dm/unread", async (req, _res) => { @@ -118,7 +136,7 @@ directMessageRouter.ws("/dm/:conversationId", async (req, _res) => { throw new httpErrors.Unauthorized(); } - const conversation = await DirectMessageConversation.findOne({ + const conversation = await DirectMessageConversation.unscoped().findOne({ where: { id: req.params.conversationId, [Op.or]: [{ initiatorId: req.session.userId }, { memberId: req.session.userId }], @@ -160,7 +178,7 @@ directMessageRouter.post("/dm/:conversationId/messages", async (req, res) => { throw new httpErrors.BadRequest(); } - const conversation = await DirectMessageConversation.findOne({ + const conversation = await DirectMessageConversation.unscoped().findOne({ where: { id: req.params.conversationId, [Op.or]: [{ initiatorId: req.session.userId }, { memberId: req.session.userId }], @@ -185,7 +203,7 @@ directMessageRouter.post("/dm/:conversationId/read", async (req, res) => { throw new httpErrors.Unauthorized(); } - const conversation = await DirectMessageConversation.findOne({ + const conversation = await DirectMessageConversation.unscoped().findOne({ where: { id: req.params.conversationId, [Op.or]: [{ initiatorId: req.session.userId }, { memberId: req.session.userId }], @@ -204,10 +222,30 @@ directMessageRouter.post("/dm/:conversationId/read", async (req, res) => { { isRead: true }, { where: { conversationId: conversation.id, senderId: peerId, isRead: false }, - individualHooks: true, }, ); + const unreadCount = await DirectMessage.unscoped().count({ + where: { + senderId: { [Op.ne]: req.session.userId }, + isRead: false, + }, + include: [ + { + association: "conversation", + where: { + [Op.or]: [{ initiatorId: req.session.userId }, { memberId: req.session.userId }], + }, + required: true, + }, + ], + }); + eventhub.emit(`dm:unread/${req.session.userId}`, { unreadCount }); + eventhub.emit(`dm:conversation/${conversation.id}`, { + type: "dm:conversation:read", + payload: { senderId: peerId }, + }); + return res.status(200).type("application/json").send({}); }); @@ -216,7 +254,7 @@ directMessageRouter.post("/dm/:conversationId/typing", async (req, res) => { throw new httpErrors.Unauthorized(); } - const conversation = await DirectMessageConversation.findByPk(req.params.conversationId); + const conversation = await DirectMessageConversation.unscoped().findByPk(req.params.conversationId); if (conversation === null) { throw new httpErrors.NotFound(); } diff --git a/application/server/src/routes/api/image.ts b/application/server/src/routes/api/image.ts index d5c23e209d..e4de5b353b 100644 --- a/application/server/src/routes/api/image.ts +++ b/application/server/src/routes/api/image.ts @@ -1,5 +1,7 @@ +import { execFile } from "child_process"; import { promises as fs } from "fs"; import path from "path"; +import { promisify } from "util"; import { Router } from "express"; import { fileTypeFromBuffer } from "file-type"; @@ -8,9 +10,37 @@ import { v4 as uuidv4 } from "uuid"; import { UPLOAD_PATH } from "@web-speed-hackathon-2026/server/src/paths"; +const execFileAsync = promisify(execFile); + // 変換した画像の拡張子 const EXTENSION = "jpg"; +// TIFFファイルからImageDescription (Tag 270) を抽出する +function extractTiffDescription(buf: Buffer): string { + if (buf.length < 8) return ""; + const sig = buf.readUInt16BE(0); + const le = sig === 0x4949; // 'II' = little-endian + const read16 = le ? (b: Buffer, o: number) => b.readUInt16LE(o) : (b: Buffer, o: number) => b.readUInt16BE(o); + const read32 = le ? (b: Buffer, o: number) => b.readUInt32LE(o) : (b: Buffer, o: number) => b.readUInt32BE(o); + + try { + const ifdOffset = read32(buf, 4); + const numEntries = read16(buf, ifdOffset); + for (let i = 0; i < numEntries; i++) { + const entryOffset = ifdOffset + 2 + i * 12; + const tag = read16(buf, entryOffset); + if (tag === 270) { // ImageDescription + const count = read32(buf, entryOffset + 4); + const valueOffset = read32(buf, entryOffset + 8); + return buf.toString("utf-8", valueOffset, valueOffset + count - 1); + } + } + } catch { + // メタデータ読み取り失敗は無視 + } + return ""; +} + export const imageRouter = Router(); imageRouter.post("/images", async (req, res) => { @@ -22,15 +52,34 @@ imageRouter.post("/images", async (req, res) => { } const type = await fileTypeFromBuffer(req.body); - if (type === undefined || type.ext !== EXTENSION) { + if (type === undefined || !type.mime.startsWith("image/")) { throw new httpErrors.BadRequest("Invalid file type"); } - const imageId = uuidv4(); + // TIFFの場合はメタデータからalt抽出 + const alt = type.ext === "tif" ? extractTiffDescription(req.body) : ""; + const imageId = uuidv4(); const filePath = path.resolve(UPLOAD_PATH, `./images/${imageId}.${EXTENSION}`); await fs.mkdir(path.resolve(UPLOAD_PATH, "images"), { recursive: true }); - await fs.writeFile(filePath, req.body); - return res.status(200).type("application/json").send({ id: imageId }); + if (type.ext !== EXTENSION) { + const tmpPath = path.resolve(UPLOAD_PATH, `./images/${imageId}_tmp.${type.ext}`); + await fs.writeFile(tmpPath, req.body); + await execFileAsync("ffmpeg", ["-y", "-i", tmpPath, filePath]); + await fs.unlink(tmpPath); + } else { + await fs.writeFile(filePath, req.body); + } + + // バックグラウンドで WebP 変換(レスポンスをブロックしない) + const webpPath = filePath.replace(".jpg", ".webp"); + void execFileAsync("ffmpeg", [ + "-y", "-i", filePath, + "-vf", "scale=min(1200\\,iw):-2", + "-c:v", "libwebp", "-quality", "80", + webpPath, + ]).catch(() => {}); + + return res.status(200).type("application/json").send({ id: imageId, alt }); }); diff --git a/application/server/src/routes/api/movie.ts b/application/server/src/routes/api/movie.ts index 4c96c207be..9352acf8e6 100644 --- a/application/server/src/routes/api/movie.ts +++ b/application/server/src/routes/api/movie.ts @@ -1,5 +1,7 @@ +import { execFile } from "child_process"; import { promises as fs } from "fs"; import path from "path"; +import { promisify } from "util"; import { Router } from "express"; import { fileTypeFromBuffer } from "file-type"; @@ -8,8 +10,7 @@ import { v4 as uuidv4 } from "uuid"; import { UPLOAD_PATH } from "@web-speed-hackathon-2026/server/src/paths"; -// 変換した動画の拡張子 -const EXTENSION = "gif"; +const execFileAsync = promisify(execFile); export const movieRouter = Router(); @@ -22,15 +23,30 @@ movieRouter.post("/movies", async (req, res) => { } const type = await fileTypeFromBuffer(req.body); - if (type === undefined || type.ext !== EXTENSION) { + if (type === undefined || (!type.mime.startsWith("video/") && !type.mime.startsWith("image/"))) { throw new httpErrors.BadRequest("Invalid file type"); } const movieId = uuidv4(); - - const filePath = path.resolve(UPLOAD_PATH, `./movies/${movieId}.${EXTENSION}`); - await fs.mkdir(path.resolve(UPLOAD_PATH, "movies"), { recursive: true }); - await fs.writeFile(filePath, req.body); + const moviesDir = path.resolve(UPLOAD_PATH, "movies"); + const inputPath = path.resolve(moviesDir, `${movieId}_input.${type.ext}`); + const webmPath = path.resolve(moviesDir, `${movieId}.webm`); + + await fs.mkdir(moviesDir, { recursive: true }); + await fs.writeFile(inputPath, req.body); + + await execFileAsync("ffmpeg", [ + "-y", "-i", inputPath, + "-t", "5", + "-r", "10", + "-vf", "crop='min(iw,ih)':'min(iw,ih)'", + "-an", + "-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "33", + "-deadline", "realtime", "-cpu-used", "8", + webmPath, + ]); + + await fs.unlink(inputPath); return res.status(200).type("application/json").send({ id: movieId }); }); diff --git a/application/server/src/routes/api/post.ts b/application/server/src/routes/api/post.ts index cda8654b2b..7e94a36a7a 100644 --- a/application/server/src/routes/api/post.ts +++ b/application/server/src/routes/api/post.ts @@ -6,6 +6,7 @@ import { Comment, Post } from "@web-speed-hackathon-2026/server/src/models"; export const postRouter = Router(); postRouter.get("/posts", async (req, res) => { + res.setHeader("Cache-Control", "public, max-age=30"); const posts = await Post.findAll({ limit: req.query["limit"] != null ? Number(req.query["limit"]) : undefined, offset: req.query["offset"] != null ? Number(req.query["offset"]) : undefined, @@ -15,6 +16,7 @@ postRouter.get("/posts", async (req, res) => { }); postRouter.get("/posts/:postId", async (req, res) => { + res.setHeader("Cache-Control", "public, max-age=60"); const post = await Post.findByPk(req.params.postId); if (post === null) { @@ -25,6 +27,7 @@ postRouter.get("/posts/:postId", async (req, res) => { }); postRouter.get("/posts/:postId/comments", async (req, res) => { + res.setHeader("Cache-Control", "public, max-age=30"); const posts = await Comment.findAll({ limit: req.query["limit"] != null ? Number(req.query["limit"]) : undefined, offset: req.query["offset"] != null ? Number(req.query["offset"]) : undefined, diff --git a/application/server/src/routes/api/search.ts b/application/server/src/routes/api/search.ts index 48e99856b4..102bc4dbc6 100644 --- a/application/server/src/routes/api/search.ts +++ b/application/server/src/routes/api/search.ts @@ -38,9 +38,10 @@ searchRouter.get("/search", async (req, res) => { // テキスト検索条件 const textWhere = searchTerm ? { text: { [Op.like]: searchTerm } } : {}; + res.setHeader("Cache-Control", "public, max-age=30"); + + // テキスト検索(defaultScope の include が自動適用される) const postsByText = await Post.findAll({ - limit, - offset, where: { ...textWhere, ...dateWhere, @@ -54,7 +55,6 @@ searchRouter.get("/search", async (req, res) => { include: [ { association: "user", - attributes: { exclude: ["profileImageId"] }, include: [{ association: "profileImage" }], required: true, where: { @@ -68,8 +68,6 @@ searchRouter.get("/search", async (req, res) => { { association: "movie" }, { association: "sound" }, ], - limit, - offset, where: dateWhere, }); } diff --git a/application/server/src/routes/api/sentiment.ts b/application/server/src/routes/api/sentiment.ts new file mode 100644 index 0000000000..505e8a5db5 --- /dev/null +++ b/application/server/src/routes/api/sentiment.ts @@ -0,0 +1,64 @@ +import path from "path"; + +import { Router } from "express"; + +import { PUBLIC_PATH } from "@web-speed-hackathon-2026/server/src/paths"; + +export const sentimentRouter = Router(); + +type Tokenizer = { + tokenize: (text: string) => object[]; +}; + +let tokenizerPromise: Promise | null = null; + +function getTokenizer(): Promise { + if (!tokenizerPromise) { + tokenizerPromise = new Promise((resolve, reject) => { + import("kuromoji").then(({ default: kuromoji }) => { + kuromoji + .builder({ dicPath: path.join(PUBLIC_PATH, "dicts") }) + .build((err: Error | null, tokenizer: Tokenizer) => { + if (err) { + tokenizerPromise = null; + reject(err); + } else { + resolve(tokenizer); + } + }); + }); + }); + } + return tokenizerPromise; +} + +sentimentRouter.get("/sentiment", async (req, res) => { + res.setHeader("Cache-Control", "public, max-age=86400"); + const text = req.query["text"]; + + if (typeof text !== "string" || text.trim() === "") { + return res.status(200).json({ label: "neutral", score: 0 }); + } + + try { + const [{ default: analyze }, tokenizer] = await Promise.all([ + import("negaposi-analyzer-ja"), + getTokenizer(), + ]); + const tokens = tokenizer.tokenize(text); + const score = analyze(tokens as Parameters[0]); + + let label: "positive" | "negative" | "neutral"; + if (score > 0.1) { + label = "positive"; + } else if (score < -0.1) { + label = "negative"; + } else { + label = "neutral"; + } + + return res.status(200).json({ label, score }); + } catch { + return res.status(200).json({ label: "neutral", score: 0 }); + } +}); diff --git a/application/server/src/routes/api/sound.ts b/application/server/src/routes/api/sound.ts index 55ce11def9..8655f110b6 100644 --- a/application/server/src/routes/api/sound.ts +++ b/application/server/src/routes/api/sound.ts @@ -1,5 +1,7 @@ +import { execFile } from "child_process"; import { promises as fs } from "fs"; import path from "path"; +import { promisify } from "util"; import { Router } from "express"; import { fileTypeFromBuffer } from "file-type"; @@ -9,6 +11,8 @@ import { v4 as uuidv4 } from "uuid"; import { UPLOAD_PATH } from "@web-speed-hackathon-2026/server/src/paths"; import { extractMetadataFromSound } from "@web-speed-hackathon-2026/server/src/utils/extract_metadata_from_sound"; +const execFileAsync = promisify(execFile); + // 変換した音声の拡張子 const EXTENSION = "mp3"; @@ -23,17 +27,26 @@ soundRouter.post("/sounds", async (req, res) => { } const type = await fileTypeFromBuffer(req.body); - if (type === undefined || type.ext !== EXTENSION) { + if (type === undefined || !type.mime.startsWith("audio/")) { throw new httpErrors.BadRequest("Invalid file type"); } const soundId = uuidv4(); + const soundsDir = path.resolve(UPLOAD_PATH, "sounds"); + const filePath = path.resolve(soundsDir, `${soundId}.${EXTENSION}`); + await fs.mkdir(soundsDir, { recursive: true }); + // WAVメタデータは変換前に元バッファから取得 const { artist, title } = await extractMetadataFromSound(req.body); - const filePath = path.resolve(UPLOAD_PATH, `./sounds/${soundId}.${EXTENSION}`); - await fs.mkdir(path.resolve(UPLOAD_PATH, "sounds"), { recursive: true }); - await fs.writeFile(filePath, req.body); + if (type.ext !== EXTENSION) { + const tmpPath = path.resolve(soundsDir, `${soundId}_tmp.${type.ext}`); + await fs.writeFile(tmpPath, req.body); + await execFileAsync("ffmpeg", ["-y", "-i", tmpPath, "-codec:a", "libmp3lame", "-b:a", "128k", "-ac", "1", "-ar", "22050", filePath]); + await fs.unlink(tmpPath); + } else { + await fs.writeFile(filePath, req.body); + } return res.status(200).type("application/json").send({ artist, id: soundId, title }); }); diff --git a/application/server/src/routes/api/user.ts b/application/server/src/routes/api/user.ts index cc6d916822..b76e1988eb 100644 --- a/application/server/src/routes/api/user.ts +++ b/application/server/src/routes/api/user.ts @@ -35,6 +35,7 @@ userRouter.put("/me", async (req, res) => { }); userRouter.get("/users/:username", async (req, res) => { + res.setHeader("Cache-Control", "public, max-age=300"); const user = await User.findOne({ where: { username: req.params.username, @@ -49,6 +50,7 @@ userRouter.get("/users/:username", async (req, res) => { }); userRouter.get("/users/:username/posts", async (req, res) => { + res.setHeader("Cache-Control", "public, max-age=30"); const user = await User.findOne({ where: { username: req.params.username, diff --git a/application/server/src/routes/static.ts b/application/server/src/routes/static.ts index b5820c986e..b288add13f 100644 --- a/application/server/src/routes/static.ts +++ b/application/server/src/routes/static.ts @@ -1,7 +1,14 @@ +import { execFile } from "child_process"; +import fs from "fs"; +import path from "path"; +import { promisify } from "util"; + import history from "connect-history-api-fallback"; import { Router } from "express"; import serveStatic from "serve-static"; +const execFileAsync = promisify(execFile); + import { CLIENT_DIST_PATH, PUBLIC_PATH, @@ -10,6 +17,126 @@ import { export const staticRouter = Router(); +const COMPRESSIBLE_EXTS = new Set([".js", ".css"]); +const MIME_TYPES: Record = { + ".js": "application/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", +}; + +function precompressedMiddleware(root: string) { + return (req: any, res: any, next: () => void) => { + const ext = path.extname(req.path); + if (!COMPRESSIBLE_EXTS.has(ext)) return next(); + + const accept = (req.headers["accept-encoding"] ?? "") as string; + const candidates: Array<{ suffix: string; encoding: string }> = []; + if (accept.includes("br")) candidates.push({ suffix: ".br", encoding: "br" }); + if (accept.includes("gzip")) candidates.push({ suffix: ".gz", encoding: "gzip" }); + + for (const { suffix, encoding } of candidates) { + const filePath = path.join(root, req.path + suffix); + if (fs.existsSync(filePath)) { + res.setHeader("Content-Encoding", encoding); + res.setHeader("Content-Type", MIME_TYPES[ext]); + res.setHeader("Vary", "Accept-Encoding"); + return res.sendFile(filePath); + } + } + next(); + }; +} + +// WebP 変換ジョブの重複防止 Map +const webpJobs = new Map>(); + +async function convertToWebP(src: string, dst: string): Promise { + if (!webpJobs.has(dst)) { + const job = execFileAsync("ffmpeg", [ + "-y", "-i", src, + "-vf", "scale=min(1200\\,iw):-2", + "-c:v", "libwebp", "-quality", "80", + dst, + ]) + .then(() => dst) + .catch(() => null) + .finally(() => webpJobs.delete(dst)); + webpJobs.set(dst, job); + } + return webpJobs.get(dst)!; +} + +// メディアファイルは UUID ベースで不変 → 長期キャッシュ +staticRouter.use("/movies/", (_req, res, next) => { + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + return next(); +}); +staticRouter.use("/images/", (_req, res, next) => { + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + return next(); +}); + +// Accept: image/webp のリクエストに対して WebP をオンデマンド変換して返す +staticRouter.use("/images/", async (req: any, res: any, next: () => void) => { + const accept = (req.headers["accept"] ?? "") as string; + if (!accept.includes("image/webp") || !req.path.endsWith(".jpg")) { + return next(); + } + + const rel = req.path; // 例: /85946f86...jpg または /profiles/xxx.jpg + const webpRel = rel.replace(/\.jpg$/, ".webp"); + + for (const root of [PUBLIC_PATH, UPLOAD_PATH]) { + const src = path.join(root, "images", rel); + const dst = path.join(root, "images", webpRel); + + // WebP キャッシュが存在すれば即座に返す + const webpExists = await fs.promises.access(dst).then(() => true).catch(() => false); + if (webpExists) { + res.setHeader("Content-Type", "image/webp"); + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + res.setHeader("Vary", "Accept"); + return res.sendFile(dst); + } + + // ソース JPG が存在するか確認 + const srcExists = await fs.promises.access(src).then(() => true).catch(() => false); + if (!srcExists) continue; + + // オンデマンド変換(初回のみ、以降はキャッシュ) + const result = await convertToWebP(src, dst); + if (result) { + res.setHeader("Content-Type", "image/webp"); + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + res.setHeader("Vary", "Accept"); + return res.sendFile(result); + } + } + + return next(); +}); +staticRouter.use("/sounds/", (_req, res, next) => { + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + return next(); +}); + +// コンテンツハッシュ付きの不変アセットは長期キャッシュ +staticRouter.use("/scripts/chunk-", (_req, res, next) => { + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + return next(); +}); +staticRouter.use("/assets/", (_req, res, next) => { + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + return next(); +}); +staticRouter.use("/styles/fonts/", (_req, res, next) => { + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + return next(); +}); +staticRouter.use("/fonts/", (_req, res, next) => { + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + return next(); +}); + // SPA 対応のため、ファイルが存在しないときに index.html を返す staticRouter.use(history()); @@ -27,6 +154,7 @@ staticRouter.use( }), ); +staticRouter.use(precompressedMiddleware(CLIENT_DIST_PATH)); staticRouter.use( serveStatic(CLIENT_DIST_PATH, { etag: false, diff --git a/application/server/src/sequelize.ts b/application/server/src/sequelize.ts index 6663f8da54..68d88d85c2 100644 --- a/application/server/src/sequelize.ts +++ b/application/server/src/sequelize.ts @@ -26,4 +26,21 @@ export async function initializeSequelize() { storage: TEMP_PATH, }); initModels(_sequelize); + + await _sequelize.query(`CREATE INDEX IF NOT EXISTS idx_posts_userId ON Posts(userId)`); + await _sequelize.query(`CREATE INDEX IF NOT EXISTS idx_posts_createdAt ON Posts(createdAt DESC)`); + await _sequelize.query(`CREATE INDEX IF NOT EXISTS idx_comments_postId ON Comments(postId, createdAt ASC)`); + await _sequelize.query(`CREATE INDEX IF NOT EXISTS idx_dm_conv_created ON DirectMessages(conversationId, createdAt DESC)`); + await _sequelize.query(`CREATE INDEX IF NOT EXISTS idx_dm_sender_read ON DirectMessages(senderId, isRead)`); + await _sequelize.query(`CREATE INDEX IF NOT EXISTS idx_dm_conv_sender_read ON DirectMessages(conversationId, senderId, isRead)`); + await _sequelize.query(`CREATE INDEX IF NOT EXISTS idx_dmconv_initiator ON DirectMessageConversations(initiatorId)`); + await _sequelize.query(`CREATE INDEX IF NOT EXISTS idx_dmconv_member ON DirectMessageConversations(memberId)`); + await _sequelize.query(`CREATE INDEX IF NOT EXISTS idx_users_username ON Users(username)`); + await _sequelize.query(`CREATE INDEX IF NOT EXISTS idx_postimg_postId ON PostsImagesRelations(postId)`); + await _sequelize.query(`CREATE INDEX IF NOT EXISTS idx_postimg_imageId ON PostsImagesRelations(imageId)`); + + await _sequelize.query("PRAGMA journal_mode=WAL"); + await _sequelize.query("PRAGMA synchronous=NORMAL"); + await _sequelize.query("PRAGMA cache_size=-20000"); + await _sequelize.query("PRAGMA temp_store=MEMORY"); } diff --git a/application/server/src/utils/extract_metadata_from_sound.ts b/application/server/src/utils/extract_metadata_from_sound.ts index f11e3bad47..abca0f9519 100644 --- a/application/server/src/utils/extract_metadata_from_sound.ts +++ b/application/server/src/utils/extract_metadata_from_sound.ts @@ -1,16 +1,50 @@ -import * as MusicMetadata from "music-metadata"; - interface SoundMetadata { artist?: string; title?: string; } +function parseRiffInfoChunk(buf: Buffer): { IART?: string; INAM?: string } { + const result: { IART?: string; INAM?: string } = {}; + if (buf.length < 12) return result; + if (buf.toString("ascii", 0, 4) !== "RIFF") return result; + if (buf.toString("ascii", 8, 12) !== "WAVE") return result; + + const dec = new TextDecoder("shift-jis"); + let i = 12; + while (i < buf.length - 8) { + const chunkId = buf.toString("ascii", i, i + 4); + const chunkSize = buf.readUInt32LE(i + 4); + if (chunkId === "LIST" && i + 12 <= buf.length) { + const listType = buf.toString("ascii", i + 8, i + 12); + if (listType === "INFO") { + let j = i + 12; + const end = Math.min(i + 8 + chunkSize, buf.length); + while (j < end - 8) { + const tagId = buf.toString("ascii", j, j + 4); + const tagSize = buf.readUInt32LE(j + 4); + if (tagSize > 0 && j + 8 + tagSize <= buf.length) { + const tagData = buf.slice(j + 8, j + 8 + tagSize); + // Remove null terminator + const trimmed = tagData[tagData.length - 1] === 0 ? tagData.slice(0, -1) : tagData; + const value = dec.decode(trimmed); + if (tagId === "IART") result.IART = value; + if (tagId === "INAM") result.INAM = value; + } + j += 8 + tagSize + (tagSize % 2); + } + } + } + i += 8 + chunkSize + (chunkSize % 2); + } + return result; +} + export async function extractMetadataFromSound(data: Buffer): Promise { try { - const metadata = await MusicMetadata.parseBuffer(data); + const info = parseRiffInfoChunk(data); return { - artist: metadata.common.artist, - title: metadata.common.title, + artist: info.IART, + title: info.INAM, }; } catch { return { diff --git a/application/server/types/negaposi-analyzer-ja.d.ts b/application/server/types/negaposi-analyzer-ja.d.ts new file mode 100644 index 0000000000..83ad0565ef --- /dev/null +++ b/application/server/types/negaposi-analyzer-ja.d.ts @@ -0,0 +1,14 @@ +declare module "negaposi-analyzer-ja" { + import type { IpadicFeatures } from "kuromoji"; + + interface Options { + unknownWordRank?: number; + positiveCorrections?: number; + negativeCorrections?: number; + posiNegaDict?: object[]; + } + + function analyze(tokens: IpadicFeatures[], options?: Options): number; + + export = analyze; +}