diff --git a/.claude/skills/pr/SKILL.md b/.claude/skills/pr/SKILL.md new file mode 100644 index 0000000000..5884898e2e --- /dev/null +++ b/.claude/skills/pr/SKILL.md @@ -0,0 +1,43 @@ +# PR作成スキル + +## Context + +- Current git status: !`git status` +- Current git diff (staged and unstaged changes): !`git diff HEAD` +- Current branch: !`git branch --show-current` +- Commits on branch: !`git log --oneline main..HEAD 2>/dev/null` + +## Your task + +Context の情報だけで判断し、追加の確認コマンド(`git remote -v`, `gh auth status`, `git log` 等)は実行しない。即座に以下を実行する: + +1. main ブランチにいる場合は新しいフィーチャーブランチを作成する(`perf/`, `fix/`, `chore/`, `ci/` などのプレフィックスを使用) +2. 未コミットの変更がある場合のみ、適切なコミットメッセージで単一のコミットを作成する +3. origin に push する +4. `unset GITHUB_TOKEN && gh auth switch --user leonard475192 2>&1` を実行してから `gh pr create` で PR を作成する + +ルール: +- `--repo leonard475192/web-speed-hackathon-2026` を必ず指定する +- upstream(CyberAgentHack)には絶対に PR を作らない +- タイトルは70文字以内 + +``` +unset GITHUB_TOKEN && gh auth switch --user leonard475192 2>&1 && \ +gh pr create \ + --repo leonard475192/web-speed-hackathon-2026 \ + --base main \ + --title "prefix: short description" \ + --body "$(cat <<'EOF' +## Summary +- 変更点1 +- 変更点2 + +## Test plan +- [ ] テスト項目 + +Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +5. You have the capability to call multiple tools in a single response. You MUST do all of the above in a single message. Do not use any other tools or do anything else. Do not send any other text or messages besides these tool calls. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000..62bdc7463c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,119 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: corepack enable + - uses: actions/setup-node@v4 + with: + node-version: "24.14.0" + cache: pnpm + cache-dependency-path: application/pnpm-lock.yaml + - run: cd application && pnpm install --frozen-lockfile + - run: cd application && pnpm exec oxlint . + - run: cd application && pnpm exec oxfmt --check . + + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: corepack enable + - uses: actions/setup-node@v4 + with: + node-version: "24.14.0" + cache: pnpm + cache-dependency-path: application/pnpm-lock.yaml + - run: cd application && pnpm install --frozen-lockfile + - run: cd application && pnpm run typecheck + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: corepack enable + - uses: actions/setup-node@v4 + with: + node-version: "24.14.0" + cache: pnpm + cache-dependency-path: application/pnpm-lock.yaml + - run: cd application && pnpm install --frozen-lockfile + - run: cd application && pnpm run build + - uses: actions/upload-artifact@v4 + with: + name: client-dist + path: application/dist/ + retention-days: 1 + + e2e: + runs-on: macos-latest + needs: build + continue-on-error: true + strategy: + fail-fast: false + matrix: + shardIndex: [1, 2, 3] + shardTotal: [3] + steps: + - uses: actions/checkout@v4 + - run: corepack enable + - uses: actions/setup-node@v4 + with: + node-version: "24.14.0" + cache: pnpm + cache-dependency-path: application/pnpm-lock.yaml + - run: cd application && pnpm install --frozen-lockfile + - uses: actions/download-artifact@v4 + with: + name: client-dist + path: application/dist/ + - name: Get Playwright version + id: playwright-version + run: echo "version=$(cd application/e2e && npx playwright --version)" >> "$GITHUB_OUTPUT" + - uses: actions/cache@v4 + id: playwright-cache + with: + path: ~/Library/Caches/ms-playwright + key: playwright-macos-${{ steps.playwright-version.outputs.version }} + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: cd application && pnpm --filter @web-speed-hackathon-2026/e2e exec playwright install --with-deps chrome + - name: Install Playwright deps (cached) + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: cd application && pnpm --filter @web-speed-hackathon-2026/e2e exec playwright install-deps chrome + - name: Start server and run E2E tests + env: + E2E_WORKERS: 2 + run: | + cd application && pnpm run start & + for i in $(seq 1 30); do curl -s http://localhost:3000 > /dev/null 2>&1 && break; sleep 2; done + cd application/e2e && pnpm run test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + - uses: actions/upload-artifact@v4 + if: always() + with: + name: e2e-report-${{ matrix.shardIndex }} + path: application/e2e/test-results/ + retention-days: 7 + + docker-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: false + load: true diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000000..b01c4c8e11 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +cd application && npx lint-staged diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..4ba093eb6a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,146 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Web Speed Hackathon 2026 — 架空の SNS「CaX」のパフォーマンスを改善する競技。意図的に重く作られたアプリを高速化する。採点は Lighthouse ベース(1150点満点)。 + +**重要な制約(レギュレーション):** + +- VRT と手動テストが失敗しない範囲でのみ変更可能 +- `fly.toml` は変更禁止 +- `GET /api/v1/crok{?prompt}` の SSE プロトコルは変更禁止 +- `POST /api/v1/initialize` でデータベースが初期値にリセットされること必須 +- シードデータの各種 ID は変更禁止 + +## Commands + +### Setup + +```bash +mise trust && mise install # Node 24.14.0 + pnpm 10.32.1 +cd application && pnpm install --frozen-lockfile +``` + +### Build & Run (application/ ディレクトリ内) + +```bash +pnpm run build # Webpack でクライアントビルド +pnpm run start # サーバー起動 (http://localhost:3000) +``` + +### Lint & Format (application/ ディレクトリ内) + +```bash +pnpm run format # oxlint --fix && oxfmt +pnpm run typecheck # 全ワークスペースの tsc +``` + +### E2E テスト (application/ ディレクトリ内) + +```bash +pnpm --filter @web-speed-hackathon-2026/e2e exec playwright install chromium +pnpm run build && pnpm run start # サーバー起動が前提 +cd e2e && pnpm run test # VRT 実行 (localhost:3000) +cd e2e && pnpm run test:update # スナップショット更新 +E2E_BASE_URL=https://example.com pnpm run test # リモート対象 +``` + +### ローカル採点 (scoring-tool/ ディレクトリ内) + +```bash +cd scoring-tool && pnpm install --frozen-lockfile +pnpm start --applicationUrl http://localhost:3000 +pnpm start --applicationUrl http://localhost:3000 --targetName # 計測名一覧 +pnpm start --applicationUrl http://localhost:3000 --targetName "投稿" # 特定の計測のみ +``` + +### シードデータ (application/server/ ディレクトリ内) + +```bash +pnpm run seed:generate # シード生成 +pnpm run seed:insert # シード挿入 +``` + +## Architecture + +### Monorepo 構成 (pnpm workspaces) + +- `application/client/` — React 19 SPA (Webpack 5, Babel, React Router v7) +- `application/server/` — Express 5 API サーバー (Sequelize + SQLite, tsx で実行) +- `application/e2e/` — Playwright VRT +- `application/upload/` — ユーザーアップロードメディアの保存先 +- `scoring-tool/` — Lighthouse ベースの採点ツール(独立した pnpm workspace) + +### Key Files + +- `application/client/webpack.config.js` — Webpack ビルド設定(最適化の主要編集対象) +- `application/client/src/index.tsx` — クライアントエントリポイント +- `application/client/src/containers/AppContainer.tsx` — ルーティング定義 +- `application/server/src/index.ts` — サーバーエントリポイント +- `application/server/src/routes/api/` — REST API ルート群 +- `application/server/src/eventhub.ts` — リアルタイムイベントバス +- `Dockerfile` — Alpine Linux ベースのデプロイ用ビルド +- `mise.toml` — Node.js / pnpm バージョン管理 + +### Client + +- **エントリポイント**: `client/src/index.tsx` → BrowserRouter +- **ルーティング** (`containers/AppContainer.tsx`): `/`, `/posts/:postId`, `/dm`, `/dm/:conversationId`, `/search`, `/users/:username`, `/terms`, `/crok`。全ページ `React.lazy` でcode-split済み +- **状態管理**: React hooks でローカル管理(Redux は削除済み) +- **ビルド**: Webpack 5。minimize, splitChunks, concatenateModules, usedExports, sideEffects 有効化済み。バンドル合計 約930KB +- **残存する重い依存**: katex(数式レンダリング) + +### Server + +- **エントリポイント**: `server/src/index.ts` → Sequelize 初期化 → Express app 起動 (port 3000) +- **DB**: SQLite (`server/src/database.sqlite` をシードとして temp にコピー)。起動時に毎回初期化 +- **セッション**: express-session (MemoryStore) +- **リアルタイム**: WebSocket (ws) で DM・タイピング通知、SSE で CROK AI チャット +- **API**: `/api/v1/` 以下に REST エンドポイント群。主要ルートファイルは `server/src/routes/api/` 配下 +- **モデル**: User, Post, Image, Movie, Sound, Comment, ProfileImage, DirectMessage, DirectMessageConversation, QaSuggestion +- **API ルート**: auth, initialize, image, movie, post, user, direct_message, sound, crok, search, sentiment, translate +- **イベントバス**: `server/src/eventhub.ts` (Node EventEmitter) でリアルタイム機能を実装 + +### 採点ページ(9ページ表示 + 5シナリオ操作) + +表示: ホーム, 投稿詳細(テキスト/写真/動画/音声), DM一覧, DM詳細, 検索, 利用規約 +操作: 認証, DM送信, 検索, Crok AI, 投稿 + +## Git & PRs + +- `gh` コマンド実行時は `GITHUB_TOKEN=` 環境変数を付与する +- PRを作成する前に `git remote -v` と `gh repo view --json nameWithOwner` でリモートを確認し、ターゲットリポジトリをユーザーに確認する +- origin = `leonard475192/web-speed-hackathon-2026`(フォーク)、upstream = `CyberAgentHack/web-speed-hackathon-2026`(本家) +- PRは常に **origin(フォーク)** に対して作成する。upstream に PR を作らない +- main に直接 push しない。必ずフィーチャーブランチを作成する +- 1改善 = 1PR の粒度を保つ + +## デバッグ指針 + +- ホワイトスクリーンやアプリ障害時は、ブラウザキャッシュクリアなどの表面的な対処ではなく、エラーハンドリングコード(API レスポンスハンドラ等)を調査する +- CI 失敗時は修正を試みる前に: 1) CI 設定ファイルを読む、2) エラーログ全文を読む、3) 根本原因の仮説を立ててユーザーに共有する +- 修正が成功したと主張する前に、実際のテスト出力で検証する + +## Development Notes + +### 並行開発 + +- パフォーマンス改善は worktree を使い並行して実施する(`EnterWorktree` → agent 並列実行) +- 1改善 = 1ブランチ = 1PR の粒度を保つ + +### Docker / デプロイ制約 + +- Dockerfile は Alpine Linux ベース。ネイティブアドオンを含む npm パッケージ(例: `shrink-ray-current`, `node-zopfli-es`)はビルドに失敗する可能性が高い。pure JS の代替を選ぶこと(例: `compression`) +- `fly.toml` は変更禁止 + +### CI 注意事項 + +- CI 上で oxlint / oxfmt を実行する場合は `pnpm exec` を使う(`npx` は不可) + +### パフォーマンス変更時のワークフロー + +- パフォーマンス変更後は必ず E2E テスト(VRT)を実行して視覚的な差分がないか確認すること +- 変更が多岐にわたる場合、最初から個別ブランチで作業し、1改善=1PR の粒度を保つ +- `pnpm run build && pnpm run start` 後に `cd e2e && pnpm run test` で確認 diff --git a/application/client/babel.config.js b/application/client/babel.config.js index c3c574591a..57b15ff356 100644 --- a/application/client/babel.config.js +++ b/application/client/babel.config.js @@ -4,16 +4,14 @@ module.exports = { [ "@babel/preset-env", { - targets: "ie 11", - corejs: "3", - modules: "commonjs", + targets: "defaults", + modules: false, useBuiltIns: false, }, ], [ "@babel/preset-react", { - development: true, runtime: "automatic", }, ], diff --git a/application/client/package.json b/application/client/package.json index 9f8e80a6a8..6f346c0fba 100644 --- a/application/client/package.json +++ b/application/client/package.json @@ -5,51 +5,23 @@ "license": "MPL-2.0", "author": "CyberAgent, Inc.", "scripts": { - "build": "NODE_ENV=development webpack", + "build": "NODE_ENV=production 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", - "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-hook-form": "7.71.2", "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,22 +29,12 @@ "@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", "copy-webpack-plugin": "13.0.1", "css-loader": "7.1.2", @@ -83,6 +45,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..0c9a35ec92 100644 --- a/application/client/postcss.config.js +++ b/application/client/postcss.config.js @@ -1,9 +1,11 @@ const postcssImport = require("postcss-import"); +const tailwindcss = require("@tailwindcss/postcss"); const postcssPresetEnv = require("postcss-preset-env"); module.exports = { plugins: [ postcssImport(), + tailwindcss(), postcssPresetEnv({ stage: 3, }), diff --git a/application/client/src/auth/validation.ts b/application/client/src/auth/validation.ts index 2a83bbfb15..37c9771916 100644 --- a/application/client/src/auth/validation.ts +++ b/application/client/src/auth/validation.ts @@ -1,30 +1,28 @@ -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): Record => { + const errors: Record = {}; const normalizedName = values.name?.trim() || ""; const normalizedPassword = values.password?.trim() || ""; const normalizedUsername = values.username?.trim() || ""; if (values.type === "signup" && normalizedName.length === 0) { - errors.name = "名前を入力してください"; + errors["name"] = "名前を入力してください"; } - if (/^(?:[^\P{Letter}&&\P{Number}]*){16,}$/v.test(normalizedPassword)) { - errors.password = "パスワードには記号を含める必要があります"; + if (/^[\p{Letter}\p{Number}]{16,}$/v.test(normalizedPassword)) { + errors["password"] = "パスワードには記号を含める必要があります"; } if (normalizedPassword.length === 0) { - errors.password = "パスワードを入力してください"; + errors["password"] = "パスワードを入力してください"; } if (!/^[a-zA-Z0-9_]*$/.test(normalizedUsername)) { - errors.username = "ユーザー名に使用できるのは英数字とアンダースコア(_)のみです"; + errors["username"] = "ユーザー名に使用できるのは英数字とアンダースコア(_)のみです"; } if (normalizedUsername.length === 0) { - errors.username = "ユーザー名を入力してください"; + errors["username"] = "ユーザー名を入力してください"; } return errors; diff --git a/application/client/src/components/application/AccountMenu.tsx b/application/client/src/components/application/AccountMenu.tsx index b6df12bbab..9eabc93816 100644 --- a/application/client/src/components/application/AccountMenu.tsx +++ b/application/client/src/components/application/AccountMenu.tsx @@ -40,7 +40,7 @@ export const AccountMenu = ({ user, onLogout }: Props) => { {user.profileImage.alt}
{user.name}
diff --git a/application/client/src/components/application/SearchPage.tsx b/application/client/src/components/application/SearchPage.tsx index e99045de45..3033659ea0 100644 --- a/application/client/src/components/application/SearchPage.tsx +++ b/application/client/src/components/application/SearchPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; 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 { @@ -9,41 +9,46 @@ import { } 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"; +import { fetchJSON } from "@web-speed-hackathon-2026/client/src/utils/fetchers"; import { Button } from "../foundation/Button"; interface Props { query: string; results: Models.Post[]; + defaultValues?: SearchFormData; } -const SearchInput = ({ input, meta }: WrappedFieldProps) => ( -
- - {meta.touched && meta.error && ( - {meta.error} - )} -
-); - -const SearchPageComponent = ({ - query, - results, - handleSubmit, -}: Props & InjectedFormProps) => { +export const SearchPage = ({ query, results, defaultValues }: Props) => { const navigate = useNavigate(); const [isNegative, setIsNegative] = useState(false); + const { + register, + handleSubmit, + reset, + formState: { errors, touchedFields, isSubmitted }, + } = useForm({ + defaultValues: defaultValues ?? { searchText: "" }, + mode: "onChange", + resolver: (values) => { + const validationErrors = validate(values); + const fieldErrors: Record = {}; + for (const [key, message] of Object.entries(validationErrors)) { + if (message) { + fieldErrors[key] = { type: "validate", message }; + } + } + return { values: Object.keys(fieldErrors).length === 0 ? values : {}, errors: fieldErrors }; + }, + }); + + useEffect(() => { + if (defaultValues) { + reset(defaultValues); + } + }, [defaultValues?.searchText]); + const parsed = parseSearchQuery(query); useEffect(() => { @@ -53,20 +58,25 @@ const SearchPageComponent = ({ } let isMounted = true; - analyzeSentiment(parsed.keywords) - .then((result) => { - if (isMounted) { - setIsNegative(result.label === "negative"); - } - }) - .catch(() => { - if (isMounted) { - setIsNegative(false); - } - }); + const timerId = setTimeout(() => { + fetchJSON<{ score: number; label: string }>( + `/api/v1/sentiment?text=${encodeURIComponent(parsed.keywords)}`, + ) + .then((result) => { + if (isMounted) { + setIsNegative(result.label === "negative"); + } + }) + .catch(() => { + if (isMounted) { + setIsNegative(false); + } + }); + }, 300); return () => { isMounted = false; + clearTimeout(timerId); }; }, [parsed.keywords]); @@ -94,7 +104,21 @@ const SearchPageComponent = ({
- +
+ + {(touchedFields.searchText || isSubmitted) && errors.searchText && ( + {errors.searchText.message} + )} +
@@ -134,9 +158,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..b7a1cb4b8d 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 { useForm, Controller } from "react-hook-form"; import { AuthFormData } from "@web-speed-hackathon-2026/client/src/auth/types"; import { validate } from "@web-speed-hackathon-2026/client/src/auth/validation"; @@ -10,25 +9,50 @@ 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 { + control, + handleSubmit, + watch, + setValue, + setError, + formState: { errors, isSubmitting, isValid, touchedFields, isSubmitted }, + } = useForm({ + defaultValues: { + type: "signin", + username: "", + name: "", + password: "", + }, + mode: "onChange", + resolver: (values) => { + const validationErrors = validate(values); + const fieldErrors: Record = {}; + for (const [key, message] of Object.entries(validationErrors)) { + if (message) { + fieldErrors[key] = { type: "validate", message }; + } + } + return { values: Object.keys(fieldErrors).length === 0 ? values : {}, errors: fieldErrors }; + }, + }); + + const type = watch("type"); + + const submitHandler = async (data: AuthFormData) => { + try { + await onSubmit(data); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + setError("root", { message }); + } + }; return ( - +

{type === "signin" ? "サインイン" : "新規登録"}

@@ -36,7 +60,7 @@ const AuthModalPageComponent = ({
- @, - autoComplete: "username", - }} + control={control} + render={({ field, fieldState }) => ( + @} + autoComplete="username" + field={field} + error={fieldState.error} + isTouched={touchedFields.username || isSubmitted} + /> + )} /> {type === "signup" && ( - ( + + )} /> )} - ( + + )} />
@@ -85,19 +124,11 @@ const AuthModalPageComponent = ({

) : null} - + {type === "signin" ? "サインイン" : "登録する"} - {error} + {errors.root?.message ?? 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..1bfd04713f 100644 --- a/application/client/src/components/crok/ChatInput.tsx +++ b/application/client/src/components/crok/ChatInput.tsx @@ -1,5 +1,3 @@ -import Bluebird from "bluebird"; -import kuromoji, { type Tokenizer, type IpadicFeatures } from "kuromoji"; import { useEffect, useLayoutEffect, @@ -11,10 +9,6 @@ import { } from "react"; import { FontAwesomeIcon } from "@web-speed-hackathon-2026/client/src/components/foundation/FontAwesomeIcon"; -import { - extractTokens, - filterSuggestionsBM25, -} from "@web-speed-hackathon-2026/client/src/utils/bm25_search"; import { fetchJSON } from "@web-speed-hackathon-2026/client/src/utils/fetchers"; interface Props { @@ -79,7 +73,6 @@ function highlightMatchByTokens(text: string, queryTokens: string[]): React.Reac export const ChatInput = ({ isStreaming, onSendMessage }: Props) => { const textareaRef = useRef(null); const suggestionsRef = useRef(null); - const [tokenizer, setTokenizer] = useState | null>(null); const [inputValue, setInputValue] = useState(""); const [suggestions, setSuggestions] = useState([]); const [queryTokens, setQueryTokens] = useState([]); @@ -92,60 +85,35 @@ export const ChatInput = ({ isStreaming, onSendMessage }: Props) => { } }, [suggestions, showSuggestions]); - // 初回にkuromojiトークナイザーを構築 - useEffect(() => { - let mounted = true; - - const init = async () => { - const builder = Bluebird.promisifyAll(kuromoji.builder({ dicPath: "/dicts" })); - const nextTokenizer = await builder.buildAsync(); - if (mounted) { - setTokenizer(nextTokenizer); - } - }; - init(); - - return () => { - mounted = false; - }; - }, []); - useEffect(() => { let cancelled = false; - const updateSuggestions = async () => { - if (!tokenizer || !inputValue.trim()) { - setSuggestions([]); - setQueryTokens([]); - setShowSuggestions(false); - return; - } - - const { suggestions: candidates } = await fetchJSON<{ suggestions: string[] }>( - "/api/v1/crok/suggestions", - ); - if (cancelled) { - return; - } - - const tokens = extractTokens(tokenizer.tokenize(inputValue)); - const results = filterSuggestionsBM25(tokenizer, candidates, tokens); - - if (cancelled) { - return; - } - - setQueryTokens(tokens); - setSuggestions(results); - setShowSuggestions(results.length > 0); - }; + if (!inputValue.trim()) { + setSuggestions([]); + setQueryTokens([]); + setShowSuggestions(false); + return; + } + + const timerId = setTimeout(() => { + const updateSuggestions = async () => { + const result = await fetchJSON<{ suggestions: string[]; tokens: string[] }>( + `/api/v1/crok/suggestions?q=${encodeURIComponent(inputValue)}`, + ); + if (cancelled) return; - void updateSuggestions(); + setQueryTokens(result.tokens); + setSuggestions(result.suggestions); + setShowSuggestions(result.suggestions.length > 0); + }; + void updateSuggestions(); + }, 200); return () => { cancelled = true; + clearTimeout(timerId); }; - }, [inputValue, tokenizer]); + }, [inputValue]); const adjustTextareaHeight = () => { const textarea = textareaRef.current; diff --git a/application/client/src/components/crok/ChatMessage.tsx b/application/client/src/components/crok/ChatMessage.tsx index ea4a10d027..fd20843781 100644 --- a/application/client/src/components/crok/ChatMessage.tsx +++ b/application/client/src/components/crok/ChatMessage.tsx @@ -1,15 +1,15 @@ -import "katex/dist/katex.min.css"; -import Markdown from "react-markdown"; -import rehypeKatex from "rehype-katex"; -import remarkGfm from "remark-gfm"; -import remarkMath from "remark-math"; +import { Suspense, lazy, memo } from "react"; -import { CodeBlock } from "@web-speed-hackathon-2026/client/src/components/crok/CodeBlock"; import { TypingIndicator } from "@web-speed-hackathon-2026/client/src/components/crok/TypingIndicator"; import { CrokLogo } from "@web-speed-hackathon-2026/client/src/components/foundation/CrokLogo"; +const MarkdownRenderer = lazy(() => + import("./MarkdownRenderer").then((m) => ({ default: m.MarkdownRenderer })), +); + interface Props { message: Models.ChatMessage; + isStreaming: boolean; } const UserMessage = ({ content }: { content: string }) => { @@ -22,7 +22,7 @@ const UserMessage = ({ content }: { content: string }) => { ); }; -const AssistantMessage = ({ content }: { content: string }) => { +const AssistantMessage = ({ content, isStreaming }: { content: string; isStreaming: boolean }) => { return (
@@ -32,14 +32,13 @@ const AssistantMessage = ({ content }: { content: string }) => {
Crok
{content ? ( - - {content} - + isStreaming ? ( +

{content}

+ ) : ( + {content}

}> + +
+ ) ) : ( )} @@ -49,9 +48,9 @@ const AssistantMessage = ({ content }: { content: string }) => { ); }; -export const ChatMessage = ({ message }: Props) => { +export const ChatMessage = memo(function ChatMessage({ message, isStreaming }: Props) { if (message.role === "user") { return ; } - return ; -}; + return ; +}); diff --git a/application/client/src/components/crok/CodeBlock.tsx b/application/client/src/components/crok/CodeBlock.tsx index 358a6bbc15..f914be3729 100644 --- a/application/client/src/components/crok/CodeBlock.tsx +++ b/application/client/src/components/crok/CodeBlock.tsx @@ -1,7 +1,14 @@ import { ComponentProps, isValidElement, ReactElement, ReactNode } from "react"; -import SyntaxHighlighter from "react-syntax-highlighter"; +import js from "react-syntax-highlighter/dist/esm/languages/hljs/javascript"; +import python from "react-syntax-highlighter/dist/esm/languages/hljs/python"; +import typescript from "react-syntax-highlighter/dist/esm/languages/hljs/typescript"; +import SyntaxHighlighter from "react-syntax-highlighter/dist/esm/light"; import { atomOneLight } from "react-syntax-highlighter/dist/esm/styles/hljs"; +SyntaxHighlighter.registerLanguage("javascript", js); +SyntaxHighlighter.registerLanguage("python", python); +SyntaxHighlighter.registerLanguage("typescript", typescript); + const getLanguage = (children: ReactElement>) => { const className = children.props.className; if (typeof className === "string") { diff --git a/application/client/src/components/crok/CrokGate.tsx b/application/client/src/components/crok/CrokGate.tsx index d47ede39c2..9383faffde 100644 --- a/application/client/src/components/crok/CrokGate.tsx +++ b/application/client/src/components/crok/CrokGate.tsx @@ -1,4 +1,4 @@ -import { Helmet } from "react-helmet"; +import { useEffect } from "react"; interface Props { headline: string; @@ -13,11 +13,12 @@ export const CrokGate = ({ buttonLabel = "サインイン", authModalId, }: Props) => { + useEffect(() => { + document.title = "Crok - CaX"; + }, []); + return ( <> - - Crok - CaX -

{headline}

{description !== "" ?

{description}

: null} diff --git a/application/client/src/components/crok/CrokPage.tsx b/application/client/src/components/crok/CrokPage.tsx index 0be7678f84..013bdb0b7f 100644 --- a/application/client/src/components/crok/CrokPage.tsx +++ b/application/client/src/components/crok/CrokPage.tsx @@ -28,7 +28,13 @@ export const CrokPage = ({ messages, isStreaming, onSendMessage }: Props) => { {messages.length === 0 && } {messages.map((message, index) => ( - + ))}
diff --git a/application/client/src/components/crok/MarkdownRenderer.tsx b/application/client/src/components/crok/MarkdownRenderer.tsx new file mode 100644 index 0000000000..117c918fc3 --- /dev/null +++ b/application/client/src/components/crok/MarkdownRenderer.tsx @@ -0,0 +1,23 @@ +import Markdown from "react-markdown"; +import rehypeKatex from "rehype-katex"; +import remarkGfm from "remark-gfm"; +import remarkMath from "remark-math"; + +import { CodeBlock } from "@web-speed-hackathon-2026/client/src/components/crok/CodeBlock"; + +// @ts-expect-error -- dynamic CSS import for code splitting; no type declarations needed +void import("katex/dist/katex.min.css"); + +const markdownComponents = { pre: CodeBlock }; +const rehypePluginsList = [rehypeKatex]; +const remarkPluginsList = [remarkMath, remarkGfm]; + +export const MarkdownRenderer = ({ content }: { content: string }) => ( + + {content} + +); diff --git a/application/client/src/components/direct_message/DirectMessageGate.tsx b/application/client/src/components/direct_message/DirectMessageGate.tsx index effa57979c..e156391694 100644 --- a/application/client/src/components/direct_message/DirectMessageGate.tsx +++ b/application/client/src/components/direct_message/DirectMessageGate.tsx @@ -1,4 +1,4 @@ -import { Helmet } from "react-helmet"; +import { useEffect } from "react"; interface Props { headline: string; @@ -13,11 +13,12 @@ export const DirectMessageGate = ({ buttonLabel = "サインイン", authModalId, }: Props) => { + useEffect(() => { + document.title = "ダイレクトメッセージ - CaX"; + }, []); + return ( <> - - ダイレクトメッセージ - CaX -

{headline}

{description !== "" ?

{description}

: null} diff --git a/application/client/src/components/direct_message/DirectMessageListPage.tsx b/application/client/src/components/direct_message/DirectMessageListPage.tsx index 5a373e918e..1a6b1de965 100644 --- a/application/client/src/components/direct_message/DirectMessageListPage.tsx +++ b/application/client/src/components/direct_message/DirectMessageListPage.tsx @@ -1,10 +1,10 @@ -import moment from "moment"; import { useCallback, useEffect, useState } from "react"; import { Button } from "@web-speed-hackathon-2026/client/src/components/foundation/Button"; import { FontAwesomeIcon } from "@web-speed-hackathon-2026/client/src/components/foundation/FontAwesomeIcon"; import { Link } from "@web-speed-hackathon-2026/client/src/components/foundation/Link"; import { useWs } from "@web-speed-hackathon-2026/client/src/hooks/use_ws"; +import { fromNow } from "@web-speed-hackathon-2026/client/src/utils/date"; import { fetchJSON } from "@web-speed-hackathon-2026/client/src/utils/fetchers"; import { getProfileImagePath } from "@web-speed-hackathon-2026/client/src/utils/get_path"; @@ -42,7 +42,13 @@ export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => { }); if (conversations == null) { - return null; + return ( +
+
+

ダイレクトメッセージ

+
+
+ ); } return ( @@ -75,10 +81,8 @@ export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => { ? conversation.initiator : conversation.member; - const lastMessage = messages.at(-1); - const hasUnread = messages - .filter((m) => m.sender.id === peer.id) - .some((m) => !m.isRead); + const lastMessage = messages[0]; + const hasUnread = (conversation as any).hasUnread; return (
  • @@ -87,7 +91,7 @@ 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()} + {fromNow(lastMessage.createdAt)} )}
    diff --git a/application/client/src/components/direct_message/DirectMessagePage.tsx b/application/client/src/components/direct_message/DirectMessagePage.tsx index 098c7d2894..1773d44b49 100644 --- a/application/client/src/components/direct_message/DirectMessagePage.tsx +++ b/application/client/src/components/direct_message/DirectMessagePage.tsx @@ -1,5 +1,4 @@ import classNames from "classnames"; -import moment from "moment"; import { ChangeEvent, useCallback, @@ -13,6 +12,7 @@ import { import { FontAwesomeIcon } from "@web-speed-hackathon-2026/client/src/components/foundation/FontAwesomeIcon"; import { DirectMessageFormData } from "@web-speed-hackathon-2026/client/src/direct_message/types"; +import { formatTime } from "@web-speed-hackathon-2026/client/src/utils/date"; import { getProfileImagePath } from "@web-speed-hackathon-2026/client/src/utils/get_path"; interface Props { @@ -44,6 +44,7 @@ export const DirectMessagePage = ({ const textAreaRows = Math.min((text || "").split("\n").length, 5); const isInvalid = text.trim().length === 0; const scrollHeightRef = useRef(0); + const messageListRef = useRef(null); const handleChange = useCallback( (event: ChangeEvent) => { @@ -74,15 +75,25 @@ export const DirectMessagePage = ({ ); useEffect(() => { - const id = setInterval(() => { - const height = Number(window.getComputedStyle(document.body).height.replace("px", "")); + const scrollToBottom = () => { + const height = document.body.scrollHeight; if (height !== scrollHeightRef.current) { scrollHeightRef.current = height; window.scrollTo(0, height); } - }, 1); + }; - return () => clearInterval(id); + scrollToBottom(); + + const target = messageListRef.current; + if (!target) return; + + const observer = new MutationObserver(() => { + scrollToBottom(); + }); + observer.observe(target, { childList: true, subtree: true, characterData: true }); + + return () => observer.disconnect(); }, []); if (conversationError != null) { @@ -99,7 +110,7 @@ export const DirectMessagePage = ({ {peer.profileImage.alt}

    @@ -111,7 +122,10 @@ export const DirectMessagePage = ({

    -
    +
    {conversation.messages.length === 0 && (

    まだメッセージはありません。最初のメッセージを送信してみましょう。 @@ -140,9 +154,7 @@ export const DirectMessagePage = ({ {message.body}

    - + {isActiveUserSend && message.isRead && ( 既読 )} diff --git a/application/client/src/components/direct_message/NewDirectMessageModalPage.tsx b/application/client/src/components/direct_message/NewDirectMessageModalPage.tsx index 7ada76bacf..ac0d02860c 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 { useForm, Controller } from "react-hook-form"; 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,63 @@ 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 { + control, + handleSubmit, + setError, + formState: { errors, isSubmitting, isValid, touchedFields, isSubmitted }, + } = useForm({ + defaultValues: { + username: "", + }, + mode: "onChange", + resolver: (values) => { + const validationErrors = validate(values); + const fieldErrors: Record = {}; + for (const [key, message] of Object.entries(validationErrors)) { + if (message) { + fieldErrors[key] = { type: "validate", message }; + } + } + return { values: Object.keys(fieldErrors).length === 0 ? values : {}, errors: fieldErrors }; + }, + }); + + const submitHandler = async (data: NewDirectMessageFormData) => { + try { + await onSubmit(data); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + setError("root", { message }); + } + }; + return (

    新しくDMを始める

    -
    - + @, - }} + control={control} + render={({ field, fieldState }) => ( + @} + field={field} + error={fieldState.error} + isTouched={touchedFields.username || isSubmitted} + /> + )} />
    - + DMを開始
    - {error} + {errors.root?.message ?? 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..51660e8192 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; @@ -6,32 +6,10 @@ interface Props { children: ReactNode; } -/** - * 親要素の横幅を基準にして、指定したアスペクト比のブロック要素を作ります - */ 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..8669dadc9c 100644 --- a/application/client/src/components/foundation/CoveredImage.tsx +++ b/application/client/src/components/foundation/CoveredImage.tsx @@ -1,70 +1,34 @@ -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?: "low" | "high" | "auto"; } /** * アスペクト比を維持したまま、要素のコンテンツボックス全体を埋めるように画像を拡大縮小します */ -export const CoveredImage = ({ src }: Props) => { +export const CoveredImage = ({ alt, src, loading, 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 top-1/2 left-1/2 h-full w-full max-w-none -translate-x-1/2 -translate-y-1/2 object-cover" + decoding="async" + fetchPriority={fetchPriority} + loading={loading} + src={src} />

    -

    diff --git a/application/client/src/components/post/ImageArea.tsx b/application/client/src/components/post/ImageArea.tsx index 27fe9c018c..9c5d2b646e 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[]; + isAboveFold?: boolean; } -export const ImageArea = ({ images }: Props) => { +export const ImageArea = ({ images, isAboveFold }: Props) => { return (
    @@ -24,7 +25,12 @@ export const ImageArea = ({ images }: Props) => { "row-span-2": images.length <= 2 || (images.length === 3 && idx === 0), })} > - +
    ); })} diff --git a/application/client/src/components/post/PostItem.tsx b/application/client/src/components/post/PostItem.tsx index 5fa904c91a..f347befc92 100644 --- a/application/client/src/components/post/PostItem.tsx +++ b/application/client/src/components/post/PostItem.tsx @@ -1,17 +1,18 @@ -import moment from "moment"; +import { memo } from "react"; import { Link } from "@web-speed-hackathon-2026/client/src/components/foundation/Link"; import { ImageArea } from "@web-speed-hackathon-2026/client/src/components/post/ImageArea"; import { MovieArea } from "@web-speed-hackathon-2026/client/src/components/post/MovieArea"; import { SoundArea } from "@web-speed-hackathon-2026/client/src/components/post/SoundArea"; import { TranslatableText } from "@web-speed-hackathon-2026/client/src/components/post/TranslatableText"; +import { formatLongDate, toISOString } from "@web-speed-hackathon-2026/client/src/utils/date"; import { getProfileImagePath } from "@web-speed-hackathon-2026/client/src/utils/get_path"; interface Props { post: Models.Post; } -export const PostItem = ({ post }: Props) => { +export const PostItem = memo(function PostItem({ post }: Props) { return (
    @@ -23,7 +24,11 @@ export const PostItem = ({ post }: Props) => { > {post.user.profileImage.alt}
    @@ -52,7 +57,7 @@ export const PostItem = ({ post }: Props) => {
    {post.images?.length > 0 ? (
    - +
    ) : null} {post.movie ? ( @@ -67,13 +72,11 @@ export const PostItem = ({ post }: Props) => { ) : null}

    - +

    ); -}; +}); diff --git a/application/client/src/components/post/TranslatableText.tsx b/application/client/src/components/post/TranslatableText.tsx index d772529d92..eec406ada9 100644 --- a/application/client/src/components/post/TranslatableText.tsx +++ b/application/client/src/components/post/TranslatableText.tsx @@ -1,6 +1,6 @@ -import { useCallback, useState } from "react"; +import { memo, useCallback, useState } from "react"; -import { createTranslator } from "@web-speed-hackathon-2026/client/src/utils/create_translator"; +import { sendJSON } from "@web-speed-hackathon-2026/client/src/utils/fetchers"; type State = | { type: "idle"; text: string } @@ -11,7 +11,7 @@ interface Props { text: string; } -export const TranslatableText = ({ text }: Props) => { +export const TranslatableText = memo(function TranslatableText({ text }: Props) { const [state, updateState] = useState({ type: "idle", text }); const handleClick = useCallback(() => { @@ -20,11 +20,11 @@ export const TranslatableText = ({ text }: Props) => { (async () => { updateState({ type: "loading" }); try { - using translator = await createTranslator({ - sourceLanguage: "ja", - targetLanguage: "en", + const { result } = await sendJSON<{ result: string }>("/api/v1/translate", { + text: state.text, + sourceLang: "ja", + targetLang: "en", }); - const result = await translator.translate(state.text); updateState({ type: "translated", @@ -76,4 +76,4 @@ export const TranslatableText = ({ text }: Props) => {

    ); -}; +}); diff --git a/application/client/src/components/timeline/Timeline.tsx b/application/client/src/components/timeline/Timeline.tsx index 752a4d973b..6b80a1e409 100644 --- a/application/client/src/components/timeline/Timeline.tsx +++ b/application/client/src/components/timeline/Timeline.tsx @@ -5,10 +5,21 @@ interface Props { } export const Timeline = ({ timeline }: Props) => { + const firstImagePostIdx = timeline.findIndex((p) => p.images?.length > 0); + return (
    - {timeline.map((post) => { - return ; + {timeline.map((post, index) => { + return ( + + ); })}
    ); diff --git a/application/client/src/components/timeline/TimelineItem.tsx b/application/client/src/components/timeline/TimelineItem.tsx index 21b88980f8..ed1fbdd11d 100644 --- a/application/client/src/components/timeline/TimelineItem.tsx +++ b/application/client/src/components/timeline/TimelineItem.tsx @@ -1,17 +1,16 @@ -import moment from "moment"; -import { MouseEventHandler, useCallback } from "react"; +import { memo, MouseEventHandler, useCallback } from "react"; import { Link, useNavigate } from "react-router"; import { ImageArea } from "@web-speed-hackathon-2026/client/src/components/post/ImageArea"; import { MovieArea } from "@web-speed-hackathon-2026/client/src/components/post/MovieArea"; import { SoundArea } from "@web-speed-hackathon-2026/client/src/components/post/SoundArea"; import { TranslatableText } from "@web-speed-hackathon-2026/client/src/components/post/TranslatableText"; +import { formatLongDate, toISOString } from "@web-speed-hackathon-2026/client/src/utils/date"; import { getProfileImagePath } from "@web-speed-hackathon-2026/client/src/utils/get_path"; -const isClickedAnchorOrButton = (target: EventTarget | null, currentTarget: Element): boolean => { +const isClickedAnchor = (target: EventTarget | null, currentTarget: Element): boolean => { while (target !== null && target instanceof Element) { - const tagName = target.tagName.toLowerCase(); - if (["button", "a"].includes(tagName)) { + if (target.tagName.toLowerCase() === "a") { return true; } if (currentTarget === target) { @@ -28,9 +27,15 @@ const isClickedAnchorOrButton = (target: EventTarget | null, currentTarget: Elem */ interface Props { post: Models.Post; + isAboveFold?: boolean; + isProfileAboveFold?: boolean; } -export const TimelineItem = ({ post }: Props) => { +export const TimelineItem = memo(function TimelineItem({ + post, + isAboveFold, + isProfileAboveFold, +}: Props) { const navigate = useNavigate(); /** @@ -39,7 +44,7 @@ export const TimelineItem = ({ post }: Props) => { const handleClick = useCallback( (ev) => { const isSelectedText = document.getSelection()?.isCollapsed === false; - if (!isClickedAnchorOrButton(ev.target, ev.currentTarget) && !isSelectedText) { + if (!isClickedAnchor(ev.target, ev.currentTarget) && !isSelectedText) { navigate(`/posts/${post.id}`); } }, @@ -56,7 +61,11 @@ export const TimelineItem = ({ post }: Props) => { > {post.user.profileImage.alt}
    @@ -76,9 +85,7 @@ export const TimelineItem = ({ post }: Props) => { - - +

    @@ -86,7 +93,7 @@ export const TimelineItem = ({ post }: Props) => {
    {post.images?.length > 0 ? (
    - +
    ) : null} {post.movie ? ( @@ -103,4 +110,4 @@ 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..19fce56d4d 100644 --- a/application/client/src/components/user_profile/UserProfileHeader.tsx +++ b/application/client/src/components/user_profile/UserProfileHeader.tsx @@ -1,8 +1,8 @@ import { FastAverageColor } from "fast-average-color"; -import moment from "moment"; import { ReactEventHandler, useCallback, useState } from "react"; import { FontAwesomeIcon } from "@web-speed-hackathon-2026/client/src/components/foundation/FontAwesomeIcon"; +import { formatLongDate, toISOString } from "@web-speed-hackathon-2026/client/src/utils/date"; import { getProfileImagePath } from "@web-speed-hackathon-2026/client/src/utils/get_path"; interface Props { @@ -24,12 +24,18 @@ export const UserProfileHeader = ({ user }: Props) => { return (
    @@ -43,9 +49,7 @@ export const UserProfileHeader = ({ user }: Props) => { - + からサービスを利用しています

    diff --git a/application/client/src/containers/AppContainer.tsx b/application/client/src/containers/AppContainer.tsx index d66858a949..9d4f4fe1cc 100644 --- a/application/client/src/containers/AppContainer.tsx +++ b/application/client/src/containers/AppContainer.tsx @@ -1,21 +1,98 @@ -import { useCallback, useEffect, useId, useState } from "react"; -import { Helmet, HelmetProvider } from "react-helmet"; +import { lazy, Suspense, useCallback, useEffect, useId, useRef, useState } from "react"; import { Route, Routes, useLocation, useNavigate } from "react-router"; import { AppPage } from "@web-speed-hackathon-2026/client/src/components/application/AppPage"; -import { AuthModalContainer } from "@web-speed-hackathon-2026/client/src/containers/AuthModalContainer"; -import { CrokContainer } from "@web-speed-hackathon-2026/client/src/containers/CrokContainer"; -import { DirectMessageContainer } from "@web-speed-hackathon-2026/client/src/containers/DirectMessageContainer"; -import { DirectMessageListContainer } from "@web-speed-hackathon-2026/client/src/containers/DirectMessageListContainer"; -import { NewPostModalContainer } from "@web-speed-hackathon-2026/client/src/containers/NewPostModalContainer"; -import { NotFoundContainer } from "@web-speed-hackathon-2026/client/src/containers/NotFoundContainer"; -import { PostContainer } from "@web-speed-hackathon-2026/client/src/containers/PostContainer"; -import { SearchContainer } from "@web-speed-hackathon-2026/client/src/containers/SearchContainer"; -import { TermContainer } from "@web-speed-hackathon-2026/client/src/containers/TermContainer"; -import { TimelineContainer } from "@web-speed-hackathon-2026/client/src/containers/TimelineContainer"; -import { UserProfileContainer } from "@web-speed-hackathon-2026/client/src/containers/UserProfileContainer"; import { fetchJSON, sendJSON } from "@web-speed-hackathon-2026/client/src/utils/fetchers"; +const TimelineContainer = lazy( + () => import("@web-speed-hackathon-2026/client/src/containers/TimelineContainer"), +); +const TermContainer = lazy( + () => import("@web-speed-hackathon-2026/client/src/containers/TermContainer"), +); +const DirectMessageListContainer = lazy( + () => import("@web-speed-hackathon-2026/client/src/containers/DirectMessageListContainer"), +); +const DirectMessageContainer = lazy( + () => import("@web-speed-hackathon-2026/client/src/containers/DirectMessageContainer"), +); +const SearchContainer = lazy( + () => import("@web-speed-hackathon-2026/client/src/containers/SearchContainer"), +); +const UserProfileContainer = lazy( + () => import("@web-speed-hackathon-2026/client/src/containers/UserProfileContainer"), +); +const PostContainer = lazy( + () => import("@web-speed-hackathon-2026/client/src/containers/PostContainer"), +); +const CrokContainer = lazy( + () => import("@web-speed-hackathon-2026/client/src/containers/CrokContainer"), +); +const NotFoundContainer = lazy( + () => import("@web-speed-hackathon-2026/client/src/containers/NotFoundContainer"), +); + +function DeferredModal({ + id, + factory, + componentProps, +}: { + id: string; + factory: () => Promise<{ default: React.ComponentType }>; + componentProps: Record; +}) { + const [Component, setComponent] = useState | null>(null); + const needsReopen = useRef(false); + const placeholderRef = useRef(null); + const factoryRef = useRef(factory); + + useEffect(() => { + const idleId = requestIdleCallback(() => { + factoryRef.current().then((mod) => setComponent(() => mod.default)); + }); + return () => cancelIdleCallback(idleId); + }, []); + + useEffect(() => { + const el = placeholderRef.current; + if (!el || Component) return; + const onToggle = (e: Event) => { + if ((e as ToggleEvent).newState === "open") { + needsReopen.current = true; + el.close(); + factoryRef.current().then((mod) => setComponent(() => mod.default)); + } + }; + el.addEventListener("toggle", onToggle); + return () => el.removeEventListener("toggle", onToggle); + }, [Component]); + + useEffect(() => { + if (Component && needsReopen.current) { + needsReopen.current = false; + requestAnimationFrame(() => { + const dialog = document.getElementById(id) as HTMLDialogElement | null; + if (dialog && !dialog.open) { + dialog.showModal(); + } + }); + } + }, [Component, id]); + + if (!Component) { + return ( + + ); + } + + return ; +} + export const AppContainer = () => { const { pathname } = useLocation(); const navigate = useNavigate(); @@ -24,12 +101,15 @@ export const AppContainer = () => { }, [pathname]); const [activeUser, setActiveUser] = useState(null); - const [isLoadingActiveUser, setIsLoadingActiveUser] = useState(true); + const [_isLoadingActiveUser, setIsLoadingActiveUser] = useState(true); useEffect(() => { void fetchJSON("/api/v1/me") .then((user) => { setActiveUser(user); }) + .catch(() => { + // 未ログイン時は401が返る + }) .finally(() => { setIsLoadingActiveUser(false); }); @@ -43,50 +123,58 @@ export const AppContainer = () => { const authModalId = useId(); const newPostModalId = useId(); - if (isLoadingActiveUser) { - return ( - - - 読込中 - CaX - - - ); - } - return ( - + <> - - } path="/" /> - - } - path="/dm" - /> - } - path="/dm/:conversationId" - /> - } path="/search" /> - } path="/users/:username" /> - } path="/posts/:postId" /> - } path="/terms" /> - } - path="/crok" - /> - } path="*" /> - + }> + + } path="/" /> + + } + path="/dm" + /> + } + path="/dm/:conversationId" + /> + } path="/search" /> + } path="/users/:username" /> + } path="/posts/:postId" /> + } path="/terms" /> + } + path="/crok" + /> + } path="*" /> + + - - - + + import("@web-speed-hackathon-2026/client/src/containers/AuthModalContainer").then( + (m) => ({ + default: m.AuthModalContainer, + }), + ) + } + componentProps={{ onUpdateActiveUser: setActiveUser }} + /> + + import("@web-speed-hackathon-2026/client/src/containers/NewPostModalContainer") + } + componentProps={{}} + /> + ); }; diff --git a/application/client/src/containers/AuthModalContainer.tsx b/application/client/src/containers/AuthModalContainer.tsx index 8d159f3528..45b8f19bf8 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" || @@ -68,10 +67,8 @@ export const AuthModalContainer = ({ id, onUpdateActiveUser }: Props) => { } handleRequestCloseModal(); } catch (err: unknown) { - const error = getErrorCode(err as JQuery.jqXHR, values.type); - throw new SubmissionError({ - _error: error, - }); + const error = getErrorCode(err as { responseJSON?: unknown }, values.type); + throw new Error(error); } }, [handleRequestCloseModal, onUpdateActiveUser], @@ -87,3 +84,5 @@ export const AuthModalContainer = ({ id, onUpdateActiveUser }: Props) => { ); }; + +export default AuthModalContainer; diff --git a/application/client/src/containers/CrokContainer.tsx b/application/client/src/containers/CrokContainer.tsx index 65f4f60e75..dece8099ac 100644 --- a/application/client/src/containers/CrokContainer.tsx +++ b/application/client/src/containers/CrokContainer.tsx @@ -1,5 +1,4 @@ -import { useCallback, useMemo, useState } from "react"; -import { Helmet } from "react-helmet"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { CrokGate } from "@web-speed-hackathon-2026/client/src/components/crok/CrokGate"; import { CrokPage } from "@web-speed-hackathon-2026/client/src/components/crok/CrokPage"; @@ -76,12 +75,13 @@ export const CrokContainer = ({ activeUser, authModalId }: Props) => { ); } + useEffect(() => { + document.title = "Crok - CaX"; + }, []); + return ( - <> - - Crok - CaX - - - + ); }; + +export default CrokContainer; diff --git a/application/client/src/containers/DirectMessageContainer.tsx b/application/client/src/containers/DirectMessageContainer.tsx index 245deac8a6..09d74da114 100644 --- a/application/client/src/containers/DirectMessageContainer.tsx +++ b/application/client/src/containers/DirectMessageContainer.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { Helmet } from "react-helmet"; import { useParams } from "react-router"; import { DirectMessageGate } from "@web-speed-hackathon-2026/client/src/components/direct_message/DirectMessageGate"; @@ -34,6 +33,7 @@ export const DirectMessageContainer = ({ activeUser, authModalId }: Props) => { const [isPeerTyping, setIsPeerTyping] = useState(false); const peerTypingTimeoutRef = useRef | null>(null); + const lastTypingSentRef = useRef(0); const loadConversation = useCallback(async () => { if (activeUser == null) { @@ -65,32 +65,35 @@ export const DirectMessageContainer = ({ activeUser, authModalId }: Props) => { async (params: DirectMessageFormData) => { setIsSubmitting(true); try { - await sendJSON(`/api/v1/dm/${conversationId}/messages`, { + await sendJSON(`/api/v1/dm/${conversationId}/messages`, { body: params.body, }); - loadConversation(); } finally { setIsSubmitting(false); } }, - [conversationId, loadConversation], + [conversationId], ); const handleTyping = useCallback(async () => { + const now = Date.now(); + if (now - lastTypingSentRef.current < 3000) { + return; + } + lastTypingSentRef.current = now; void sendJSON(`/api/v1/dm/${conversationId}/typing`, {}); }, [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; + void loadConversation(); + 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); @@ -103,6 +106,19 @@ export const DirectMessageContainer = ({ activeUser, authModalId }: Props) => { } }); + const peer = + conversation != null && activeUser != null + ? conversation.initiator.id !== activeUser.id + ? conversation.initiator + : conversation.member + : null; + + useEffect(() => { + if (peer != null) { + document.title = `${peer.name} さんとのダイレクトメッセージ - CaX`; + } + }, [peer?.name]); + if (activeUser === null) { return ( { if (conversationError != null) { return ; } - return null; + return
    ; } - const peer = - conversation.initiator.id !== activeUser?.id ? conversation.initiator : conversation.member; - return ( <> - - {peer.name} さんとのダイレクトメッセージ - CaX - { ); }; + +export default DirectMessageContainer; diff --git a/application/client/src/containers/DirectMessageListContainer.tsx b/application/client/src/containers/DirectMessageListContainer.tsx index 578932618f..196e2bb9ea 100644 --- a/application/client/src/containers/DirectMessageListContainer.tsx +++ b/application/client/src/containers/DirectMessageListContainer.tsx @@ -1,5 +1,4 @@ -import { useId } from "react"; -import { Helmet } from "react-helmet"; +import { useEffect, useId } from "react"; import { DirectMessageGate } from "@web-speed-hackathon-2026/client/src/components/direct_message/DirectMessageGate"; import { DirectMessageListPage } from "@web-speed-hackathon-2026/client/src/components/direct_message/DirectMessageListPage"; @@ -13,6 +12,10 @@ interface Props { export const DirectMessageListContainer = ({ activeUser, authModalId }: Props) => { const newDmModalId = useId(); + useEffect(() => { + document.title = "ダイレクトメッセージ - CaX"; + }, []); + if (activeUser === null) { return ( - - ダイレクトメッセージ - CaX - ); }; + +export default DirectMessageListContainer; diff --git a/application/client/src/containers/NewDirectMessageModalContainer.tsx b/application/client/src/containers/NewDirectMessageModalContainer.tsx index fb3f6b168c..8797bc7e8b 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"; @@ -38,9 +37,7 @@ export const NewDirectMessageModalContainer = ({ id }: Props) => { }); navigate(`/dm/${conversation.id}`); } catch { - throw new SubmissionError({ - _error: "ユーザーが見つかりませんでした", - }); + throw new Error("ユーザーが見つかりませんでした"); } }, [navigate], diff --git a/application/client/src/containers/NewPostModalContainer.tsx b/application/client/src/containers/NewPostModalContainer.tsx index ae9484e878..5c12693f6a 100644 --- a/application/client/src/containers/NewPostModalContainer.tsx +++ b/application/client/src/containers/NewPostModalContainer.tsx @@ -15,7 +15,12 @@ interface SubmitParams { async function sendNewPost({ images, movie, sound, text }: SubmitParams): Promise { const payload = { images: images - ? await Promise.all(images.map((image) => sendFile("/api/v1/images", image))) + ? await Promise.all( + images.map(async (image) => { + const result = await sendFile<{ id: string; alt?: string }>("/api/v1/images", image); + return { id: result.id, alt: result.alt ?? "" }; + }), + ) : [], movie: movie ? await sendFile("/api/v1/movies", movie) : undefined, sound: sound ? await sendFile("/api/v1/sounds", sound) : undefined, @@ -39,13 +44,13 @@ export const NewPostModalContainer = ({ id }: Props) => { return; } - const handleToggle = () => { - // モーダル開閉時にkeyを更新することでフォームの状態をリセットする + const handleClose = () => { + // モーダル閉じた時にkeyを更新することでフォームの状態をリセットする setResetKey((key) => key + 1); }; - element.addEventListener("toggle", handleToggle); + element.addEventListener("close", handleClose); return () => { - element.removeEventListener("toggle", handleToggle); + element.removeEventListener("close", handleClose); }; }, []); @@ -87,3 +92,5 @@ export const NewPostModalContainer = ({ id }: Props) => { ); }; + +export default NewPostModalContainer; diff --git a/application/client/src/containers/NotFoundContainer.tsx b/application/client/src/containers/NotFoundContainer.tsx index 1017234fb7..71a1224bf0 100644 --- a/application/client/src/containers/NotFoundContainer.tsx +++ b/application/client/src/containers/NotFoundContainer.tsx @@ -1,14 +1,13 @@ -import { Helmet } from "react-helmet"; +import { useEffect } from "react"; import { NotFoundPage } from "@web-speed-hackathon-2026/client/src/components/application/NotFoundPage"; export const NotFoundContainer = () => { - return ( - <> - - ページが見つかりません - CaX - - - - ); + useEffect(() => { + document.title = "ページが見つかりません - CaX"; + }, []); + + return ; }; + +export default NotFoundContainer; diff --git a/application/client/src/containers/PostContainer.tsx b/application/client/src/containers/PostContainer.tsx index c296554ead..ac0e5447da 100644 --- a/application/client/src/containers/PostContainer.tsx +++ b/application/client/src/containers/PostContainer.tsx @@ -1,4 +1,4 @@ -import { Helmet } from "react-helmet"; +import { useEffect } from "react"; import { useParams } from "react-router"; import { InfiniteScroll } from "@web-speed-hackathon-2026/client/src/components/foundation/InfiniteScroll"; @@ -19,11 +19,34 @@ const PostContainerContent = ({ postId }: { postId: string | undefined }) => { fetchJSON, ); + useEffect(() => { + if (isLoadingPost) { + document.title = "読込中 - CaX"; + } else if (post !== null) { + document.title = `${post.user.name} さんのつぶやき - CaX`; + } + }, [isLoadingPost, post]); + if (isLoadingPost) { return ( - - 読込中 - CaX - +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    ); } @@ -33,9 +56,6 @@ const PostContainerContent = ({ postId }: { postId: string | undefined }) => { return ( - - {post.user.name} さんのつぶやき - CaX - ); @@ -45,3 +65,5 @@ export const PostContainer = () => { const { postId } = useParams(); return ; }; + +export default PostContainer; diff --git a/application/client/src/containers/SearchContainer.tsx b/application/client/src/containers/SearchContainer.tsx index f5cdd4148f..0840c5f1f9 100644 --- a/application/client/src/containers/SearchContainer.tsx +++ b/application/client/src/containers/SearchContainer.tsx @@ -1,4 +1,4 @@ -import { Helmet } from "react-helmet"; +import { useEffect } from "react"; import { SearchPage } from "@web-speed-hackathon-2026/client/src/components/application/SearchPage"; import { InfiniteScroll } from "@web-speed-hackathon-2026/client/src/components/foundation/InfiniteScroll"; @@ -15,12 +15,15 @@ export const SearchContainer = () => { fetchJSON, ); + useEffect(() => { + document.title = "検索 - CaX"; + }, []); + return ( - - 検索 - CaX - - + ); }; + +export default SearchContainer; diff --git a/application/client/src/containers/TermContainer.tsx b/application/client/src/containers/TermContainer.tsx index 4363cafe93..835a4eb935 100644 --- a/application/client/src/containers/TermContainer.tsx +++ b/application/client/src/containers/TermContainer.tsx @@ -1,14 +1,13 @@ -import { Helmet } from "react-helmet"; +import { useEffect } from "react"; import { TermPage } from "@web-speed-hackathon-2026/client/src/components/term/TermPage"; export const TermContainer = () => { - return ( - <> - - 利用規約 - CaX - - - - ); + useEffect(() => { + document.title = "利用規約 - CaX"; + }, []); + + return ; }; + +export default TermContainer; diff --git a/application/client/src/containers/TimelineContainer.tsx b/application/client/src/containers/TimelineContainer.tsx index 3b0ca03356..d34dfe2964 100644 --- a/application/client/src/containers/TimelineContainer.tsx +++ b/application/client/src/containers/TimelineContainer.tsx @@ -1,4 +1,4 @@ -import { Helmet } from "react-helmet"; +import { useEffect } from "react"; import { InfiniteScroll } from "@web-speed-hackathon-2026/client/src/components/foundation/InfiniteScroll"; import { TimelinePage } from "@web-speed-hackathon-2026/client/src/components/timeline/TimelinePage"; @@ -8,12 +8,15 @@ import { fetchJSON } from "@web-speed-hackathon-2026/client/src/utils/fetchers"; export const TimelineContainer = () => { const { data: posts, fetchMore } = useInfiniteFetch("/api/v1/posts", fetchJSON); + useEffect(() => { + document.title = "タイムライン - CaX"; + }, []); + return ( - - タイムライン - CaX - ); }; + +export default TimelineContainer; diff --git a/application/client/src/containers/UserProfileContainer.tsx b/application/client/src/containers/UserProfileContainer.tsx index dae135e89c..ed7262f5d7 100644 --- a/application/client/src/containers/UserProfileContainer.tsx +++ b/application/client/src/containers/UserProfileContainer.tsx @@ -1,4 +1,4 @@ -import { Helmet } from "react-helmet"; +import { useEffect } from "react"; import { useParams } from "react-router"; import { InfiniteScroll } from "@web-speed-hackathon-2026/client/src/components/foundation/InfiniteScroll"; @@ -20,11 +20,24 @@ export const UserProfileContainer = () => { fetchJSON, ); + useEffect(() => { + if (isLoadingUser) { + document.title = "読込中 - CaX"; + } else if (user !== null) { + document.title = `${user.name} さんのタイムライン - CaX`; + } + }, [isLoadingUser, user]); + if (isLoadingUser) { return ( - - 読込中 - CaX - +
    +
    +
    +
    +
    +
    +
    +
    ); } @@ -34,10 +47,9 @@ export const UserProfileContainer = () => { return ( - - {user.name} さんのタイムライン - CaX - ); }; + +export default UserProfileContainer; diff --git a/application/client/src/direct_message/validation.ts b/application/client/src/direct_message/validation.ts index 83220aeffc..3fccb90e8d 100644 --- a/application/client/src/direct_message/validation.ts +++ b/application/client/src/direct_message/validation.ts @@ -1,16 +1,12 @@ -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 = {}; +export const validate = (values: NewDirectMessageFormData): Record => { + const errors: Record = {}; const normalizedUsername = values.username?.trim().replace(/^@/, "") || ""; if (normalizedUsername.length === 0) { - errors.username = "ユーザー名を入力してください"; + errors["username"] = "ユーザー名を入力してください"; } return errors; diff --git a/application/client/src/hooks/use_fetch.ts b/application/client/src/hooks/use_fetch.ts index 36039ed35d..d297cd7cfb 100644 --- a/application/client/src/hooks/use_fetch.ts +++ b/application/client/src/hooks/use_fetch.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; interface ReturnValues { data: T | null; @@ -6,17 +6,38 @@ interface ReturnValues { isLoading: boolean; } +function consumeInitialData(cacheKey: string): T | null { + const store = (window as unknown as { __INITIAL_DATA__?: Record }) + .__INITIAL_DATA__; + if (store && cacheKey in store) { + const data = store[cacheKey] as T; + delete store[cacheKey]; + return data; + } + return null; +} + export function useFetch( apiPath: string, fetcher: (apiPath: string) => Promise, ): ReturnValues { - const [result, setResult] = useState>({ - data: null, - error: null, - isLoading: true, + const usedInitialDataRef = useRef(false); + + const [result, setResult] = useState>(() => { + const cached = consumeInitialData(apiPath); + if (cached != null) { + usedInitialDataRef.current = true; + return { data: cached, error: null, isLoading: false }; + } + return { data: null, error: null, isLoading: true }; }); useEffect(() => { + if (usedInitialDataRef.current) { + usedInitialDataRef.current = false; + return; + } + setResult(() => ({ data: null, error: null, diff --git a/application/client/src/hooks/use_has_content_below.ts b/application/client/src/hooks/use_has_content_below.ts index 97795778b8..dd7fb177e7 100644 --- a/application/client/src/hooks/use_has_content_below.ts +++ b/application/client/src/hooks/use_has_content_below.ts @@ -14,22 +14,25 @@ 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 }); - return () => { - active = false; - }; + const endEl = contentEndRef.current; + const barEl = boundaryRef.current; + if (!endEl || !barEl) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry) { + setHasContentBelow(!entry.isIntersecting); + } + }, + { + root: null, + rootMargin: `-${barEl.getBoundingClientRect().height}px 0px 0px 0px`, + threshold: 0, + }, + ); + + observer.observe(endEl); + return () => observer.disconnect(); }, [contentEndRef, boundaryRef]); return hasContentBelow; diff --git a/application/client/src/hooks/use_infinite_fetch.ts b/application/client/src/hooks/use_infinite_fetch.ts index 394fccd9ea..9f939dc52a 100644 --- a/application/client/src/hooks/use_infinite_fetch.ts +++ b/application/client/src/hooks/use_infinite_fetch.ts @@ -9,16 +9,43 @@ interface ReturnValues { fetchMore: () => void; } +function consumeInitialData(cacheKey: string): T[] | null { + const store = (window as unknown as { __INITIAL_DATA__?: Record }) + .__INITIAL_DATA__; + if (store && cacheKey in store) { + const data = store[cacheKey] as T[]; + delete store[cacheKey]; + return data; + } + return null; +} + export function useInfiniteFetch( apiPath: string, fetcher: (apiPath: string) => Promise, ): ReturnValues { const internalRef = useRef({ isLoading: false, offset: 0 }); + const usedInitialDataRef = useRef(false); - const [result, setResult] = useState, "fetchMore">>({ - data: [], - error: null, - isLoading: true, + const [result, setResult] = useState, "fetchMore">>(() => { + // Check for server-injected initial data + const separator = apiPath.includes("?") ? "&" : "?"; + const cacheKey = `${apiPath}${separator}limit=${LIMIT}&offset=0`; + const cached = consumeInitialData(cacheKey); + if (cached != null) { + internalRef.current = { isLoading: false, offset: LIMIT }; + usedInitialDataRef.current = true; + return { + data: cached, + error: null, + isLoading: false, + }; + } + return { + data: [], + error: null, + isLoading: true, + }; }); const fetchMore = useCallback(() => { @@ -36,11 +63,18 @@ export function useInfiniteFetch( offset, }; - void fetcher(apiPath).then( - (allData) => { + if (!apiPath) { + return; + } + + const separator = apiPath.includes("?") ? "&" : "?"; + const paginatedUrl = `${apiPath}${separator}limit=${LIMIT}&offset=${offset}`; + + void fetcher(paginatedUrl).then( + (pageData) => { setResult((cur) => ({ ...cur, - data: [...cur.data, ...allData.slice(offset, offset + LIMIT)], + data: [...cur.data, ...pageData], isLoading: false, })); internalRef.current = { @@ -63,6 +97,12 @@ export function useInfiniteFetch( }, [apiPath, fetcher]); useEffect(() => { + // Skip initial fetch if we already consumed server-injected data + if (usedInitialDataRef.current) { + usedInitialDataRef.current = false; + return; + } + setResult(() => ({ data: [], error: null, diff --git a/application/client/src/hooks/use_search_params.ts b/application/client/src/hooks/use_search_params.ts index 5c7ec50f19..49d3209a8c 100644 --- a/application/client/src/hooks/use_search_params.ts +++ b/application/client/src/hooks/use_search_params.ts @@ -1,30 +1,33 @@ -import { useEffect, useRef, useState } from "react"; +import { useSyncExternalStore } 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; +let listeners: (() => void)[] = []; - 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 }); - }; +function subscribe(cb: () => void) { + listeners.push(cb); + return () => { + listeners = listeners.filter((l) => l !== cb); + }; +} - scheduler.postTask(poll, { priority: "user-blocking", delay: 1 }); +function getSnapshot() { + return window.location.search; +} - return () => { - active = false; - }; - }, []); +const origPushState = history.pushState.bind(history); +const origReplaceState = history.replaceState.bind(history); +history.pushState = (...args) => { + origPushState(...args); + for (const l of listeners) l(); +}; +history.replaceState = (...args) => { + origReplaceState(...args); + for (const l of listeners) l(); +}; +window.addEventListener("popstate", () => { + for (const l of listeners) l(); +}); - return [searchParams]; +export function useSearchParams(): [URLSearchParams] { + const search = useSyncExternalStore(subscribe, getSnapshot); + return [new URLSearchParams(search)]; } diff --git a/application/client/src/hooks/use_sse.ts b/application/client/src/hooks/use_sse.ts index 24532a9c5a..3acb7d94e0 100644 --- a/application/client/src/hooks/use_sse.ts +++ b/application/client/src/hooks/use_sse.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from "react"; +import { startTransition, useCallback, useRef, useState } from "react"; interface SSEOptions { onMessage: (data: T, prevContent: string) => string; @@ -44,23 +44,43 @@ export function useSSE(options: SSEOptions): ReturnValues { const eventSource = new EventSource(url); eventSourceRef.current = eventSource; + let rafId: number | null = null; + eventSource.onmessage = (event) => { const data = JSON.parse(event.data) as T; const isDone = options.onDone?.(data) ?? false; if (isDone) { - options.onComplete?.(contentRef.current); - stop(); + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + const finalContent = contentRef.current; + eventSourceRef.current?.close(); + eventSourceRef.current = null; + startTransition(() => { + setContent(finalContent); + setIsStreaming(false); + }); + options.onComplete?.(finalContent); return; } const newContent = options.onMessage(data, contentRef.current); contentRef.current = newContent; - setContent(newContent); + + if (rafId === null) { + rafId = requestAnimationFrame(() => { + rafId = null; + setContent(contentRef.current); + }); + } }; eventSource.onerror = (error) => { console.error("SSE Error:", error); + if (rafId !== null) { + cancelAnimationFrame(rafId); + } stop(); }; }, diff --git a/application/client/src/index.css b/application/client/src/index.css index 8612ebcdd2..2f1c5f0c6e 100644 --- a/application/client/src/index.css +++ b/application/client/src/index.css @@ -1,20 +1,22 @@ -@layer normalize, theme, base, components, utilities; - -@import "normalize.css" layer(normalize); +@import "tailwindcss"; @font-face { /* Source Han Serif JP Regular の Y 軸を 1/1.43 に縮小した改変フォント */ font-family: "Rei no Are Mincho"; - font-display: block; - src: url(/fonts/ReiNoAreMincho-Regular.otf) format("opentype"); + font-display: swap; + src: + url(/fonts/ReiNoAreMincho-Regular.woff2) format("woff2"), + url(/fonts/ReiNoAreMincho-Regular.otf) format("opentype"); font-weight: normal; } @font-face { /* 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"); + font-display: swap; + src: + url(/fonts/ReiNoAreMincho-Heavy.woff2) format("woff2"), + url(/fonts/ReiNoAreMincho-Heavy.otf) format("opentype"); font-weight: bold; } @@ -23,3 +25,167 @@ vertical-align: -0.125em; width: 1em; } + +@layer base { + button:not(:disabled), + [role="button"]:not(:disabled) { + cursor: pointer; + } +} + +@theme { + --color-cax-canvas: var(--color-stone-100); + --color-cax-surface: var(--color-white); + --color-cax-surface-raised: var(--color-white); + --color-cax-surface-subtle: var(--color-stone-50); + --color-cax-overlay: var(--color-slate-950); + --color-cax-border: var(--color-stone-300); + --color-cax-border-strong: var(--color-stone-400); + --color-cax-text: var(--color-teal-950); + --color-cax-text-muted: var(--color-teal-700); + --color-cax-text-subtle: var(--color-slate-500); + --color-cax-brand: var(--color-teal-700); + --color-cax-brand-strong: var(--color-teal-800); + --color-cax-brand-soft: var(--color-teal-100); + --color-cax-accent: var(--color-orange-700); + --color-cax-accent-soft: var(--color-orange-100); + --color-cax-danger: var(--color-red-600); + --color-cax-danger-soft: var(--color-red-100); + --color-cax-highlight: var(--color-amber-200); + --color-cax-highlight-ink: var(--color-amber-950); +} + +@utility markdown { + @apply text-sm wrap-anywhere; + line-break: strict; + + /* インライン要素 */ + :where(a) { + @apply text-cax-accent decoration-cax-accent underline underline-offset-2; + } + :where(strong) { + @apply font-bold; + } + :where(em) { + @apply italic; + } + :where(code):not(:where(pre > code)) { + @apply bg-cax-surface-subtle text-cax-text rounded px-1 py-0.5 font-mono; + } + :where(del) { + @apply decoration-cax-text-subtle line-through; + } + + /* ブロック要素 */ + :where(p) { + @apply text-cax-text my-6; + } + :where(blockquote) { + @apply border-cax-border text-cax-text-muted my-6 border-l-4 pl-4; + } + :where(hr) { + @apply border-cax-border my-10 border-t; + } + + /* リスト */ + :where(ol) { + @apply my-6 list-decimal pl-6; + } + :where(ul) { + @apply my-6 list-disc pl-6; + } + :where(li) { + @apply my-2; + } + :where(ol > li, ul > li)::marker { + @apply text-cax-text-muted; + } + :where(ol ol, ul ul, ol ul, ul ol) { + @apply my-2; + } + + /* テーブル */ + :where(table) { + @apply text-cax-text my-6 w-full table-auto text-sm; + } + :where(thead) { + @apply border-cax-border border-b; + } + :where(thead th) { + @apply px-2 pb-1.5 font-bold; + } + :where(tbody tr) { + @apply border-cax-border border-b; + } + :where(tbody tr:last-child) { + @apply border-b-0; + } + :where(tbody td) { + @apply align-baseline; + } + :where(tfoot) { + @apply border-cax-border border-t; + } + :where(tfoot td) { + @apply align-top; + } + :where(th, td) { + @apply text-left; + } + :where(tbody td, tfoot td) { + @apply px-2 py-1.5; + } + :where(tbody tr:last-child td, tfoot tr:last-child td) { + @apply pb-0; + } + + /* 見出し */ + :where(h1, h2, h3, h4, h5, h6) { + @apply text-cax-text text-pretty; + } + :where(h1, h2, h3, h4) { + @apply font-bold; + } + :where(h1, h2) { + @apply mt-10 mb-8 text-2xl; + } + :where(h3) { + @apply mt-8 mb-6 text-xl; + } + :where(h4) { + @apply mt-6 mb-4 text-lg; + } + :where(h5) { + @apply border-cax-border mt-6 mb-4 border-b py-0.5 pl-2 font-bold; + } + :where(h6) { + @apply border-cax-border mt-6 mb-4 border-b py-0.5 pl-2; + } + :where(h1 + *, h2 + *, h3 + *, h4 + *, h5 + *, h6 + *) { + @apply mt-0; + } + + /* 注釈 */ + :where(.footnotes) { + @apply border-cax-border mt-8 border-t; + } + :where(.footnotes h2) { + @apply sr-only; + } + :where(.footnotes ol) { + @apply mt-8 mb-0 text-sm; + } + :where(.footnotes ol li p) { + @apply my-0; + } + + /* 最初の要素はマージンを0にする */ + & > *:first-child { + @apply mt-0; + } + + /* 最後の要素はマージンを0にする */ + & > *:last-child { + @apply mb-0; + } +} diff --git a/application/client/src/index.html b/application/client/src/index.html index 3d949e7473..3297b69e3c 100644 --- a/application/client/src/index.html +++ b/application/client/src/index.html @@ -4,172 +4,29 @@ 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..1ff173c188 100644 --- a/application/client/src/search/services.ts +++ b/application/client/src/search/services.ts @@ -10,8 +10,8 @@ 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 sincePattern = /since:(\d{4}-\d{2}-\d{2})$/; + const untilPattern = /until:(\d{4}-\d{2}-\d{2})$/; const sincePart = query.match(/since:[^\s]*/)?.[0] || ""; const untilPart = query.match(/until:[^\s]*/)?.[0] || ""; @@ -20,8 +20,8 @@ export const parseSearchQuery = (query: string) => { const untilMatch = untilPattern.exec(untilPart); 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) => { @@ -38,7 +38,7 @@ export const parseSearchQuery = (query: string) => { }; export const isValidDate = (dateStr: string): boolean => { - const slowDateLike = /^(\d+)+-(\d+)+-(\d+)+$/; + const slowDateLike = /^\d{4}-\d{2}-\d{2}$/; if (!slowDateLike.test(dateStr)) return false; const date = new Date(dateStr); diff --git a/application/client/src/search/validation.ts b/application/client/src/search/validation.ts index 1da46e6d8d..4ef22d685f 100644 --- a/application/client/src/search/validation.ts +++ b/application/client/src/search/validation.ts @@ -1,39 +1,37 @@ -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): Record => { + const errors: Record = {}; const raw = values.searchText?.trim() || ""; if (!raw) { - errors.searchText = "検索キーワードを入力してください"; + errors["searchText"] = "検索キーワードを入力してください"; return errors; } const { keywords, sinceDate, untilDate } = parseSearchQuery(raw); if (!keywords && !sinceDate && !untilDate) { - errors.searchText = "検索キーワードまたは日付範囲を指定してください"; + errors["searchText"] = "検索キーワードまたは日付範囲を指定してください"; return errors; } if (sinceDate && !isValidDate(sinceDate)) { - errors.searchText = `since: の日付形式が不正です: ${sinceDate}`; + errors["searchText"] = `since: の日付形式が不正です: ${sinceDate}`; return errors; } if (untilDate && !isValidDate(untilDate)) { - errors.searchText = `until: の日付形式が不正です: ${untilDate}`; + errors["searchText"] = `until: の日付形式が不正です: ${untilDate}`; return errors; } if (sinceDate && untilDate && new Date(sinceDate) > new Date(untilDate)) { - errors.searchText = "since: は until: より前の日付を指定してください"; + errors["searchText"] = "since: は until: より前の日付を指定してください"; return errors; } 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/utils/bm25_search.ts b/application/client/src/utils/bm25_search.ts deleted file mode 100644 index c590d12c09..0000000000 --- a/application/client/src/utils/bm25_search.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { BM25 } from "bayesian-bm25"; -import type { Tokenizer, IpadicFeatures } from "kuromoji"; -import _ from "lodash"; - -const STOP_POS = new Set(["助詞", "助動詞", "記号"]); - -/** - * 形態素解析で内容語トークン(名詞、動詞、形容詞など)を抽出 - */ -export function extractTokens(tokens: IpadicFeatures[]): string[] { - return tokens - .filter((t) => t.surface_form !== "" && t.pos !== "" && !STOP_POS.has(t.pos)) - .map((t) => t.surface_form.toLowerCase()); -} - -/** - * BM25で候補をスコアリングして、クエリと類似度の高い上位10件を返す - */ -export function filterSuggestionsBM25( - tokenizer: Tokenizer, - candidates: string[], - queryTokens: string[], -): string[] { - if (queryTokens.length === 0) return []; - - const bm25 = new BM25({ k1: 1.2, b: 0.75 }); - - 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) - .filter((s) => s.score > 0) - .sortBy(["score"]) - .slice(-10) - .map((s) => s.text) - .value(); -} diff --git a/application/client/src/utils/convert_image.ts b/application/client/src/utils/convert_image.ts deleted file mode 100644 index 9fce086d9c..0000000000 --- a/application/client/src/utils/convert_image.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { initializeImageMagick, ImageMagick, MagickFormat } from "@imagemagick/magick-wasm"; -import magickWasm from "@imagemagick/magick-wasm/magick.wasm?binary"; -import { dump, insert, ImageIFD } from "piexifjs"; - -interface Options { - extension: MagickFormat; -} - -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; - - const comment = img.comment; - - img.write((output) => { - if (comment == null) { - resolve(new Blob([output as Uint8Array])); - return; - } - - // ImageMagick では EXIF の ImageDescription フィールドに保存されているデータが - // 非標準の Comment フィールドに移されてしまうため - // piexifjs を使って ImageDescription フィールドに書き込む - const binary = Array.from(output as Uint8Array) - .map((b) => String.fromCharCode(b)) - .join(""); - const descriptionBinary = Array.from(new TextEncoder().encode(comment)) - .map((b) => String.fromCharCode(b)) - .join(""); - const exifStr = dump({ "0th": { [ImageIFD.ImageDescription]: descriptionBinary } }); - const outputWithExif = insert(exifStr, binary); - const bytes = Uint8Array.from(outputWithExif.split("").map((c) => c.charCodeAt(0))); - resolve(new Blob([bytes])); - }); - }); - }); -} diff --git a/application/client/src/utils/convert_movie.ts b/application/client/src/utils/convert_movie.ts deleted file mode 100644 index fa08b4a003..0000000000 --- a/application/client/src/utils/convert_movie.ts +++ /dev/null @@ -1,43 +0,0 @@ -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; -} diff --git a/application/client/src/utils/convert_sound.ts b/application/client/src/utils/convert_sound.ts deleted file mode 100644 index 79cc37ab2d..0000000000 --- a/application/client/src/utils/convert_sound.ts +++ /dev/null @@ -1,35 +0,0 @@ -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; -} diff --git a/application/client/src/utils/create_translator.ts b/application/client/src/utils/create_translator.ts deleted file mode 100644 index ad1dabad22..0000000000 --- a/application/client/src/utils/create_translator.ts +++ /dev/null @@ -1,61 +0,0 @@ -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; -} - -interface Params { - sourceLanguage: string; - targetLanguage: string; -} - -export async function createTranslator(params: Params): Promise { - 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 engine = await CreateMLCEngine("gemma-2-2b-jpn-it-q4f16_1-MLC"); - - return { - async translate(text: string): Promise { - const reply = await engine.chat.completions.create({ - messages: [ - { - role: "system", - content: stripIndents` - You are a professional translator. Translate the following text from ${sourceLang.name} to ${targetLang.name}. - Provide as JSON only in the format: { "result": "{{translated text}}" } without any additional explanations. - `, - }, - { - role: "user", - content: text, - }, - ], - response_format: { type: "json_object" }, - temperature: 0, - }); - - const content = reply.choices[0]!.message.content; - invariant(content, "No content in the reply from the translation engine."); - - const parsed = JSONRepairJS.loads(content); - invariant( - parsed != null && "result" in parsed, - "The translation result is missing in the reply.", - ); - - return String(parsed.result); - }, - [Symbol.dispose]: () => { - engine.unload(); - }, - }; -} diff --git a/application/client/src/utils/date.ts b/application/client/src/utils/date.ts new file mode 100644 index 0000000000..e5bdde902e --- /dev/null +++ b/application/client/src/utils/date.ts @@ -0,0 +1,43 @@ +const longDateFormatter = new Intl.DateTimeFormat("ja-JP", { + year: "numeric", + month: "long", + day: "numeric", +}); + +const timeFormatter = new Intl.DateTimeFormat("ja-JP", { + hour: "2-digit", + minute: "2-digit", + hour12: false, +}); + +const rtf = new Intl.RelativeTimeFormat("ja", { numeric: "auto" }); + +export function formatLongDate(date: string): string { + return longDateFormatter.format(new Date(date)); +} + +export function formatTime(date: string): string { + return timeFormatter.format(new Date(date)); +} + +export function toISOString(date: string): string { + return new Date(date).toISOString(); +} + +export function fromNow(date: string): string { + const now = Date.now(); + const then = new Date(date).getTime(); + const diffSeconds = Math.round((then - now) / 1000); + + if (Math.abs(diffSeconds) < 60) return rtf.format(diffSeconds, "second"); + const diffMinutes = Math.round(diffSeconds / 60); + if (Math.abs(diffMinutes) < 60) return rtf.format(diffMinutes, "minute"); + const diffHours = Math.round(diffMinutes / 60); + if (Math.abs(diffHours) < 24) return rtf.format(diffHours, "hour"); + const diffDays = Math.round(diffHours / 24); + if (Math.abs(diffDays) < 30) return rtf.format(diffDays, "day"); + const diffMonths = Math.round(diffDays / 30); + if (Math.abs(diffMonths) < 12) return rtf.format(diffMonths, "month"); + const diffYears = Math.round(diffDays / 365); + return rtf.format(diffYears, "year"); +} 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..82f08e3df9 100644 --- a/application/client/src/utils/fetchers.ts +++ b/application/client/src/utils/fetchers.ts @@ -1,58 +1,69 @@ -import $ from "jquery"; -import { gzip } from "pako"; +async function compressGzip(data: Uint8Array): Promise { + const cs = new CompressionStream("gzip"); + const writer = cs.writable.getWriter(); + writer.write(data as unknown as BufferSource); + writer.close(); + const reader = cs.readable.getReader(); + const chunks: Uint8Array[] = []; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.length; + } + return result; +} 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) { + throw new Error(`HTTP ${response.status}`); + } + return response.json() as Promise; } export async function sendFile(url: string, file: File): Promise { - const result = await $.ajax({ - async: false, - data: file, - dataType: "json", + const response = await fetch(url, { + method: "POST", headers: { "Content-Type": "application/octet-stream", }, - method: "POST", - processData: false, - url, + body: file, }); - return result; + if (!response.ok) { + const responseJSON = await response.json().catch(() => null); + throw Object.assign(new Error(`HTTP ${response.status}`), { responseJSON }); + } + 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 compressed = await compressGzip(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: compressed as unknown as BodyInit, }); - return result; + if (!response.ok) { + const responseJSON = await response.json().catch(() => null); + throw Object.assign(new Error(`HTTP ${response.status}`), { responseJSON }); + } + 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..ff5e49739f 100644 --- a/application/client/src/utils/get_path.ts +++ b/application/client/src/utils/get_path.ts @@ -1,15 +1,17 @@ -export function getImagePath(imageId: string): string { - return `/images/${imageId}.jpg`; +export function getImagePath(imageId: string, width?: number): string { + const base = `/images/${imageId}.webp`; + return width ? `${base}?w=${width}` : base; } export function getMoviePath(movieId: string): string { - return `/movies/${movieId}.gif`; + return `/movies/${movieId}.mp4`; } export function getSoundPath(soundId: string): string { return `/sounds/${soundId}.mp3`; } -export function getProfileImagePath(profileImageId: string): string { - return `/images/profiles/${profileImageId}.jpg`; +export function getProfileImagePath(profileImageId: string, width?: number): string { + const base = `/images/profiles/${profileImageId}.webp`; + return width ? `${base}?w=${width}` : base; } 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 deleted file mode 100644 index f81ed5f4ea..0000000000 --- a/application/client/src/utils/negaposi_analyzer.ts +++ /dev/null @@ -1,31 +0,0 @@ -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"; - } - - return { score, label }; -} diff --git a/application/client/webpack.config.js b/application/client/webpack.config.js index 9fae72647f..5949f3d148 100644 --- a/application/client/webpack.config.js +++ b/application/client/webpack.config.js @@ -25,18 +25,14 @@ const config = { ], static: [PUBLIC_PATH, UPLOAD_PATH], }, - devtool: "inline-source-map", entry: { main: [ - "core-js", - "regenerator-runtime/runtime", - "jquery-binarytransport", path.resolve(SRC_PATH, "./index.css"), path.resolve(SRC_PATH, "./buildinfo.ts"), path.resolve(SRC_PATH, "./index.tsx"), ], }, - mode: "none", + mode: "production", module: { rules: [ { @@ -60,27 +56,20 @@ const config = { }, output: { chunkFilename: "scripts/chunk-[contenthash].js", - chunkFormat: false, - filename: "scripts/[name].js", + filename: "scripts/[name]-[contenthash].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", + filename: "styles/[name]-[contenthash].css", }), new CopyWebpackPlugin({ patterns: [ @@ -91,36 +80,13 @@ const config = { ], }), new HtmlWebpackPlugin({ - inject: false, + inject: "head", + scriptLoading: "defer", template: path.resolve(SRC_PATH, "./index.html"), }), ], 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, path: false, @@ -128,20 +94,31 @@ const config = { }, }, optimization: { - minimize: false, - splitChunks: false, - concatenateModules: false, - usedExports: false, - providedExports: false, - sideEffects: false, - }, - cache: false, - ignoreWarnings: [ - { - module: /@ffmpeg/, - message: /Critical dependency: the request of a dependency is an expression/, + minimize: true, + splitChunks: { + chunks: "all", + maxSize: 200000, + cacheGroups: { + katex: { + test: /katex/, + name: "katex", + chunks: "async", + priority: 25, + }, + vendor: { + test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/, + name: "vendor-react", + chunks: "all", + priority: 20, + }, + }, }, - ], + concatenateModules: true, + usedExports: true, + providedExports: true, + sideEffects: true, + }, + ignoreWarnings: [], }; module.exports = config; diff --git "a/application/e2e/src/crok-chat.test.ts-snapshots/crok-AI\345\277\234\347\255\224\345\256\214\344\272\206\345\276\214-Desktop-Chrome-darwin.png" "b/application/e2e/src/crok-chat.test.ts-snapshots/crok-AI\345\277\234\347\255\224\345\256\214\344\272\206\345\276\214-Desktop-Chrome-darwin.png" new file mode 100644 index 0000000000..ac67547d9f Binary files /dev/null and "b/application/e2e/src/crok-chat.test.ts-snapshots/crok-AI\345\277\234\347\255\224\345\256\214\344\272\206\345\276\214-Desktop-Chrome-darwin.png" differ diff --git a/application/e2e/src/crok-chat.test.ts-snapshots/crok-Crok-Desktop-Chrome-darwin.png b/application/e2e/src/crok-chat.test.ts-snapshots/crok-Crok-Desktop-Chrome-darwin.png new file mode 100644 index 0000000000..813f6d62a9 Binary files /dev/null and b/application/e2e/src/crok-chat.test.ts-snapshots/crok-Crok-Desktop-Chrome-darwin.png differ diff --git "a/application/e2e/src/crok-chat.test.ts-snapshots/crok-\343\202\265\343\202\270\343\202\247\343\202\271\343\203\210\350\241\250\347\244\272\345\276\214-Desktop-Chrome-darwin.png" "b/application/e2e/src/crok-chat.test.ts-snapshots/crok-\343\202\265\343\202\270\343\202\247\343\202\271\343\203\210\350\241\250\347\244\272\345\276\214-Desktop-Chrome-darwin.png" new file mode 100644 index 0000000000..d0953c073e Binary files /dev/null and "b/application/e2e/src/crok-chat.test.ts-snapshots/crok-\343\202\265\343\202\270\343\202\247\343\202\271\343\203\210\350\241\250\347\244\272\345\276\214-Desktop-Chrome-darwin.png" differ diff --git "a/application/e2e/src/dm.test.ts-snapshots/dm-DM\344\270\200\350\246\247-Desktop-Chrome-darwin.png" "b/application/e2e/src/dm.test.ts-snapshots/dm-DM\344\270\200\350\246\247-Desktop-Chrome-darwin.png" new file mode 100644 index 0000000000..97da6f26b9 Binary files /dev/null and "b/application/e2e/src/dm.test.ts-snapshots/dm-DM\344\270\200\350\246\247-Desktop-Chrome-darwin.png" differ diff --git "a/application/e2e/src/dm.test.ts-snapshots/dm-DM\350\251\263\347\264\260-Desktop-Chrome-darwin.png" "b/application/e2e/src/dm.test.ts-snapshots/dm-DM\350\251\263\347\264\260-Desktop-Chrome-darwin.png" new file mode 100644 index 0000000000..e44b89d169 Binary files /dev/null and "b/application/e2e/src/dm.test.ts-snapshots/dm-DM\350\251\263\347\264\260-Desktop-Chrome-darwin.png" differ diff --git "a/application/e2e/src/dm.test.ts-snapshots/dm-\346\226\260\350\246\217DM\351\226\213\345\247\213\343\203\242\343\203\274\343\203\200\343\203\253\357\274\210\343\203\220\343\203\252\343\203\207\343\203\274\343\202\267\343\203\247\343\203\263\343\202\250\343\203\251\343\203\274\357\274\211-Desktop-Chrome-darwin.png" "b/application/e2e/src/dm.test.ts-snapshots/dm-\346\226\260\350\246\217DM\351\226\213\345\247\213\343\203\242\343\203\274\343\203\200\343\203\253\357\274\210\343\203\220\343\203\252\343\203\207\343\203\274\343\202\267\343\203\247\343\203\263\343\202\250\343\203\251\343\203\274\357\274\211-Desktop-Chrome-darwin.png" new file mode 100644 index 0000000000..3df6e0c933 Binary files /dev/null and "b/application/e2e/src/dm.test.ts-snapshots/dm-\346\226\260\350\246\217DM\351\226\213\345\247\213\343\203\242\343\203\274\343\203\200\343\203\253\357\274\210\343\203\220\343\203\252\343\203\207\343\203\274\343\202\267\343\203\247\343\203\263\343\202\250\343\203\251\343\203\274\357\274\211-Desktop-Chrome-darwin.png" differ diff --git "a/application/e2e/src/dm.test.ts-snapshots/dm-\346\226\260\350\246\217DM\351\226\213\345\247\213\343\203\242\343\203\274\343\203\200\343\203\253\357\274\210\345\255\230\345\234\250\343\201\227\343\201\252\343\201\204\343\203\246\343\203\274\343\202\266\343\203\274\345\220\215\357\274\211-Desktop-Chrome-darwin.png" "b/application/e2e/src/dm.test.ts-snapshots/dm-\346\226\260\350\246\217DM\351\226\213\345\247\213\343\203\242\343\203\274\343\203\200\343\203\253\357\274\210\345\255\230\345\234\250\343\201\227\343\201\252\343\201\204\343\203\246\343\203\274\343\202\266\343\203\274\345\220\215\357\274\211-Desktop-Chrome-darwin.png" new file mode 100644 index 0000000000..d33784ea8f Binary files /dev/null and "b/application/e2e/src/dm.test.ts-snapshots/dm-\346\226\260\350\246\217DM\351\226\213\345\247\213\343\203\242\343\203\274\343\203\200\343\203\253\357\274\210\345\255\230\345\234\250\343\201\227\343\201\252\343\201\204\343\203\246\343\203\274\343\202\266\343\203\274\345\220\215\357\274\211-Desktop-Chrome-darwin.png" differ diff --git a/application/e2e/src/home.test.ts-snapshots/home-404-Desktop-Chrome-darwin.png b/application/e2e/src/home.test.ts-snapshots/home-404-Desktop-Chrome-darwin.png new file mode 100644 index 0000000000..a0bf8c5f13 Binary files /dev/null and b/application/e2e/src/home.test.ts-snapshots/home-404-Desktop-Chrome-darwin.png differ diff --git "a/application/e2e/src/home.test.ts-snapshots/home-\343\202\277\343\202\244\343\203\240\343\203\251\343\202\244\343\203\263\357\274\210\343\202\265\343\202\244\343\203\263\343\202\244\343\203\263\345\211\215\357\274\211-Desktop-Chrome-darwin.png" "b/application/e2e/src/home.test.ts-snapshots/home-\343\202\277\343\202\244\343\203\240\343\203\251\343\202\244\343\203\263\357\274\210\343\202\265\343\202\244\343\203\263\343\202\244\343\203\263\345\211\215\357\274\211-Desktop-Chrome-darwin.png" new file mode 100644 index 0000000000..19b43260a4 Binary files /dev/null and "b/application/e2e/src/home.test.ts-snapshots/home-\343\202\277\343\202\244\343\203\240\343\203\251\343\202\244\343\203\263\357\274\210\343\202\265\343\202\244\343\203\263\343\202\244\343\203\263\345\211\215\357\274\211-Desktop-Chrome-darwin.png" differ diff --git "a/application/e2e/src/post-detail.test.ts-snapshots/post-detail-\345\206\231\347\234\237-Desktop-Chrome-darwin.png" "b/application/e2e/src/post-detail.test.ts-snapshots/post-detail-\345\206\231\347\234\237-Desktop-Chrome-darwin.png" new file mode 100644 index 0000000000..45b55b81f9 Binary files /dev/null and "b/application/e2e/src/post-detail.test.ts-snapshots/post-detail-\345\206\231\347\234\237-Desktop-Chrome-darwin.png" differ diff --git "a/application/e2e/src/post-detail.test.ts-snapshots/post-detail-\345\213\225\347\224\273\345\206\215\347\224\237\344\270\255-Desktop-Chrome-darwin.png" "b/application/e2e/src/post-detail.test.ts-snapshots/post-detail-\345\213\225\347\224\273\345\206\215\347\224\237\344\270\255-Desktop-Chrome-darwin.png" new file mode 100644 index 0000000000..dc18178530 Binary files /dev/null and "b/application/e2e/src/post-detail.test.ts-snapshots/post-detail-\345\213\225\347\224\273\345\206\215\347\224\237\344\270\255-Desktop-Chrome-darwin.png" differ diff --git "a/application/e2e/src/post-detail.test.ts-snapshots/post-detail-\346\212\225\347\250\277\350\251\263\347\264\260-Desktop-Chrome-darwin.png" "b/application/e2e/src/post-detail.test.ts-snapshots/post-detail-\346\212\225\347\250\277\350\251\263\347\264\260-Desktop-Chrome-darwin.png" new file mode 100644 index 0000000000..dc18178530 Binary files /dev/null and "b/application/e2e/src/post-detail.test.ts-snapshots/post-detail-\346\212\225\347\250\277\350\251\263\347\264\260-Desktop-Chrome-darwin.png" differ diff --git "a/application/e2e/src/post-detail.test.ts-snapshots/post-detail-\351\237\263\345\243\260\345\206\215\347\224\237\345\211\215-Desktop-Chrome-darwin.png" "b/application/e2e/src/post-detail.test.ts-snapshots/post-detail-\351\237\263\345\243\260\345\206\215\347\224\237\345\211\215-Desktop-Chrome-darwin.png" new file mode 100644 index 0000000000..93fba8659c Binary files /dev/null and "b/application/e2e/src/post-detail.test.ts-snapshots/post-detail-\351\237\263\345\243\260\345\206\215\347\224\237\345\211\215-Desktop-Chrome-darwin.png" differ diff --git "a/application/e2e/src/posting.test.ts-snapshots/posting-\343\203\206\343\202\255\343\202\271\343\203\210\345\205\245\345\212\233\345\276\214-Desktop-Chrome-darwin.png" "b/application/e2e/src/posting.test.ts-snapshots/posting-\343\203\206\343\202\255\343\202\271\343\203\210\345\205\245\345\212\233\345\276\214-Desktop-Chrome-darwin.png" new file mode 100644 index 0000000000..9c3a572d2f Binary files /dev/null and "b/application/e2e/src/posting.test.ts-snapshots/posting-\343\203\206\343\202\255\343\202\271\343\203\210\345\205\245\345\212\233\345\276\214-Desktop-Chrome-darwin.png" differ diff --git "a/application/e2e/src/responsive.test.ts-snapshots/responsive-\343\202\271\343\203\236\343\203\233-Desktop-Chrome-darwin.png" "b/application/e2e/src/responsive.test.ts-snapshots/responsive-\343\202\271\343\203\236\343\203\233-Desktop-Chrome-darwin.png" new file mode 100644 index 0000000000..7fd7e2569d Binary files /dev/null and "b/application/e2e/src/responsive.test.ts-snapshots/responsive-\343\202\271\343\203\236\343\203\233-Desktop-Chrome-darwin.png" differ diff --git "a/application/e2e/src/responsive.test.ts-snapshots/responsive-\343\203\207\343\202\271\343\202\257\343\203\210\343\203\203\343\203\227-Desktop-Chrome-darwin.png" "b/application/e2e/src/responsive.test.ts-snapshots/responsive-\343\203\207\343\202\271\343\202\257\343\203\210\343\203\203\343\203\227-Desktop-Chrome-darwin.png" new file mode 100644 index 0000000000..33b065397b Binary files /dev/null and "b/application/e2e/src/responsive.test.ts-snapshots/responsive-\343\203\207\343\202\271\343\202\257\343\203\210\343\203\203\343\203\227-Desktop-Chrome-darwin.png" differ diff --git "a/application/e2e/src/search.test.ts-snapshots/search-\346\244\234\347\264\242\343\203\232\343\203\274\343\202\270-Desktop-Chrome-darwin.png" "b/application/e2e/src/search.test.ts-snapshots/search-\346\244\234\347\264\242\343\203\232\343\203\274\343\202\270-Desktop-Chrome-darwin.png" new file mode 100644 index 0000000000..568a9a25b1 Binary files /dev/null and "b/application/e2e/src/search.test.ts-snapshots/search-\346\244\234\347\264\242\343\203\232\343\203\274\343\202\270-Desktop-Chrome-darwin.png" differ diff --git "a/application/e2e/src/search.test.ts-snapshots/search-\346\244\234\347\264\242\347\265\220\346\236\234-Desktop-Chrome-darwin.png" "b/application/e2e/src/search.test.ts-snapshots/search-\346\244\234\347\264\242\347\265\220\346\236\234-Desktop-Chrome-darwin.png" new file mode 100644 index 0000000000..8eb3267cfa Binary files /dev/null and "b/application/e2e/src/search.test.ts-snapshots/search-\346\244\234\347\264\242\347\265\220\346\236\234-Desktop-Chrome-darwin.png" differ diff --git "a/application/e2e/src/terms.test.ts-snapshots/terms-\345\210\251\347\224\250\350\246\217\347\264\204-Desktop-Chrome-darwin.png" "b/application/e2e/src/terms.test.ts-snapshots/terms-\345\210\251\347\224\250\350\246\217\347\264\204-Desktop-Chrome-darwin.png" new file mode 100644 index 0000000000..9dcee28303 Binary files /dev/null and "b/application/e2e/src/terms.test.ts-snapshots/terms-\345\210\251\347\224\250\350\246\217\347\264\204-Desktop-Chrome-darwin.png" differ diff --git "a/application/e2e/src/user-profile.test.ts-snapshots/user-profile-\343\203\246\343\203\274\343\202\266\343\203\274\350\251\263\347\264\260-Desktop-Chrome-darwin.png" "b/application/e2e/src/user-profile.test.ts-snapshots/user-profile-\343\203\246\343\203\274\343\202\266\343\203\274\350\251\263\347\264\260-Desktop-Chrome-darwin.png" new file mode 100644 index 0000000000..b09f92a251 Binary files /dev/null and "b/application/e2e/src/user-profile.test.ts-snapshots/user-profile-\343\203\246\343\203\274\343\202\266\343\203\274\350\251\263\347\264\260-Desktop-Chrome-darwin.png" differ diff --git a/application/package.json b/application/package.json index 9a4daa55a6..5e7e08f926 100644 --- a/application/package.json +++ b/application/package.json @@ -7,17 +7,40 @@ "scripts": { "build": "pnpm --filter @web-speed-hackathon-2026/client build", "start": "pnpm --filter @web-speed-hackathon-2026/server start", + "restart": "lsof -ti:${PORT:-3000} | xargs kill -9 2>/dev/null; pnpm run build && pnpm run start", "typecheck": "pnpm run --recursive typecheck", "format": "pnpm run format:oxlint && pnpm run format:oxfmt", "format:oxlint": "oxlint --fix", - "format:oxfmt": "oxfmt" + "format:oxfmt": "oxfmt", + "prepare": "git config core.hooksPath .husky || true" }, "devDependencies": { + "lint-staged": "16.4.0", "oxfmt": "0.36.0", "oxlint": "1.43.0" }, + "lint-staged": { + "*.{ts,tsx,js,jsx}": [ + "oxlint --fix", + "oxfmt" + ], + "*.{json,css}": [ + "oxfmt" + ] + }, "engines": { "node": "24.14.0" }, - "packageManager": "pnpm@10.32.1" + "packageManager": "pnpm@10.32.1", + "pnpm": { + "onlyBuiltDependencies": [ + "@ffmpeg-installer/darwin-arm64", + "bcrypt", + "esbuild", + "iltorb", + "negaposi-analyzer-ja", + "sharp", + "sqlite3" + ] + } } diff --git a/application/pnpm-lock.yaml b/application/pnpm-lock.yaml index 510570f5c9..5aeb529018 100644 --- a/application/pnpm-lock.yaml +++ b/application/pnpm-lock.yaml @@ -12,6 +12,9 @@ importers: .: devDependencies: + lint-staged: + specifier: 16.4.0 + version: 16.4.0 oxfmt: specifier: 0.36.0 version: 0.36.0 @@ -21,117 +24,36 @@ 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 '@web-speed-hackathon-2026/client': specifier: workspace:* version: 'link:' - 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 - encoding-japanese: - specifier: 2.2.0 - version: 2.2.0 fast-average-color: specifier: 9.5.0 version: 9.5.0 - gifler: - specifier: github:themadcreator/gifler#v0.3.0 - version: https://codeload.github.com/themadcreator/gifler/tar.gz/c3259b071c7782f85d4928a5f03d0b378ed003b5 - image-size: - specifier: 2.0.2 - version: 2.0.2 - jquery: - specifier: 3.7.1 - version: 3.7.1 - jquery-binarytransport: - specifier: 1.0.0 - version: 1.0.0 - json-repair-js: - specifier: 1.0.0 - version: 1.0.0 katex: specifier: 0.16.25 version: 0.16.25 - kuromoji: - specifier: 0.1.2 - version: 0.1.2 - 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 react: specifier: 19.2.0 version: 19.2.0 react-dom: specifier: 19.2.0 version: 19.2.0(react@19.2.0) - 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-hook-form: + specifier: 7.71.2 + version: 7.71.2(react@19.2.0) 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 +63,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,42 +79,15 @@ 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 '@types/react': specifier: 19.2.2 version: 19.2.2 @@ -205,9 +97,6 @@ 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) @@ -238,6 +127,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 @@ -265,21 +157,36 @@ importers: server: dependencies: + '@ffmpeg-installer/ffmpeg': + specifier: 1.1.0 + version: 1.1.0 '@tsconfig/strictest': specifier: 2.0.8 version: 2.0.8 + '@types/compression': + specifier: 1.8.1 + version: 1.8.1 '@web-speed-hackathon-2026/server': specifier: workspace:* version: 'link:' + bayesian-bm25: + specifier: 0.4.0 + version: 0.4.0 bcrypt: specifier: 6.0.0 version: 6.0.0 body-parser: specifier: 2.2.0 version: 2.2.0 + compression: + specifier: 1.8.1 + version: 1.8.1 connect-history-api-fallback: specifier: 2.0.0 version: 2.0.0 + exifr: + specifier: 7.1.3 + version: 7.1.3 express: specifier: 5.1.0 version: 5.1.0 @@ -292,15 +199,24 @@ 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) serve-static: specifier: 2.2.0 version: 2.2.0 + sharp: + specifier: 0.33.5 + version: 0.33.5 sqlite3: specifier: 5.1.7 version: 5.1.7 @@ -332,9 +248,15 @@ importers: '@types/express-session': specifier: 1.18.2 version: 1.18.2 + '@types/fluent-ffmpeg': + specifier: 2.1.27 + version: 2.1.27 '@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 +272,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'} @@ -1185,10 +1111,8 @@ packages: resolution: {integrity: sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ==} engines: {node: '>=14.17.0'} - '@dr.pogodin/react-helmet@3.0.4': - resolution: {integrity: sha512-TesfNpzO12qcbyqKyWGDIYTdwVxD3pJv75rE/zhKUq/k9yxeP0BpOdHQ5cc1zA3j/GyY7CuIZjAUXmsxqI1/yw==} - peerDependencies: - react: '19' + '@emnapi/runtime@1.9.1': + resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} '@esbuild/aix-ppc64@0.25.11': resolution: {integrity: sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==} @@ -1350,23 +1274,168 @@ 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-installer/darwin-arm64@4.1.5': + resolution: {integrity: sha512-hYqTiP63mXz7wSQfuqfFwfLOfwwFChUedeCVKkBtl/cliaTM7/ePI9bVzfZ2c+dWu3TqCwLDRWNSJ5pqZl8otA==} + cpu: [arm64] + os: [darwin] - '@ffmpeg/ffmpeg@0.12.15': - resolution: {integrity: sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw==} - engines: {node: '>=18.x'} + '@ffmpeg-installer/darwin-x64@4.1.0': + resolution: {integrity: sha512-Z4EyG3cIFjdhlY8wI9aLUXuH8nVt7E9SlMVZtWvSPnm2sm37/yC2CwjUzyCQbJbySnef1tQwGG2Sx+uWhd9IAw==} + cpu: [x64] + os: [darwin] - '@ffmpeg/types@0.12.4': - resolution: {integrity: sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A==} - engines: {node: '>=16.x'} + '@ffmpeg-installer/ffmpeg@1.1.0': + resolution: {integrity: sha512-Uq4rmwkdGxIa9A6Bd/VqqYbT7zqh1GrT5/rFwCwKM70b42W5gIjWeVETq6SdcL0zXqDtY081Ws/iJWhr1+xvQg==} + + '@ffmpeg-installer/linux-arm64@4.1.4': + resolution: {integrity: sha512-dljEqAOD0oIM6O6DxBW9US/FkvqvQwgJ2lGHOwHDDwu/pX8+V0YsDL1xqHbj1DMX/+nP9rxw7G7gcUvGspSoKg==} + cpu: [arm64] + os: [linux] + + '@ffmpeg-installer/linux-arm@4.1.3': + resolution: {integrity: sha512-NDf5V6l8AfzZ8WzUGZ5mV8O/xMzRag2ETR6+TlGIsMHp81agx51cqpPItXPib/nAZYmo55Bl2L6/WOMI3A5YRg==} + cpu: [arm] + os: [linux] + + '@ffmpeg-installer/linux-ia32@4.1.0': + resolution: {integrity: sha512-0LWyFQnPf+Ij9GQGD034hS6A90URNu9HCtQ5cTqo5MxOEc7Rd8gLXrJvn++UmxhU0J5RyRE9KRYstdCVUjkNOQ==} + cpu: [ia32] + os: [linux] + + '@ffmpeg-installer/linux-x64@4.1.0': + resolution: {integrity: sha512-Y5BWhGLU/WpQjOArNIgXD3z5mxxdV8c41C+U15nsE5yF8tVcdCGet5zPs5Zy3Ta6bU7haGpIzryutqCGQA/W8A==} + cpu: [x64] + os: [linux] + + '@ffmpeg-installer/win32-ia32@4.1.0': + resolution: {integrity: sha512-FV2D7RlaZv/lrtdhaQ4oETwoFUsUjlUiasiZLDxhEUPdNDWcH1OU9K1xTvqz+OXLdsmYelUDuBS/zkMOTtlUAw==} + cpu: [ia32] + os: [win32] + + '@ffmpeg-installer/win32-x64@4.1.0': + resolution: {integrity: sha512-Drt5u2vzDnIONf4ZEkKtFlbvwj6rI3kxw1Ck9fpudmtgaZIHD4ucsWB2lCZBXRxJgXR+2IMSti+4rtM4C4rXgg==} + cpu: [x64] + os: [win32] '@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==} + '@img/sharp-darwin-arm64@0.33.5': + resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.33.5': + resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.0.4': + resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.0.4': + resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.0.4': + resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.0.5': + resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.0.4': + resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.0.4': + resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.0.4': + resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.33.5': + resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.33.5': + resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.33.5': + resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.33.5': + resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.33.5': + resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.33.5': + resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.33.5': + resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-ia32@0.33.5': + resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.33.5': + resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1426,9 +1495,6 @@ packages: '@leichtgewicht/ip-codec@2.0.5': resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} - '@mlc-ai/web-llm@0.2.80': - resolution: {integrity: sha512-Hwy1OCsK5cOU4nKr2wIJ2qA1g595PENtO5f2d9Wd/GgFsj5X04uxfaaJfqED8eFAJOpQpn/DirogdEY/yp5jQg==} - '@npmcli/fs@1.1.1': resolution: {integrity: sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==} @@ -1608,6 +1674,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,17 +1783,14 @@ 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==} '@types/bonjour@3.5.13': resolution: {integrity: sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==} - '@types/common-tags@1.8.4': - resolution: {integrity: sha512-S+1hLDJPjWNDhcGxsxEbepzaxWqURP/o+3cP4aa2w7yBXgdcmKGQtZzP8JbyfOd0m+33nh+8+kvxYE2UJtBDkg==} + '@types/compression@1.8.1': + resolution: {integrity: sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==} '@types/connect-history-api-fallback@1.5.4': resolution: {integrity: sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==} @@ -1649,9 +1804,6 @@ packages: '@types/doublearray@0.0.32': resolution: {integrity: sha512-HloTru3I3a55runIVqZX1YBQi2L5A4peNQPh33yshzB4ttt1qHCnHPkuhy9Djy/cTx7i5xJvxItKRPCmvnfpGw==} - '@types/encoding-japanese@2.2.1': - resolution: {integrity: sha512-6jjepuTusvySxMLP7W6usamlbgf0F4sIDvm7EzYePjLHY7zWUv4yz2PLUnu0vuNVtXOTLu2cRdFcDg40J5Owsw==} - '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -1679,6 +1831,9 @@ packages: '@types/express@5.0.3': resolution: {integrity: sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==} + '@types/fluent-ffmpeg@2.1.27': + resolution: {integrity: sha512-QiDWjihpUhriISNoBi2hJBRUUmoj/BMTYcfz+F+ZM9hHWBYABFAE6hjP/TbCZC0GWwlpa3FzvHH9RzFeRusZ7A==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -1691,9 +1846,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==} @@ -1703,12 +1855,6 @@ packages: '@types/kuromoji@0.1.3': resolution: {integrity: sha512-u+YwX6eJj6Fmm0F5qunsyA+X8HSiyRNNE5ON3itD3tERax4meq9tv+S7bjTMXkPjqbdBGUmH2maGDCuEvpODwg==} - '@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==} @@ -1724,15 +1870,6 @@ packages: '@types/node@22.18.8': resolution: {integrity: sha512-pAZSHMiagDR7cARo/cch1f3rXy0AEXwsVsVH09FcyeJVAzCnGgmYis7P3JidtTUjyadhTeSo8TgRPswstghDaw==} - '@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==} - '@types/prismjs@1.26.5': resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==} @@ -1753,9 +1890,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 +1905,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 +1914,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==} @@ -1921,6 +2049,10 @@ packages: ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + ansi-html-community@0.0.8: resolution: {integrity: sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==} engines: {'0': node >= 0.8.0} @@ -1930,6 +2062,14 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -1951,10 +2091,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} @@ -2017,9 +2153,6 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - bluebird@3.7.2: - resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} - body-parser@1.20.3: resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -2052,9 +2185,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'} @@ -2126,14 +2256,36 @@ packages: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-truncate@5.2.0: + resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} + engines: {node: '>=20'} + clone-deep@4.0.1: resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==} engines: {node: '>=6'} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + color-support@1.1.3: resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} hasBin: true + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} @@ -2144,6 +2296,10 @@ packages: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -2151,10 +2307,6 @@ packages: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} - common-tags@1.8.2: - resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} - engines: {node: '>=4.0.0'} - compressible@2.0.18: resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} engines: {node: '>= 0.6'} @@ -2223,9 +2375,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==} @@ -2401,6 +2550,9 @@ packages: electron-to-chromium@1.5.233: resolution: {integrity: sha512-iUdTQSf7EFXsDdQsp8MwJz5SVk4APEFqXU/S47OtQ0YLqacSwPXdZ5vRlMX3neb07Cy2vgioNuRnWUXFwuslkg==} + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -2412,10 +2564,6 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} - encoding-japanese@2.2.0: - resolution: {integrity: sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==} - engines: {node: '>=8.10.0'} - encoding@0.1.13: resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} @@ -2426,6 +2574,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==} @@ -2442,6 +2594,10 @@ packages: engines: {node: '>=4'} hasBin: true + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + err-code@2.0.3: resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} @@ -2463,9 +2619,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'} @@ -2512,10 +2665,16 @@ packages: eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + exifr@7.1.3: + resolution: {integrity: sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==} + expand-template@2.0.3: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} @@ -2656,6 +2815,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -2667,10 +2830,6 @@ 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} - version: 0.3.0 - github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} @@ -2760,9 +2919,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==} @@ -2855,11 +3011,6 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - image-size@2.0.2: - resolution: {integrity: sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==} - engines: {node: '>=16.x'} - hasBin: true - import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -2904,9 +3055,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'} @@ -2928,6 +3076,9 @@ packages: is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-arrayish@0.3.4: + resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} + is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} @@ -2952,6 +3103,10 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -2987,9 +3142,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 +3171,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==} @@ -3041,9 +3186,6 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - json-repair-js@1.0.0: - resolution: {integrity: sha512-OtJiP3CQSubLdbViPVufKMFw5b2soz6aNILz3QJZMyGWMxEFLppeN7MRONODr4pMusc+ByYhQRlDQZME3vMPJw==} - json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -3069,15 +3211,95 @@ packages: kuromojin@1.5.1: resolution: {integrity: sha512-tzt3UUqWqzwHMsahchyrcs9kgbn6OM7xP4QRCd0w5vqE0lA/cjCH0OxjLaekz5cnxGmcy8RfN7La3xOxZOvJ1w==} - langs@2.0.0: - resolution: {integrity: sha512-v4pxOBEQVN1WBTfB1crhTtxzNLZU9HPWgadlwzWKISJtt6Ku/CnpBrwVy+jFv8StjxsPfwPFzO0CMwdZLJ0/BA==} - 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==} + lint-staged@16.4.0: + resolution: {integrity: sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==} + engines: {node: '>=20.17'} + hasBin: true + + listr2@9.0.5: + resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} + engines: {node: '>=20.0.0'} + loader-runner@4.3.1: resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==} engines: {node: '>=6.11.5'} @@ -3096,17 +3318,13 @@ packages: lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - loglevel@1.9.2: - resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} - engines: {node: '>= 0.6.0'} + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} 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 +3338,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'} @@ -3316,6 +3537,10 @@ packages: engines: {node: '>=4'} hasBin: true + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + mimic-response@3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} @@ -3486,10 +3711,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'} @@ -3497,9 +3718,6 @@ packages: obuf@1.1.2: resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} - omggif@1.0.10: - resolution: {integrity: sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==} - on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -3511,6 +3729,10 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + open@10.2.0: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} engines: {node: '>=18'} @@ -3558,9 +3780,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==} @@ -3620,9 +3839,6 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} - piexifjs@1.0.6: - resolution: {integrity: sha512-0wVyH0cKohzBQ5Gi2V1BuxYpxWfxF3cSqfFXfPIpl5tl9XLS5z4ogqhUCD20AbHi0h9aJkqXNJnkVev6gwh2ag==} - pify@2.3.0: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} @@ -3851,6 +4067,7 @@ packages: prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true pretty-error@4.0.0: @@ -3875,9 +4092,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,8 +4138,11 @@ packages: peerDependencies: react: ^19.2.0 - react-is@16.13.1: - resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-hook-form@7.71.2: + resolution: {integrity: sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 react-markdown@10.1.0: resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} @@ -3933,18 +4150,6 @@ packages: '@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 +4188,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 +4198,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'} @@ -4077,6 +4261,10 @@ packages: engines: {node: '>= 0.4'} hasBin: true + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + retry-as-promised@7.1.1: resolution: {integrity: sha512-hMD7odLOt3LkTjcif8aRZqi/hybjpLNgSk5oF5FCowfCjok6LukpN2bDX7R5wDmbgBQFn7YoBxSagmtXHaJYJw==} @@ -4088,6 +4276,9 @@ packages: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} deprecated: Rimraf versions prior to v4 are no longer supported @@ -4209,6 +4400,10 @@ packages: resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} engines: {node: '>=8'} + sharp@0.33.5: + resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -4240,12 +4435,27 @@ packages: signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + simple-concat@1.0.1: resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + simple-swizzle@0.2.4: + resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + + slice-ansi@7.1.2: + resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} + engines: {node: '>=18'} + + slice-ansi@8.0.0: + resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} + engines: {node: '>=20'} + smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} @@ -4289,9 +4499,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'} @@ -4304,10 +4511,22 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.2.0: + resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} + engines: {node: '>=20'} + string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -4321,6 +4540,10 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} @@ -4343,6 +4566,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'} @@ -4396,6 +4622,10 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -4525,11 +4755,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==} @@ -4651,6 +4876,10 @@ packages: wkx@0.5.0: resolution: {integrity: sha512-Xng/d4Ichh8uN4l0FToV/258EjMGU9MGcA0HV2d9B/ZpZB3lqQm7nkOdZdm5GhKtLLhAE7PiVQwN4eN+2YJJUg==} + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -4676,6 +4905,11 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yaml@2.8.2: + resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + engines: {node: '>= 14.6'} + hasBin: true + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -4691,6 +4925,8 @@ packages: snapshots: + '@alloc/quick-lru@5.2.0': {} + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.27.1 @@ -5712,103 +5948,203 @@ snapshots: '@discoveryjs/json-ext@0.6.3': {} - '@dr.pogodin/react-helmet@3.0.4(react@19.2.0)': + '@emnapi/runtime@1.9.1': dependencies: - '@babel/runtime': 7.28.4 - react: 19.2.0 + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.25.11': + optional: true + + '@esbuild/android-arm64@0.25.11': + optional: true + + '@esbuild/android-arm@0.25.11': + optional: true + + '@esbuild/android-x64@0.25.11': + optional: true + + '@esbuild/darwin-arm64@0.25.11': + optional: true + + '@esbuild/darwin-x64@0.25.11': + optional: true + + '@esbuild/freebsd-arm64@0.25.11': + optional: true + + '@esbuild/freebsd-x64@0.25.11': + optional: true + + '@esbuild/linux-arm64@0.25.11': + optional: true + + '@esbuild/linux-arm@0.25.11': + optional: true + + '@esbuild/linux-ia32@0.25.11': + optional: true + + '@esbuild/linux-loong64@0.25.11': + optional: true + + '@esbuild/linux-mips64el@0.25.11': + optional: true + + '@esbuild/linux-ppc64@0.25.11': + optional: true + + '@esbuild/linux-riscv64@0.25.11': + optional: true + + '@esbuild/linux-s390x@0.25.11': + optional: true + + '@esbuild/linux-x64@0.25.11': + optional: true + + '@esbuild/netbsd-arm64@0.25.11': + optional: true + + '@esbuild/netbsd-x64@0.25.11': + optional: true + + '@esbuild/openbsd-arm64@0.25.11': + optional: true + + '@esbuild/openbsd-x64@0.25.11': + optional: true + + '@esbuild/openharmony-arm64@0.25.11': + optional: true + + '@esbuild/sunos-x64@0.25.11': + optional: true - '@esbuild/aix-ppc64@0.25.11': + '@esbuild/win32-arm64@0.25.11': optional: true - '@esbuild/android-arm64@0.25.11': + '@esbuild/win32-ia32@0.25.11': optional: true - '@esbuild/android-arm@0.25.11': + '@esbuild/win32-x64@0.25.11': optional: true - '@esbuild/android-x64@0.25.11': + '@faker-js/faker@10.2.0': {} + + '@ffmpeg-installer/darwin-arm64@4.1.5': optional: true - '@esbuild/darwin-arm64@0.25.11': + '@ffmpeg-installer/darwin-x64@4.1.0': optional: true - '@esbuild/darwin-x64@0.25.11': + '@ffmpeg-installer/ffmpeg@1.1.0': + optionalDependencies: + '@ffmpeg-installer/darwin-arm64': 4.1.5 + '@ffmpeg-installer/darwin-x64': 4.1.0 + '@ffmpeg-installer/linux-arm': 4.1.3 + '@ffmpeg-installer/linux-arm64': 4.1.4 + '@ffmpeg-installer/linux-ia32': 4.1.0 + '@ffmpeg-installer/linux-x64': 4.1.0 + '@ffmpeg-installer/win32-ia32': 4.1.0 + '@ffmpeg-installer/win32-x64': 4.1.0 + + '@ffmpeg-installer/linux-arm64@4.1.4': optional: true - '@esbuild/freebsd-arm64@0.25.11': + '@ffmpeg-installer/linux-arm@4.1.3': optional: true - '@esbuild/freebsd-x64@0.25.11': + '@ffmpeg-installer/linux-ia32@4.1.0': optional: true - '@esbuild/linux-arm64@0.25.11': + '@ffmpeg-installer/linux-x64@4.1.0': optional: true - '@esbuild/linux-arm@0.25.11': + '@ffmpeg-installer/win32-ia32@4.1.0': optional: true - '@esbuild/linux-ia32@0.25.11': + '@ffmpeg-installer/win32-x64@4.1.0': optional: true - '@esbuild/linux-loong64@0.25.11': + '@gar/promisify@1.1.3': optional: true - '@esbuild/linux-mips64el@0.25.11': + '@img/sharp-darwin-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.0.4 optional: true - '@esbuild/linux-ppc64@0.25.11': + '@img/sharp-darwin-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.0.4 optional: true - '@esbuild/linux-riscv64@0.25.11': + '@img/sharp-libvips-darwin-arm64@1.0.4': optional: true - '@esbuild/linux-s390x@0.25.11': + '@img/sharp-libvips-darwin-x64@1.0.4': optional: true - '@esbuild/linux-x64@0.25.11': + '@img/sharp-libvips-linux-arm64@1.0.4': optional: true - '@esbuild/netbsd-arm64@0.25.11': + '@img/sharp-libvips-linux-arm@1.0.5': optional: true - '@esbuild/netbsd-x64@0.25.11': + '@img/sharp-libvips-linux-s390x@1.0.4': optional: true - '@esbuild/openbsd-arm64@0.25.11': + '@img/sharp-libvips-linux-x64@1.0.4': optional: true - '@esbuild/openbsd-x64@0.25.11': + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': optional: true - '@esbuild/openharmony-arm64@0.25.11': + '@img/sharp-libvips-linuxmusl-x64@1.0.4': optional: true - '@esbuild/sunos-x64@0.25.11': + '@img/sharp-linux-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.0.4 optional: true - '@esbuild/win32-arm64@0.25.11': + '@img/sharp-linux-arm@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.0.5 optional: true - '@esbuild/win32-ia32@0.25.11': + '@img/sharp-linux-s390x@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.0.4 optional: true - '@esbuild/win32-x64@0.25.11': + '@img/sharp-linux-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.0.4 optional: true - '@faker-js/faker@10.2.0': {} + '@img/sharp-linuxmusl-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + optional: true - '@ffmpeg/core@0.12.10': {} + '@img/sharp-linuxmusl-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + optional: true - '@ffmpeg/ffmpeg@0.12.15': + '@img/sharp-wasm32@0.33.5': dependencies: - '@ffmpeg/types': 0.12.4 - - '@ffmpeg/types@0.12.4': {} + '@emnapi/runtime': 1.9.1 + optional: true - '@gar/promisify@1.1.3': + '@img/sharp-win32-ia32@0.33.5': optional: true - '@imagemagick/magick-wasm@0.0.37': {} + '@img/sharp-win32-x64@0.33.5': + optional: true '@jridgewell/gen-mapping@0.3.13': dependencies: @@ -5871,10 +6207,6 @@ snapshots: '@leichtgewicht/ip-codec@2.0.5': {} - '@mlc-ai/web-llm@0.2.80': - dependencies: - loglevel: 1.9.2 - '@npmcli/fs@1.1.1': dependencies: '@gar/promisify': 1.1.3 @@ -5972,6 +6304,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 +6391,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 @@ -6001,7 +6400,10 @@ snapshots: dependencies: '@types/node': 22.18.8 - '@types/common-tags@1.8.4': {} + '@types/compression@1.8.1': + dependencies: + '@types/express': 5.0.3 + '@types/node': 22.18.8 '@types/connect-history-api-fallback@1.5.4': dependencies: @@ -6018,8 +6420,6 @@ snapshots: '@types/doublearray@0.0.32': {} - '@types/encoding-japanese@2.2.1': {} - '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -6067,6 +6467,10 @@ snapshots: '@types/express-serve-static-core': 5.1.0 '@types/serve-static': 1.15.9 + '@types/fluent-ffmpeg@2.1.27': + dependencies: + '@types/node': 22.18.8 + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -6079,10 +6483,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': {} @@ -6091,10 +6491,6 @@ snapshots: dependencies: '@types/doublearray': 0.0.32 - '@types/langs@2.0.5': {} - - '@types/lodash@4.17.20': {} - '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -6111,12 +6507,6 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/omggif@1.0.5': {} - - '@types/pako@2.0.4': {} - - '@types/piexifjs@1.0.0': {} - '@types/prismjs@1.26.5': {} '@types/qs@6.14.0': {} @@ -6135,11 +6525,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 +6546,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 +6554,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': @@ -6331,10 +6712,18 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + ansi-html-community@0.0.8: {} ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + + ansi-styles@6.2.3: {} + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -6357,11 +6746,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 @@ -6432,8 +6816,6 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - bluebird@3.7.2: {} - body-parser@1.20.3: dependencies: bytes: 3.1.2 @@ -6497,11 +6879,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 @@ -6588,27 +6965,52 @@ snapshots: clean-stack@2.2.0: optional: true + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-truncate@5.2.0: + dependencies: + slice-ansi: 8.0.0 + string-width: 8.2.0 + clone-deep@4.0.1: dependencies: is-plain-object: 2.0.4 kind-of: 6.0.3 shallow-clone: 3.0.1 + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.4 + color-support@1.1.3: optional: true + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + colorette@2.0.20: {} comma-separated-tokens@2.0.3: {} commander@12.1.0: {} + commander@14.0.3: {} + commander@2.20.3: {} commander@8.3.0: {} - common-tags@1.8.2: {} - compressible@2.0.18: dependencies: mime-db: 1.54.0 @@ -6677,8 +7079,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): @@ -6834,6 +7234,8 @@ snapshots: electron-to-chromium@1.5.233: {} + emoji-regex@10.6.0: {} + emoji-regex@8.0.0: optional: true @@ -6841,8 +7243,6 @@ snapshots: encodeurl@2.0.0: {} - encoding-japanese@2.2.0: {} - encoding@0.1.13: dependencies: iconv-lite: 0.6.3 @@ -6856,6 +7256,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: {} @@ -6864,6 +7269,8 @@ snapshots: envinfo@7.17.0: {} + environment@1.1.0: {} + err-code@2.0.3: optional: true @@ -6881,8 +7288,6 @@ snapshots: dependencies: es-errors: 1.3.0 - es6-error@4.1.1: {} - esbuild@0.25.11: optionalDependencies: '@esbuild/aix-ppc64': 0.25.11 @@ -6939,8 +7344,12 @@ snapshots: eventemitter3@4.0.7: {} + eventemitter3@5.0.4: {} + events@3.3.0: {} + exifr@7.1.3: {} + expand-template@2.0.3: {} express-session@1.18.2: @@ -7139,6 +7548,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-east-asian-width@1.5.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -7161,11 +7572,6 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - gifler@https://codeload.github.com/themadcreator/gifler/tar.gz/c3259b071c7782f85d4928a5f03d0b378ed003b5: - dependencies: - bluebird: 3.7.2 - omggif: 1.0.10 - github-from-package@0.0.0: {} glob-parent@5.1.2: @@ -7295,10 +7701,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 @@ -7419,8 +7821,6 @@ snapshots: ieee754@1.2.1: {} - image-size@2.0.2: {} - import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -7458,10 +7858,6 @@ snapshots: interpret@3.1.1: {} - invariant@2.2.4: - dependencies: - loose-envify: 1.4.0 - ip-address@10.0.1: optional: true @@ -7478,6 +7874,8 @@ snapshots: is-arrayish@0.2.1: {} + is-arrayish@0.3.4: {} + is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 @@ -7495,6 +7893,10 @@ snapshots: is-fullwidth-code-point@3.0.0: optional: true + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.5.0 + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -7520,8 +7922,6 @@ snapshots: dependencies: isobject: 3.0.1 - is-promise@2.2.2: {} - is-promise@4.0.0: {} is-stream@1.1.0: {} @@ -7544,10 +7944,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: @@ -7558,8 +7954,6 @@ snapshots: json-parse-even-better-errors@2.3.1: {} - json-repair-js@1.0.0: {} - json-schema-traverse@1.0.0: {} json5@2.2.3: {} @@ -7586,15 +7980,80 @@ snapshots: dependencies: kuromoji: 0.1.1 - langs@2.0.0: {} - launch-editor@2.11.1: dependencies: 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: {} + lint-staged@16.4.0: + dependencies: + commander: 14.0.3 + listr2: 9.0.5 + picomatch: 4.0.3 + string-argv: 0.3.2 + tinyexec: 1.0.4 + yaml: 2.8.2 + + listr2@9.0.5: + dependencies: + cli-truncate: 5.2.0 + colorette: 2.0.20 + eventemitter3: 5.0.4 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.2 + loader-runner@4.3.1: {} locate-path@5.0.0: @@ -7609,14 +8068,16 @@ snapshots: lodash@4.17.21: {} - loglevel@1.9.2: {} + log-update@6.1.0: + dependencies: + ansi-escapes: 7.3.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.2 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 longest-streak@3.1.0: {} - loose-envify@1.4.0: - dependencies: - js-tokens: 4.0.0 - lower-case@2.0.2: dependencies: tslib: 2.8.1 @@ -7635,6 +8096,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 @@ -8068,6 +8533,8 @@ snapshots: mime@1.6.0: {} + mimic-function@5.0.1: {} + mimic-response@3.1.0: {} mini-css-extract-plugin@2.9.4(webpack@5.102.1): @@ -8252,14 +8719,10 @@ snapshots: object-assign@3.0.0: {} - object-assign@4.1.1: {} - object-inspect@1.13.4: {} obuf@1.1.2: {} - omggif@1.0.10: {} - on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -8270,6 +8733,10 @@ snapshots: dependencies: wrappy: 1.0.2 + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + open@10.2.0: dependencies: default-browser: 5.2.1 @@ -8341,8 +8808,6 @@ snapshots: p-try@2.2.0: {} - pako@2.1.0: {} - param-case@3.0.4: dependencies: dot-case: 3.0.4 @@ -8401,8 +8866,6 @@ snapshots: picomatch@4.0.3: {} - piexifjs@1.0.6: {} - pify@2.3.0: {} pkg-dir@4.2.0: @@ -8718,12 +9181,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,7 +9235,9 @@ snapshots: react: 19.2.0 scheduler: 0.27.0 - react-is@16.13.1: {} + react-hook-form@7.71.2(react@19.2.0): + dependencies: + react: 19.2.0 react-markdown@10.1.0(@types/react@19.2.2)(react@19.2.0): dependencies: @@ -8798,15 +9257,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 +9305,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 +9318,6 @@ snapshots: regenerate@1.4.2: {} - regenerator-runtime@0.14.1: {} - regexpu-core@6.4.0: dependencies: regenerate: 1.4.2 @@ -8988,6 +9416,11 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + retry-as-promised@7.1.1: {} retry@0.12.0: @@ -8995,6 +9428,8 @@ snapshots: retry@0.13.1: {} + rfdc@1.4.1: {} + rimraf@3.0.2: dependencies: glob: 7.2.3 @@ -9144,6 +9579,32 @@ snapshots: dependencies: kind-of: 6.0.3 + sharp@0.33.5: + dependencies: + color: 4.2.3 + detect-libc: 2.1.2 + semver: 7.7.3 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.33.5 + '@img/sharp-darwin-x64': 0.33.5 + '@img/sharp-libvips-darwin-arm64': 1.0.4 + '@img/sharp-libvips-darwin-x64': 1.0.4 + '@img/sharp-libvips-linux-arm': 1.0.5 + '@img/sharp-libvips-linux-arm64': 1.0.4 + '@img/sharp-libvips-linux-s390x': 1.0.4 + '@img/sharp-libvips-linux-x64': 1.0.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + '@img/sharp-linux-arm': 0.33.5 + '@img/sharp-linux-arm64': 0.33.5 + '@img/sharp-linux-s390x': 0.33.5 + '@img/sharp-linux-x64': 0.33.5 + '@img/sharp-linuxmusl-arm64': 0.33.5 + '@img/sharp-linuxmusl-x64': 0.33.5 + '@img/sharp-wasm32': 0.33.5 + '@img/sharp-win32-ia32': 0.33.5 + '@img/sharp-win32-x64': 0.33.5 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -9183,6 +9644,8 @@ snapshots: signal-exit@3.0.7: optional: true + signal-exit@4.1.0: {} + simple-concat@1.0.1: {} simple-get@4.0.1: @@ -9191,6 +9654,20 @@ snapshots: once: 1.4.0 simple-concat: 1.0.1 + simple-swizzle@0.2.4: + dependencies: + is-arrayish: 0.3.4 + + slice-ansi@7.1.2: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + slice-ansi@8.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + smart-buffer@4.2.0: optional: true @@ -9264,18 +9741,14 @@ 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: {} statuses@2.0.2: {} + string-argv@0.3.2: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -9283,6 +9756,17 @@ snapshots: strip-ansi: 6.0.1 optional: true + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + + string-width@8.2.0: + dependencies: + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 @@ -9300,6 +9784,10 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-json-comments@2.0.1: {} strtok3@10.3.4: @@ -9320,6 +9808,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + tailwindcss@4.2.2: {} + tapable@2.3.0: {} tar-fs@2.1.4: @@ -9372,6 +9862,8 @@ snapshots: tiny-invariant@1.3.3: {} + tinyexec@1.0.4: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -9509,10 +10001,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: {} @@ -9687,6 +10175,12 @@ snapshots: dependencies: '@types/node': 22.18.8 + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrappy@1.0.2: {} ws@8.18.3: {} @@ -9699,6 +10193,8 @@ snapshots: yallist@4.0.0: {} + yaml@2.8.2: {} + yocto-queue@0.1.0: {} zlibjs@0.2.0: {} diff --git a/application/public/dicts/base.dat.gz b/application/public/dicts/base.dat.gz deleted file mode 100644 index 621f6b5115..0000000000 Binary files a/application/public/dicts/base.dat.gz and /dev/null differ diff --git a/application/public/dicts/cc.dat.gz b/application/public/dicts/cc.dat.gz deleted file mode 100644 index c8cf6ceb3b..0000000000 Binary files a/application/public/dicts/cc.dat.gz and /dev/null differ diff --git a/application/public/dicts/check.dat.gz b/application/public/dicts/check.dat.gz deleted file mode 100644 index b250c0b061..0000000000 Binary files a/application/public/dicts/check.dat.gz and /dev/null differ diff --git a/application/public/dicts/tid.dat.gz b/application/public/dicts/tid.dat.gz deleted file mode 100644 index c80ce80350..0000000000 Binary files a/application/public/dicts/tid.dat.gz and /dev/null differ diff --git a/application/public/dicts/tid_map.dat.gz b/application/public/dicts/tid_map.dat.gz deleted file mode 100644 index 9e2947703a..0000000000 Binary files a/application/public/dicts/tid_map.dat.gz and /dev/null differ diff --git a/application/public/dicts/tid_pos.dat.gz b/application/public/dicts/tid_pos.dat.gz deleted file mode 100644 index 18773898f2..0000000000 Binary files a/application/public/dicts/tid_pos.dat.gz and /dev/null differ diff --git a/application/public/dicts/unk.dat.gz b/application/public/dicts/unk.dat.gz deleted file mode 100644 index 691f73ad14..0000000000 Binary files a/application/public/dicts/unk.dat.gz and /dev/null differ diff --git a/application/public/dicts/unk_char.dat.gz b/application/public/dicts/unk_char.dat.gz deleted file mode 100644 index 72ae88c37f..0000000000 Binary files a/application/public/dicts/unk_char.dat.gz and /dev/null differ diff --git a/application/public/dicts/unk_compat.dat.gz b/application/public/dicts/unk_compat.dat.gz deleted file mode 100644 index 13dbc993c7..0000000000 Binary files a/application/public/dicts/unk_compat.dat.gz and /dev/null differ diff --git a/application/public/dicts/unk_invoke.dat.gz b/application/public/dicts/unk_invoke.dat.gz deleted file mode 100644 index c018b127f5..0000000000 Binary files a/application/public/dicts/unk_invoke.dat.gz and /dev/null differ diff --git a/application/public/dicts/unk_map.dat.gz b/application/public/dicts/unk_map.dat.gz deleted file mode 100644 index 688f535170..0000000000 Binary files a/application/public/dicts/unk_map.dat.gz and /dev/null differ diff --git a/application/public/dicts/unk_pos.dat.gz b/application/public/dicts/unk_pos.dat.gz deleted file mode 100644 index 47054a0a2e..0000000000 Binary files a/application/public/dicts/unk_pos.dat.gz and /dev/null differ diff --git a/application/public/fonts/ReiNoAreMincho-Heavy.woff2 b/application/public/fonts/ReiNoAreMincho-Heavy.woff2 new file mode 100644 index 0000000000..fa4650946c Binary files /dev/null and b/application/public/fonts/ReiNoAreMincho-Heavy.woff2 differ diff --git a/application/public/fonts/ReiNoAreMincho-Regular.woff2 b/application/public/fonts/ReiNoAreMincho-Regular.woff2 new file mode 100644 index 0000000000..f8c5a6ae47 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..c60c62e098 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..28bed79ba1 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..35891b0e0b 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..bea6fc2237 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..d3e815b039 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..aacbc4c4f1 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..2cbec0da95 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..365e001b72 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..e2d983e09e 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..a12b55cd9a 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..ea14b8c7ef 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..f0c901ae7f 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..b838ee569f 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..00bfc72534 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..28923e1068 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..19356b324d 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..c0fa38eb5f 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..9636e717e1 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..b803e1a187 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..1951af873e 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..1cfe09a5a0 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..ebfc5a8a5e 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..12c602b070 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..e07d890fcb 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..81f55fac76 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..740c43beea 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..bd633578c8 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..476b90d0c5 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..56b1b15236 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..8aa04d699a 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..e1e40b40c7 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..fd9d3c51e2 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..b59a3fe879 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..1ee02342c3 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..73ee1c6ca6 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..300b52f1ca 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..9127c6957c 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..de0d8f41ec 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..6d1471e04f 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..036af92b8c 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..ef231374a2 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..5368693359 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..1d2e856725 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..42179cea5f 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..f2e0f991eb 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..3e81767f6a 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..8f8562c205 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..dbccaac89e 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..fc21143252 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..557380c2c3 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..3c772765d2 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..2cf0559396 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..a780ebfbc1 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..b26292bf76 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..c53bfa9aa3 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..d790085af5 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..f6e7baa507 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..09e207021a 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..c16c57f157 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..185cb6898e 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.gif b/application/public/movies/090e7491-5cdb-4a1b-88b1-1e036a45e296.gif deleted file mode 100644 index b7798a3627..0000000000 Binary files a/application/public/movies/090e7491-5cdb-4a1b-88b1-1e036a45e296.gif and /dev/null differ diff --git a/application/public/movies/090e7491-5cdb-4a1b-88b1-1e036a45e296.mp4 b/application/public/movies/090e7491-5cdb-4a1b-88b1-1e036a45e296.mp4 new file mode 100644 index 0000000000..8fa1641f66 Binary files /dev/null and b/application/public/movies/090e7491-5cdb-4a1b-88b1-1e036a45e296.mp4 differ diff --git a/application/public/movies/0c4b66bc-091e-4f76-85a3-288567cfdc12.gif b/application/public/movies/0c4b66bc-091e-4f76-85a3-288567cfdc12.gif deleted file mode 100644 index 3b57aade45..0000000000 Binary files a/application/public/movies/0c4b66bc-091e-4f76-85a3-288567cfdc12.gif and /dev/null differ diff --git a/application/public/movies/0c4b66bc-091e-4f76-85a3-288567cfdc12.mp4 b/application/public/movies/0c4b66bc-091e-4f76-85a3-288567cfdc12.mp4 new file mode 100644 index 0000000000..0924d22ea2 Binary files /dev/null and b/application/public/movies/0c4b66bc-091e-4f76-85a3-288567cfdc12.mp4 differ diff --git a/application/public/movies/1b558288-6ec6-4ece-a9b8-4259379b7489.gif b/application/public/movies/1b558288-6ec6-4ece-a9b8-4259379b7489.gif deleted file mode 100644 index 955479d1dd..0000000000 Binary files a/application/public/movies/1b558288-6ec6-4ece-a9b8-4259379b7489.gif and /dev/null differ diff --git a/application/public/movies/1b558288-6ec6-4ece-a9b8-4259379b7489.mp4 b/application/public/movies/1b558288-6ec6-4ece-a9b8-4259379b7489.mp4 new file mode 100644 index 0000000000..4ba2dba953 Binary files /dev/null and b/application/public/movies/1b558288-6ec6-4ece-a9b8-4259379b7489.mp4 differ diff --git a/application/public/movies/241b7993-f7c4-49e5-84f0-bbaf6a144634.gif b/application/public/movies/241b7993-f7c4-49e5-84f0-bbaf6a144634.gif deleted file mode 100644 index 1b3d66c6c3..0000000000 Binary files a/application/public/movies/241b7993-f7c4-49e5-84f0-bbaf6a144634.gif and /dev/null differ diff --git a/application/public/movies/241b7993-f7c4-49e5-84f0-bbaf6a144634.mp4 b/application/public/movies/241b7993-f7c4-49e5-84f0-bbaf6a144634.mp4 new file mode 100644 index 0000000000..a5baf5c4fa Binary files /dev/null and b/application/public/movies/241b7993-f7c4-49e5-84f0-bbaf6a144634.mp4 differ diff --git a/application/public/movies/3cb50e48-535b-4e5f-bbde-455c01def021.gif b/application/public/movies/3cb50e48-535b-4e5f-bbde-455c01def021.gif deleted file mode 100644 index 04207e5b63..0000000000 Binary files a/application/public/movies/3cb50e48-535b-4e5f-bbde-455c01def021.gif and /dev/null differ diff --git a/application/public/movies/3cb50e48-535b-4e5f-bbde-455c01def021.mp4 b/application/public/movies/3cb50e48-535b-4e5f-bbde-455c01def021.mp4 new file mode 100644 index 0000000000..cdd3d6a8d7 Binary files /dev/null and b/application/public/movies/3cb50e48-535b-4e5f-bbde-455c01def021.mp4 differ diff --git a/application/public/movies/51a14d70-9dd6-45ad-9f87-64af91ec2779.gif b/application/public/movies/51a14d70-9dd6-45ad-9f87-64af91ec2779.gif deleted file mode 100644 index 32dad01ff2..0000000000 Binary files a/application/public/movies/51a14d70-9dd6-45ad-9f87-64af91ec2779.gif and /dev/null differ diff --git a/application/public/movies/51a14d70-9dd6-45ad-9f87-64af91ec2779.mp4 b/application/public/movies/51a14d70-9dd6-45ad-9f87-64af91ec2779.mp4 new file mode 100644 index 0000000000..7d4a475ee3 Binary files /dev/null and b/application/public/movies/51a14d70-9dd6-45ad-9f87-64af91ec2779.mp4 differ diff --git a/application/public/movies/6ccc437c-253d-4e6f-baa2-2f4f2419f830.gif b/application/public/movies/6ccc437c-253d-4e6f-baa2-2f4f2419f830.gif deleted file mode 100644 index 2a58c67b43..0000000000 Binary files a/application/public/movies/6ccc437c-253d-4e6f-baa2-2f4f2419f830.gif and /dev/null differ diff --git a/application/public/movies/6ccc437c-253d-4e6f-baa2-2f4f2419f830.mp4 b/application/public/movies/6ccc437c-253d-4e6f-baa2-2f4f2419f830.mp4 new file mode 100644 index 0000000000..b9076d7f06 Binary files /dev/null and b/application/public/movies/6ccc437c-253d-4e6f-baa2-2f4f2419f830.mp4 differ diff --git a/application/public/movies/74eb4b82-601d-40ec-9aa5-70c4ac5d9799.gif b/application/public/movies/74eb4b82-601d-40ec-9aa5-70c4ac5d9799.gif deleted file mode 100644 index 83ce120211..0000000000 Binary files a/application/public/movies/74eb4b82-601d-40ec-9aa5-70c4ac5d9799.gif and /dev/null differ diff --git a/application/public/movies/74eb4b82-601d-40ec-9aa5-70c4ac5d9799.mp4 b/application/public/movies/74eb4b82-601d-40ec-9aa5-70c4ac5d9799.mp4 new file mode 100644 index 0000000000..b3c4d3663b Binary files /dev/null and b/application/public/movies/74eb4b82-601d-40ec-9aa5-70c4ac5d9799.mp4 differ diff --git a/application/public/movies/7518b1ae-3bc5-4b42-b82b-0013a3a74b16.gif b/application/public/movies/7518b1ae-3bc5-4b42-b82b-0013a3a74b16.gif deleted file mode 100644 index 1792654806..0000000000 Binary files a/application/public/movies/7518b1ae-3bc5-4b42-b82b-0013a3a74b16.gif and /dev/null differ diff --git a/application/public/movies/7518b1ae-3bc5-4b42-b82b-0013a3a74b16.mp4 b/application/public/movies/7518b1ae-3bc5-4b42-b82b-0013a3a74b16.mp4 new file mode 100644 index 0000000000..251baa9930 Binary files /dev/null and b/application/public/movies/7518b1ae-3bc5-4b42-b82b-0013a3a74b16.mp4 differ diff --git a/application/public/movies/826f0b4d-0f4b-408c-9560-a82798116255.gif b/application/public/movies/826f0b4d-0f4b-408c-9560-a82798116255.gif deleted file mode 100644 index f6c6268a43..0000000000 Binary files a/application/public/movies/826f0b4d-0f4b-408c-9560-a82798116255.gif and /dev/null differ diff --git a/application/public/movies/826f0b4d-0f4b-408c-9560-a82798116255.mp4 b/application/public/movies/826f0b4d-0f4b-408c-9560-a82798116255.mp4 new file mode 100644 index 0000000000..094cc44134 Binary files /dev/null and b/application/public/movies/826f0b4d-0f4b-408c-9560-a82798116255.mp4 differ diff --git a/application/public/movies/b3998a47-ee87-483e-acf1-8e5b69c8527a.gif b/application/public/movies/b3998a47-ee87-483e-acf1-8e5b69c8527a.gif deleted file mode 100644 index 6be7f49447..0000000000 Binary files a/application/public/movies/b3998a47-ee87-483e-acf1-8e5b69c8527a.gif and /dev/null differ diff --git a/application/public/movies/b3998a47-ee87-483e-acf1-8e5b69c8527a.mp4 b/application/public/movies/b3998a47-ee87-483e-acf1-8e5b69c8527a.mp4 new file mode 100644 index 0000000000..78cb6a19e3 Binary files /dev/null and b/application/public/movies/b3998a47-ee87-483e-acf1-8e5b69c8527a.mp4 differ diff --git a/application/public/movies/b44e6ef6-fb30-4f59-9c86-70fe0f1edf08.gif b/application/public/movies/b44e6ef6-fb30-4f59-9c86-70fe0f1edf08.gif deleted file mode 100644 index cb45b84887..0000000000 Binary files a/application/public/movies/b44e6ef6-fb30-4f59-9c86-70fe0f1edf08.gif and /dev/null differ diff --git a/application/public/movies/b44e6ef6-fb30-4f59-9c86-70fe0f1edf08.mp4 b/application/public/movies/b44e6ef6-fb30-4f59-9c86-70fe0f1edf08.mp4 new file mode 100644 index 0000000000..aebc325bef Binary files /dev/null and b/application/public/movies/b44e6ef6-fb30-4f59-9c86-70fe0f1edf08.mp4 differ diff --git a/application/public/movies/c8f1d48d-d831-4d69-9477-0112152f95b9.gif b/application/public/movies/c8f1d48d-d831-4d69-9477-0112152f95b9.gif deleted file mode 100644 index 369c4f6c2f..0000000000 Binary files a/application/public/movies/c8f1d48d-d831-4d69-9477-0112152f95b9.gif and /dev/null differ diff --git a/application/public/movies/c8f1d48d-d831-4d69-9477-0112152f95b9.mp4 b/application/public/movies/c8f1d48d-d831-4d69-9477-0112152f95b9.mp4 new file mode 100644 index 0000000000..eefe0da2df Binary files /dev/null and b/application/public/movies/c8f1d48d-d831-4d69-9477-0112152f95b9.mp4 differ diff --git a/application/public/movies/db504945-d122-4c70-8c2d-fe636282ca00.gif b/application/public/movies/db504945-d122-4c70-8c2d-fe636282ca00.gif deleted file mode 100644 index 55c85a097a..0000000000 Binary files a/application/public/movies/db504945-d122-4c70-8c2d-fe636282ca00.gif and /dev/null differ diff --git a/application/public/movies/db504945-d122-4c70-8c2d-fe636282ca00.mp4 b/application/public/movies/db504945-d122-4c70-8c2d-fe636282ca00.mp4 new file mode 100644 index 0000000000..c8cb2056dc Binary files /dev/null and b/application/public/movies/db504945-d122-4c70-8c2d-fe636282ca00.mp4 differ diff --git a/application/public/movies/fafa6ec6-1572-4def-aa16-4a9fbf28aa41.gif b/application/public/movies/fafa6ec6-1572-4def-aa16-4a9fbf28aa41.gif deleted file mode 100644 index d63af63ab8..0000000000 Binary files a/application/public/movies/fafa6ec6-1572-4def-aa16-4a9fbf28aa41.gif and /dev/null differ diff --git a/application/public/movies/fafa6ec6-1572-4def-aa16-4a9fbf28aa41.mp4 b/application/public/movies/fafa6ec6-1572-4def-aa16-4a9fbf28aa41.mp4 new file mode 100644 index 0000000000..f6446f0209 Binary files /dev/null and b/application/public/movies/fafa6ec6-1572-4def-aa16-4a9fbf28aa41.mp4 differ diff --git a/application/server/package.json b/application/server/package.json index 9482575df7..43faa81d5f 100644 --- a/application/server/package.json +++ b/application/server/package.json @@ -13,18 +13,26 @@ "seed:insert": "tsx ./scripts/insertSeeds.ts" }, "dependencies": { + "@ffmpeg-installer/ffmpeg": "1.1.0", "@tsconfig/strictest": "2.0.8", + "@types/compression": "1.8.1", "@web-speed-hackathon-2026/server": "workspace:*", + "bayesian-bm25": "0.4.0", "bcrypt": "6.0.0", "body-parser": "2.2.0", + "compression": "1.8.1", "connect-history-api-fallback": "2.0.0", + "exifr": "7.1.3", "express": "5.1.0", "express-session": "1.18.2", "file-type": "21.1.1", "http-errors": "2.0.0", + "kuromoji": "0.1.2", "music-metadata": "11.10.3", + "negaposi-analyzer-ja": "1.0.1", "sequelize": "6.37.7", "serve-static": "2.2.0", + "sharp": "0.33.5", "sqlite3": "5.1.7", "tsx": "4.20.6", "uuid": "13.0.0", @@ -37,7 +45,9 @@ "@types/connect-history-api-fallback": "1.5.4", "@types/express": "5.0.3", "@types/express-session": "1.18.2", + "@types/fluent-ffmpeg": "2.1.27", "@types/http-errors": "2.0.5", + "@types/kuromoji": "0.1.3", "@types/node": "22.18.8", "@types/serve-static": "1.15.9", "@types/ws": "8.18.1", diff --git a/application/server/src/app.ts b/application/server/src/app.ts index 671fb424cc..5b04fc16d9 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,17 +10,10 @@ export const app = Express(); app.set("trust proxy", true); +app.use(compression()); app.use(sessionMiddleware); app.use(bodyParser.json()); app.use(bodyParser.raw({ limit: "10mb" })); -app.use((_req, res, next) => { - res.header({ - "Cache-Control": "max-age=0, no-transform", - Connection: "close", - }); - return next(); -}); - app.use("/api/v1", apiRouter); app.use(staticRouter); diff --git a/application/server/src/index.ts b/application/server/src/index.ts index 8b7858889a..ed146b31da 100644 --- a/application/server/src/index.ts +++ b/application/server/src/index.ts @@ -6,6 +6,27 @@ import { initializeSequelize } from "./sequelize"; async function main() { await initializeSequelize(); + // Pre-compute waveforms for all sounds to warm cache + try { + const { Sound } = await import("@web-speed-hackathon-2026/server/src/models"); + const { computeWaveform } = + await import("@web-speed-hackathon-2026/server/src/routes/api/sound"); + const sounds = await Sound.findAll(); + await Promise.all(sounds.map((s) => computeWaveform(s.id).catch(() => {}))); + console.log(`Pre-computed waveforms for ${sounds.length} sounds`); + } catch (err) { + console.warn("Failed to pre-compute waveforms:", err); + } + + // Pre-build home HTML cache + try { + const { warmHomeCache } = await import("@web-speed-hackathon-2026/server/src/routes/static"); + await warmHomeCache(); + console.log("Pre-built home HTML cache"); + } catch (err) { + console.warn("Failed to pre-build home HTML cache:", err); + } + const server = app.listen(Number(process.env["PORT"] || 3000), "0.0.0.0", () => { const address = server.address(); if (typeof address === "object") { diff --git a/application/server/src/models/Comment.ts b/application/server/src/models/Comment.ts index 41e9e4e094..d9c9caa3b5 100644 --- a/application/server/src/models/Comment.ts +++ b/application/server/src/models/Comment.ts @@ -53,6 +53,7 @@ export function initComment(sequelize: Sequelize) { ], order: [["createdAt", "ASC"]], }, + indexes: [{ fields: ["userId"] }, { fields: ["postId"] }], }, ); } diff --git a/application/server/src/models/DirectMessage.ts b/application/server/src/models/DirectMessage.ts index e4565ba1c4..9606081358 100644 --- a/application/server/src/models/DirectMessage.ts +++ b/application/server/src/models/DirectMessage.ts @@ -69,24 +69,30 @@ export function initDirectMessage(sequelize: Sequelize) { ], order: [["createdAt", "ASC"]], }, + indexes: [ + { fields: ["conversationId"] }, + { fields: ["senderId"] }, + { fields: ["conversationId", "createdAt"] }, + ], }, ); DirectMessage.addHook("afterSave", "onDmSaved", async (message) => { - const directMessage = await DirectMessage.findByPk(message.get().id); - const conversation = await DirectMessageConversation.findByPk(directMessage?.conversationId); + const conversationId = message.getDataValue("conversationId"); + const senderId = message.getDataValue("senderId"); + + const conversation = await DirectMessageConversation.unscoped().findByPk(conversationId, { + attributes: ["id", "initiatorId", "memberId"], + }); - if (directMessage == null || conversation == null) { + if (conversation == null) { return; } const receiverId = - conversation.initiatorId === directMessage.senderId - ? conversation.memberId - : conversation.initiatorId; + conversation.initiatorId === senderId ? conversation.memberId : conversation.initiatorId; - const unreadCount = await DirectMessage.count({ - distinct: true, + const unreadCount = await DirectMessage.unscoped().count({ where: { senderId: { [Op.ne]: receiverId }, isRead: false, @@ -94,6 +100,7 @@ export function initDirectMessage(sequelize: Sequelize) { include: [ { association: "conversation", + attributes: [], where: { [Op.or]: [{ initiatorId: receiverId }, { memberId: receiverId }], }, @@ -102,6 +109,7 @@ export function initDirectMessage(sequelize: Sequelize) { ], }); + const directMessage = await DirectMessage.findByPk(message.getDataValue("id")); eventhub.emit(`dm:conversation/${conversation.id}:message`, directMessage); eventhub.emit(`dm:unread/${receiverId}`, { unreadCount }); }); diff --git a/application/server/src/models/DirectMessageConversation.ts b/application/server/src/models/DirectMessageConversation.ts index 99ebb2425b..965ac0ddb5 100644 --- a/application/server/src/models/DirectMessageConversation.ts +++ b/application/server/src/models/DirectMessageConversation.ts @@ -58,6 +58,11 @@ export function initDirectMessageConversation(sequelize: Sequelize) { }, ], }, + indexes: [ + { fields: ["initiatorId"] }, + { fields: ["memberId"] }, + { fields: ["initiatorId", "memberId"] }, + ], }, ); } diff --git a/application/server/src/models/Post.ts b/application/server/src/models/Post.ts index 6f86442eb1..9ef9fc1914 100644 --- a/application/server/src/models/Post.ts +++ b/application/server/src/models/Post.ts @@ -64,6 +64,12 @@ export function initPost(sequelize: Sequelize) { ["images", "createdAt", "ASC"], ], }, + indexes: [ + { fields: ["userId"] }, + { fields: ["movieId"] }, + { fields: ["soundId"] }, + { fields: ["createdAt"] }, + ], }, ); } diff --git a/application/server/src/models/User.ts b/application/server/src/models/User.ts index 9085a287a3..eaa17ddc2f 100644 --- a/application/server/src/models/User.ts +++ b/application/server/src/models/User.ts @@ -81,6 +81,7 @@ export function initUser(sequelize: Sequelize) { attributes: { exclude: ["profileImageId"] }, include: { association: "profileImage" }, }, + indexes: [{ fields: ["profileImageId"] }], }, ); } diff --git a/application/server/src/routes/api.ts b/application/server/src/routes/api.ts index e6a57a3b16..8e556c149d 100644 --- a/application/server/src/routes/api.ts +++ b/application/server/src/routes/api.ts @@ -10,7 +10,9 @@ 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 { translateRouter } from "@web-speed-hackathon-2026/server/src/routes/api/translate"; import { userRouter } from "@web-speed-hackathon-2026/server/src/routes/api/user"; export const apiRouter = Router(); @@ -25,6 +27,8 @@ apiRouter.use(imageRouter); apiRouter.use(soundRouter); apiRouter.use(authRouter); apiRouter.use(crokRouter); +apiRouter.use(sentimentRouter); +apiRouter.use(translateRouter); apiRouter.use(async (err: Error, _req: Request, _res: Response, _next: NextFunction) => { if (err instanceof ValidationError) { diff --git a/application/server/src/routes/api/crok.ts b/application/server/src/routes/api/crok.ts index cfd6065951..1ecb62fa5e 100644 --- a/application/server/src/routes/api/crok.ts +++ b/application/server/src/routes/api/crok.ts @@ -2,19 +2,60 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { BM25 } from "bayesian-bm25"; import { Router } from "express"; import httpErrors from "http-errors"; import { QaSuggestion } from "@web-speed-hackathon-2026/server/src/models"; +import { getTokenizer } from "@web-speed-hackathon-2026/server/src/routes/api/sentiment"; export const crokRouter = Router(); 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 STOP_POS = new Set(["助詞", "助動詞", "記号"]); + +crokRouter.get("/crok/suggestions", async (req, res) => { + const q = req.query["q"]; const suggestions = await QaSuggestion.findAll({ logging: false }); - res.json({ suggestions: suggestions.map((s) => s.question) }); + const candidates = suggestions.map((s) => s.question); + + if (typeof q !== "string" || q.trim() === "") { + res.json({ suggestions: candidates, tokens: [] }); + return; + } + + const tokenizer = await getTokenizer(); + const queryTokens = tokenizer + .tokenize(q) + .filter((t) => t.surface_form !== "" && t.pos !== "" && !STOP_POS.has(t.pos)) + .map((t) => t.surface_form.toLowerCase()); + + if (queryTokens.length === 0) { + res.json({ suggestions: [], tokens: [] }); + return; + } + + const bm25 = new BM25({ k1: 1.2, b: 0.75 }); + const tokenizedCandidates = candidates.map((c) => + tokenizer + .tokenize(c) + .filter((t) => t.surface_form !== "" && t.pos !== "" && !STOP_POS.has(t.pos)) + .map((t) => t.surface_form.toLowerCase()), + ); + bm25.index(tokenizedCandidates); + + const scores = bm25.getScores(queryTokens); + const results = candidates.map((text, i) => ({ text, score: scores[i] as number })); + + const filtered = results + .filter((s) => s.score > 0) + .sort((a, b) => a.score - b.score) + .slice(-10) + .map((s) => s.text); + + res.json({ suggestions: filtered, tokens: queryTokens }); }); function sleep(ms: number): Promise { @@ -33,15 +74,12 @@ crokRouter.get("/crok", async (req, res) => { let messageId = 0; - // TTFT (Time to First Token) - await sleep(3000); - - for (const char of response) { + const chunkSize = 200; + for (let i = 0; i < response.length; i += chunkSize) { if (res.closed) break; - - const data = JSON.stringify({ text: char, done: false }); + const chunk = response.slice(i, i + chunkSize); + const data = JSON.stringify({ text: chunk, done: false }); res.write(`event: message\nid: ${messageId++}\ndata: ${data}\n\n`); - await sleep(10); } diff --git a/application/server/src/routes/api/direct_message.ts b/application/server/src/routes/api/direct_message.ts index 2993a2d6be..b791002005 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, QueryTypes } from "sequelize"; import { eventhub } from "@web-speed-hackathon-2026/server/src/eventhub"; import { @@ -16,20 +16,86 @@ 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" }] }, + ], 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"]], }); - const sorted = conversations.map((c) => ({ - ...c.toJSON(), - messages: c.messages?.reverse(), - })); + const conversationIds = conversations.map((c) => c.id); + if (conversationIds.length === 0) { + return res.status(200).type("application/json").send([]); + } + + // Fetch the latest message ID per conversation via raw SQL subquery + const sequelize = DirectMessage.sequelize!; + const latestMsgRows = await sequelize.query<{ + id: string; + conversationId: string; + createdAt: string; + }>( + `SELECT dm.id, dm.conversationId, dm.createdAt + FROM DirectMessages dm + INNER JOIN ( + SELECT conversationId, MAX(createdAt) AS maxCreatedAt + FROM DirectMessages + WHERE conversationId IN (:convIds) + GROUP BY conversationId + ) latest ON dm.conversationId = latest.conversationId AND dm.createdAt = latest.maxCreatedAt + WHERE dm.conversationId IN (:convIds)`, + { + replacements: { convIds: conversationIds }, + type: QueryTypes.SELECT, + }, + ); + + // Fetch full message objects for those IDs + const latestMsgIds = latestMsgRows.map((r) => r.id); + const latestMessages = + latestMsgIds.length > 0 + ? await DirectMessage.unscoped().findAll({ + where: { id: { [Op.in]: latestMsgIds } }, + include: [{ association: "sender", include: [{ association: "profileImage" }] }], + }) + : []; + + const msgByConv = new Map(); + for (const msg of latestMessages) { + msgByConv.set(msg.conversationId, msg); + } + + // Query unread conversation IDs in bulk + const unreadRows = await DirectMessage.unscoped().findAll({ + attributes: ["conversationId"], + where: { + conversationId: { [Op.in]: conversationIds }, + senderId: { [Op.ne]: req.session.userId }, + isRead: false, + }, + group: ["conversationId"], + }); + const unreadSet = new Set(unreadRows.map((r) => r.conversationId)); + + // Filter out conversations with no messages, then sort by latest message + const sorted = conversations + .filter((c) => msgByConv.has(c.id)) + .map((c) => { + const latestMsg = msgByConv.get(c.id)!; + return { + ...c.toJSON(), + messages: [latestMsg], + hasUnread: unreadSet.has(c.id), + }; + }) + .sort((a, b) => { + const aLast = a.messages[0]; + const bLast = b.messages[0]; + return new Date(bLast?.createdAt ?? 0).getTime() - new Date(aLast?.createdAt ?? 0).getTime(); + }); return res.status(200).type("application/json").send(sorted); }); @@ -44,7 +110,7 @@ directMessageRouter.post("/dm", async (req, res) => { throw new httpErrors.NotFound(); } - const [conversation] = await DirectMessageConversation.findOrCreate({ + const [conversation] = await DirectMessageConversation.unscoped().findOrCreate({ where: { [Op.or]: [ { initiatorId: req.session.userId, memberId: peer.id }, @@ -56,9 +122,18 @@ directMessageRouter.post("/dm", async (req, res) => { memberId: peer.id, }, }); - await conversation.reload(); - return res.status(200).type("application/json").send(conversation); + const result = 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({ ...result!.toJSON(), messages: [] }); }); directMessageRouter.ws("/dm/unread", async (req, _res) => { @@ -100,7 +175,17 @@ directMessageRouter.get("/dm/:conversationId", async (req, res) => { throw new httpErrors.Unauthorized(); } - const conversation = await DirectMessageConversation.findOne({ + const conversation = await DirectMessageConversation.unscoped().findOne({ + include: [ + { association: "initiator", include: [{ association: "profileImage" }] }, + { association: "member", include: [{ association: "profileImage" }] }, + { + association: "messages", + include: [{ association: "sender", include: [{ association: "profileImage" }] }], + separate: true, + order: [["createdAt", "ASC"]], + }, + ], where: { id: req.params.conversationId, [Op.or]: [{ initiatorId: req.session.userId }, { memberId: req.session.userId }], @@ -118,7 +203,8 @@ directMessageRouter.ws("/dm/:conversationId", async (req, _res) => { throw new httpErrors.Unauthorized(); } - const conversation = await DirectMessageConversation.findOne({ + const conversation = await DirectMessageConversation.unscoped().findOne({ + attributes: ["id", "initiatorId", "memberId"], where: { id: req.params.conversationId, [Op.or]: [{ initiatorId: req.session.userId }, { memberId: req.session.userId }], @@ -160,7 +246,8 @@ directMessageRouter.post("/dm/:conversationId/messages", async (req, res) => { throw new httpErrors.BadRequest(); } - const conversation = await DirectMessageConversation.findOne({ + const conversation = await DirectMessageConversation.unscoped().findOne({ + attributes: ["id", "initiatorId", "memberId"], where: { id: req.params.conversationId, [Op.or]: [{ initiatorId: req.session.userId }, { memberId: req.session.userId }], @@ -185,7 +272,8 @@ directMessageRouter.post("/dm/:conversationId/read", async (req, res) => { throw new httpErrors.Unauthorized(); } - const conversation = await DirectMessageConversation.findOne({ + const conversation = await DirectMessageConversation.unscoped().findOne({ + attributes: ["id", "initiatorId", "memberId"], where: { id: req.params.conversationId, [Op.or]: [{ initiatorId: req.session.userId }, { memberId: req.session.userId }], @@ -204,10 +292,28 @@ directMessageRouter.post("/dm/:conversationId/read", async (req, res) => { { isRead: true }, { where: { conversationId: conversation.id, senderId: peerId, isRead: false }, - individualHooks: true, }, ); + // Emit unread count update once (replaces per-row individualHooks) + const unreadCount = await DirectMessage.unscoped().count({ + where: { + senderId: { [Op.ne]: req.session.userId }, + isRead: false, + }, + include: [ + { + association: "conversation", + attributes: [], + where: { + [Op.or]: [{ initiatorId: req.session.userId }, { memberId: req.session.userId }], + }, + required: true, + }, + ], + }); + eventhub.emit(`dm:unread/${req.session.userId}`, { unreadCount }); + return res.status(200).type("application/json").send({}); }); @@ -216,7 +322,12 @@ 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, + { + attributes: ["id", "initiatorId", "memberId"], + }, + ); 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..281bab7343 100644 --- a/application/server/src/routes/api/image.ts +++ b/application/server/src/routes/api/image.ts @@ -1,14 +1,14 @@ import { promises as fs } from "fs"; import path from "path"; +import exifr from "exifr"; import { Router } from "express"; -import { fileTypeFromBuffer } from "file-type"; import httpErrors from "http-errors"; +import sharp from "sharp"; import { v4 as uuidv4 } from "uuid"; import { UPLOAD_PATH } from "@web-speed-hackathon-2026/server/src/paths"; -// 変換した画像の拡張子 const EXTENSION = "jpg"; export const imageRouter = Router(); @@ -21,16 +21,22 @@ imageRouter.post("/images", async (req, res) => { throw new httpErrors.BadRequest(); } - const type = await fileTypeFromBuffer(req.body); - if (type === undefined || type.ext !== EXTENSION) { - throw new httpErrors.BadRequest("Invalid file type"); + let alt = ""; + try { + const exifData = await exifr.parse(req.body, { pick: ["ImageDescription"] }); + if (exifData?.ImageDescription) { + alt = exifData.ImageDescription; + } + } catch { + // EXIF extraction failed, use empty alt } 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 }); + await sharp(req.body).jpeg().toFile(filePath); + + return res.status(200).type("application/json").send({ id: imageId, alt }); }); diff --git a/application/server/src/routes/api/initialize.ts b/application/server/src/routes/api/initialize.ts index de1044cef0..2f1b09d7db 100644 --- a/application/server/src/routes/api/initialize.ts +++ b/application/server/src/routes/api/initialize.ts @@ -6,6 +6,7 @@ import { UPLOAD_PATH } from "@web-speed-hackathon-2026/server/src/paths"; import { initializeSequelize } from "../../sequelize"; import { sessionStore } from "../../session"; +import { clearHomeCache, warmHomeCache } from "../static"; export const initializeRouter = Router(); @@ -16,6 +17,9 @@ initializeRouter.post("/initialize", async (_req, res) => { sessionStore.clear(); // uploadディレクトリをクリア await fs.rm(UPLOAD_PATH, { force: true, recursive: true }); + // ホームHTMLキャッシュを再構築 + clearHomeCache(); + await warmHomeCache(); return res.status(200).type("application/json").send({}); }); diff --git a/application/server/src/routes/api/movie.ts b/application/server/src/routes/api/movie.ts index 4c96c207be..2ac1fef319 100644 --- a/application/server/src/routes/api/movie.ts +++ b/application/server/src/routes/api/movie.ts @@ -1,15 +1,19 @@ +import { execFile } from "child_process"; import { promises as fs } from "fs"; +import os from "os"; import path from "path"; +import { promisify } from "util"; +import ffmpegPath from "@ffmpeg-installer/ffmpeg"; import { Router } from "express"; -import { fileTypeFromBuffer } from "file-type"; import httpErrors from "http-errors"; import { v4 as uuidv4 } from "uuid"; import { UPLOAD_PATH } from "@web-speed-hackathon-2026/server/src/paths"; -// 変換した動画の拡張子 -const EXTENSION = "gif"; +const execFileAsync = promisify(execFile); + +const EXTENSION = "mp4"; export const movieRouter = Router(); @@ -21,16 +25,32 @@ movieRouter.post("/movies", async (req, res) => { throw new httpErrors.BadRequest(); } - const type = await fileTypeFromBuffer(req.body); - if (type === undefined || type.ext !== EXTENSION) { - throw new httpErrors.BadRequest("Invalid file type"); - } - const movieId = uuidv4(); - const filePath = path.resolve(UPLOAD_PATH, `./movies/${movieId}.${EXTENSION}`); + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "movie-")); + const inputPath = path.join(tmpDir, "input"); + const outputPath = path.resolve(UPLOAD_PATH, `./movies/${movieId}.${EXTENSION}`); + await fs.mkdir(path.resolve(UPLOAD_PATH, "movies"), { recursive: true }); - await fs.writeFile(filePath, req.body); + await fs.writeFile(inputPath, req.body); + + try { + await execFileAsync(ffmpegPath.path, [ + "-i", + inputPath, + "-t", + "5", + "-r", + "10", + "-vf", + "crop='min(iw,ih)':'min(iw,ih)'", + "-an", + "-y", + outputPath, + ]); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } 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..cfcbdce764 100644 --- a/application/server/src/routes/api/post.ts +++ b/application/server/src/routes/api/post.ts @@ -2,6 +2,7 @@ import { Router } from "express"; import httpErrors from "http-errors"; import { Comment, Post } from "@web-speed-hackathon-2026/server/src/models"; +import { clearHomeCache } from "@web-speed-hackathon-2026/server/src/routes/static"; export const postRouter = Router(); @@ -11,6 +12,7 @@ postRouter.get("/posts", async (req, res) => { offset: req.query["offset"] != null ? Number(req.query["offset"]) : undefined, }); + res.setHeader("Cache-Control", "public, max-age=5"); return res.status(200).type("application/json").send(posts); }); @@ -21,6 +23,7 @@ postRouter.get("/posts/:postId", async (req, res) => { throw new httpErrors.NotFound(); } + res.setHeader("Cache-Control", "public, max-age=5"); return res.status(200).type("application/json").send(post); }); @@ -33,6 +36,7 @@ postRouter.get("/posts/:postId/comments", async (req, res) => { }, }); + res.setHeader("Cache-Control", "public, max-age=5"); return res.status(200).type("application/json").send(posts); }); @@ -58,5 +62,6 @@ postRouter.post("/posts", async (req, res) => { }, ); + clearHomeCache(); return res.status(200).type("application/json").send(post); }); diff --git a/application/server/src/routes/api/search.ts b/application/server/src/routes/api/search.ts index 48e99856b4..011fc58ac7 100644 --- a/application/server/src/routes/api/search.ts +++ b/application/server/src/routes/api/search.ts @@ -35,58 +35,53 @@ searchRouter.get("/search", async (req, res) => { const dateWhere = dateConditions.length > 0 ? { createdAt: Object.assign({}, ...dateConditions) } : {}; - // テキスト検索条件 - const textWhere = searchTerm ? { text: { [Op.like]: searchTerm } } : {}; + const whereConditions: Record[] = []; - const postsByText = await Post.findAll({ - limit, - offset, - where: { - ...textWhere, - ...dateWhere, - }, - }); - - // ユーザー名/名前での検索(キーワードがある場合のみ) - let postsByUser: typeof postsByText = []; if (searchTerm) { - postsByUser = await Post.findAll({ - include: [ - { - association: "user", - attributes: { exclude: ["profileImageId"] }, - include: [{ association: "profileImage" }], - required: true, - where: { - [Op.or]: [{ username: { [Op.like]: searchTerm } }, { name: { [Op.like]: searchTerm } }], - }, - }, - { - association: "images", - through: { attributes: [] }, - }, - { association: "movie" }, - { association: "sound" }, + whereConditions.push({ + [Op.or]: [ + { text: { [Op.like]: searchTerm } }, + { "$user.username$": { [Op.like]: searchTerm } }, + { "$user.name$": { [Op.like]: searchTerm } }, ], - limit, - offset, - where: dateWhere, }); } - const postIdSet = new Set(); - const mergedPosts: typeof postsByText = []; - - for (const post of [...postsByText, ...postsByUser]) { - if (!postIdSet.has(post.id)) { - postIdSet.add(post.id); - mergedPosts.push(post); - } + if (Object.keys(dateWhere).length > 0) { + whereConditions.push(dateWhere); } - mergedPosts.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + // Phase 1: マッチするPost IDを正しいLIMIT/OFFSETで取得(images JOINなし) + const matchingRows = await Post.unscoped().findAll({ + attributes: ["id"], + include: [ + { + association: "user", + attributes: [], + required: false, + }, + ], + where: whereConditions.length > 0 ? { [Op.and]: whereConditions } : {}, + limit, + offset, + order: [["createdAt", "DESC"]], + subQuery: false, + raw: true, + }); + + const ids = matchingRows.map((r: { id: string }) => r.id); + + // Phase 2: IDリストでフルデータ取得(defaultScope適用) + const posts = + ids.length > 0 + ? await Post.findAll({ + where: { id: { [Op.in]: ids } }, + }) + : []; - const result = mergedPosts.slice(offset || 0, (offset || 0) + (limit || mergedPosts.length)); + // 元のコードと同じ createdAt DESC でソート + posts.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); - return res.status(200).type("application/json").send(result); + res.setHeader("Cache-Control", "public, max-age=5"); + return res.status(200).type("application/json").send(posts); }); diff --git a/application/server/src/routes/api/sentiment.ts b/application/server/src/routes/api/sentiment.ts new file mode 100644 index 0000000000..96b75904ed --- /dev/null +++ b/application/server/src/routes/api/sentiment.ts @@ -0,0 +1,51 @@ +import { Router } from "express"; +import kuromoji, { type Tokenizer, type IpadicFeatures } from "kuromoji"; +// @ts-expect-error -- no type declarations available +import analyze from "negaposi-analyzer-ja"; + +export const sentimentRouter = Router(); + +let cachedTokenizer: Tokenizer | null = null; +let pendingPromise: Promise> | null = null; + +export function getTokenizer(): Promise> { + if (cachedTokenizer) return Promise.resolve(cachedTokenizer); + if (pendingPromise) return pendingPromise; + + pendingPromise = new Promise>((resolve, reject) => { + kuromoji.builder({ dicPath: "node_modules/kuromoji/dict" }).build((err, tokenizer) => { + if (err) { + pendingPromise = null; + reject(err); + } else { + cachedTokenizer = tokenizer; + resolve(tokenizer); + } + }); + }); + + return pendingPromise; +} + +sentimentRouter.get("/sentiment", async (req, res) => { + const text = req.query["text"]; + if (typeof text !== "string" || text.trim() === "") { + res.json({ score: 0, label: "neutral" }); + return; + } + + const tokenizer = await getTokenizer(); + const tokens = tokenizer.tokenize(text); + const score = analyze(tokens); + + let label: "positive" | "negative" | "neutral"; + if (score > 0.1) { + label = "positive"; + } else if (score < -0.1) { + label = "negative"; + } else { + label = "neutral"; + } + + res.json({ score, label }); +}); diff --git a/application/server/src/routes/api/sound.ts b/application/server/src/routes/api/sound.ts index 55ce11def9..96e30fb9dc 100644 --- a/application/server/src/routes/api/sound.ts +++ b/application/server/src/routes/api/sound.ts @@ -1,19 +1,94 @@ +import { execFile } from "child_process"; import { promises as fs } from "fs"; +import os from "os"; import path from "path"; +import { promisify } from "util"; +import ffmpegPath from "@ffmpeg-installer/ffmpeg"; import { Router } from "express"; -import { fileTypeFromBuffer } from "file-type"; import httpErrors from "http-errors"; import { v4 as uuidv4 } from "uuid"; -import { UPLOAD_PATH } from "@web-speed-hackathon-2026/server/src/paths"; +import { PUBLIC_PATH, 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"; +const waveformCache = new Map(); + +export async function computeWaveform(soundId: string): Promise<{ max: number; peaks: number[] }> { + const cached = waveformCache.get(soundId); + if (cached) return cached; + + let soundPath = path.resolve(UPLOAD_PATH, `sounds/${soundId}.${EXTENSION}`); + try { + await fs.access(soundPath); + } catch { + soundPath = path.resolve(PUBLIC_PATH, `sounds/${soundId}.${EXTENSION}`); + await fs.access(soundPath); + } + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "waveform-")); + const rawPath = path.join(tmpDir, "output.raw"); + + try { + await execFileAsync(ffmpegPath.path, [ + "-i", + soundPath, + "-ac", + "1", + "-ar", + "8000", + "-f", + "f32le", + "-y", + rawPath, + ]); + + const rawBuffer = await fs.readFile(rawPath); + const samples = new Float32Array( + rawBuffer.buffer, + rawBuffer.byteOffset, + rawBuffer.byteLength / 4, + ); + + const chunkSize = Math.ceil(samples.length / 100); + const peaks: number[] = []; + let max = 0; + for (let i = 0; i < samples.length; i += chunkSize) { + const end = Math.min(i + chunkSize, samples.length); + let sum = 0; + for (let j = i; j < end; j++) { + sum += Math.abs(samples[j]!); + } + const mean = sum / (end - i); + peaks.push(mean); + if (mean > max) max = mean; + } + + const result = { max, peaks }; + waveformCache.set(soundId, result); + return result; + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } +} + export const soundRouter = Router(); +soundRouter.get("/sounds/:soundId/waveform", async (req, res) => { + const { soundId } = req.params; + try { + const result = await computeWaveform(soundId!); + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + return res.status(200).type("application/json").send(result); + } catch { + throw new httpErrors.NotFound(); + } +}); + soundRouter.post("/sounds", async (req, res) => { if (req.session.userId === undefined) { throw new httpErrors.Unauthorized(); @@ -22,18 +97,29 @@ soundRouter.post("/sounds", async (req, res) => { throw new httpErrors.BadRequest(); } - const type = await fileTypeFromBuffer(req.body); - if (type === undefined || type.ext !== EXTENSION) { - throw new httpErrors.BadRequest("Invalid file type"); - } - const soundId = uuidv4(); const { artist, title } = await extractMetadataFromSound(req.body); - const filePath = path.resolve(UPLOAD_PATH, `./sounds/${soundId}.${EXTENSION}`); + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "sound-")); + const inputPath = path.join(tmpDir, "input"); + const outputPath = path.resolve(UPLOAD_PATH, `./sounds/${soundId}.${EXTENSION}`); + await fs.mkdir(path.resolve(UPLOAD_PATH, "sounds"), { recursive: true }); - await fs.writeFile(filePath, req.body); + await fs.writeFile(inputPath, req.body); + + try { + const args = ["-i", inputPath, "-y", outputPath]; + if (title) { + args.splice(-1, 0, "-metadata", `title=${title}`); + } + if (artist) { + args.splice(-1, 0, "-metadata", `artist=${artist}`); + } + await execFileAsync(ffmpegPath.path, args); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } return res.status(200).type("application/json").send({ artist, id: soundId, title }); }); diff --git a/application/server/src/routes/api/translate.ts b/application/server/src/routes/api/translate.ts new file mode 100644 index 0000000000..9fdcced81c --- /dev/null +++ b/application/server/src/routes/api/translate.ts @@ -0,0 +1,29 @@ +import { Router } from "express"; +import httpErrors from "http-errors"; + +export const translateRouter = Router(); + +translateRouter.post("/translate", async (req, res) => { + const { text, sourceLang, targetLang } = req.body as { + text?: string; + sourceLang?: string; + targetLang?: string; + }; + + if (!text || !sourceLang || !targetLang) { + throw new httpErrors.BadRequest("text, sourceLang, targetLang are required"); + } + + try { + const params = new URLSearchParams({ + q: text, + langpair: `${sourceLang}|${targetLang}`, + }); + const response = await fetch(`https://api.mymemory.translated.net/get?${params.toString()}`); + const data = (await response.json()) as { responseData?: { translatedText?: string } }; + const translated = data.responseData?.translatedText ?? text; + res.json({ result: translated }); + } catch { + res.json({ result: text }); + } +}); diff --git a/application/server/src/routes/api/user.ts b/application/server/src/routes/api/user.ts index cc6d916822..10364ee999 100644 --- a/application/server/src/routes/api/user.ts +++ b/application/server/src/routes/api/user.ts @@ -45,6 +45,7 @@ userRouter.get("/users/:username", async (req, res) => { throw new httpErrors.NotFound(); } + res.setHeader("Cache-Control", "public, max-age=5"); return res.status(200).type("application/json").send(user); }); @@ -67,5 +68,6 @@ userRouter.get("/users/:username/posts", async (req, res) => { }, }); + res.setHeader("Cache-Control", "public, max-age=5"); return res.status(200).type("application/json").send(posts); }); diff --git a/application/server/src/routes/image-resize.ts b/application/server/src/routes/image-resize.ts new file mode 100644 index 0000000000..f5304a5f44 --- /dev/null +++ b/application/server/src/routes/image-resize.ts @@ -0,0 +1,71 @@ +import fs from "node:fs"; +import path from "node:path"; + +import type { RequestHandler } from "express"; +import sharp from "sharp"; + +import { PUBLIC_PATH, UPLOAD_PATH } from "@web-speed-hackathon-2026/server/src/paths"; + +const CACHE_DIR = "/tmp/image-cache"; + +// Ensure cache directory exists +fs.mkdirSync(CACHE_DIR, { recursive: true }); + +function findSourceFile(reqPath: string): string | null { + // reqPath is like /images/xxx.webp or /images/profiles/xxx.webp + // Try upload path first (uploaded images are .jpg) + const jpgPath = reqPath.replace(/\.webp$/, ".jpg"); + + for (const basePath of [UPLOAD_PATH, PUBLIC_PATH]) { + const webpFile = path.join(basePath, reqPath); + if (fs.existsSync(webpFile)) return webpFile; + + const jpgFile = path.join(basePath, jpgPath); + if (fs.existsSync(jpgFile)) return jpgFile; + } + return null; +} + +export const imageResizeMiddleware: RequestHandler = async (req, res, next) => { + // Only handle image requests with width parameter + if (!req.path.match(/^\/images\/.*\.webp$/) || !req.query["w"]) { + return next(); + } + + const width = parseInt(req.query["w"] as string, 10); + if (isNaN(width) || width <= 0 || width > 4000) { + return next(); + } + + const cacheKey = `${req.path.replace(/\//g, "_")}-${width}.webp`; + const cachePath = path.join(CACHE_DIR, cacheKey); + + // Serve from cache if available + if (fs.existsSync(cachePath)) { + res.setHeader("Content-Type", "image/webp"); + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + res.sendFile(cachePath); + return; + } + + const sourceFile = findSourceFile(req.path); + if (!sourceFile) { + return next(); + } + + try { + const buffer = await sharp(sourceFile) + .resize({ width, withoutEnlargement: true }) + .webp({ nearLossless: true, quality: 90 }) + .toBuffer(); + + // Write to cache + fs.writeFileSync(cachePath, buffer); + + res.setHeader("Content-Type", "image/webp"); + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + res.send(buffer); + } catch { + return next(); + } +}; diff --git a/application/server/src/routes/static.ts b/application/server/src/routes/static.ts index b5820c986e..f9a7fdfbf0 100644 --- a/application/server/src/routes/static.ts +++ b/application/server/src/routes/static.ts @@ -1,4 +1,6 @@ -import history from "connect-history-api-fallback"; +import fs from "node:fs"; +import path from "node:path"; + import { Router } from "express"; import serveStatic from "serve-static"; @@ -7,29 +9,321 @@ import { PUBLIC_PATH, UPLOAD_PATH, } from "@web-speed-hackathon-2026/server/src/paths"; +import { imageResizeMiddleware } from "@web-speed-hackathon-2026/server/src/routes/image-resize"; export const staticRouter = Router(); -// SPA 対応のため、ファイルが存在しないときに index.html を返す -staticRouter.use(history()); +// Image resize middleware (before static serving) +staticRouter.use(imageResizeMiddleware); +// Serve uploaded files and public assets first staticRouter.use( serveStatic(UPLOAD_PATH, { - etag: false, - lastModified: false, + etag: true, + lastModified: true, + maxAge: "1y", }), ); staticRouter.use( serveStatic(PUBLIC_PATH, { - etag: false, - lastModified: false, + etag: true, + lastModified: true, + maxAge: "1y", }), ); +// Serve non-HTML static assets from client dist (JS, CSS, fonts, etc.) +// Use index: false to prevent serving index.html for "/" — we handle that below staticRouter.use( serveStatic(CLIENT_DIST_PATH, { - etag: false, - lastModified: false, + etag: true, + lastModified: true, + maxAge: "1y", + index: false, + setHeaders(res, filePath) { + if (filePath.endsWith(".html")) { + res.setHeader("Cache-Control", "no-cache"); + } + }, }), ); + +// Read the HTML template — cache in production, reload every request in development +let htmlTemplate: string | null = null; +function getHtmlTemplate(): string | null { + if (process.env["NODE_ENV"] === "production" && htmlTemplate != null) { + return htmlTemplate; + } + try { + htmlTemplate = fs.readFileSync(path.resolve(CLIENT_DIST_PATH, "index.html"), "utf-8"); + return htmlTemplate; + } catch { + return null; + } +} + +// --- Home HTML cache (Change 1) --- +// Dedup concurrent requests with a shared promise +let cachedHomeHtml: string | null = null; +let homeBuildPromise: Promise | null = null; + +async function buildHomeHtml(baseHtml: string): Promise { + const initialData: Record = {}; + const preloadLinks: string[] = []; + + const { Post } = await import("@web-speed-hackathon-2026/server/src/models"); + const { computeWaveform } = await import("@web-speed-hackathon-2026/server/src/routes/api/sound"); + + const posts = await Post.findAll({ limit: 30, offset: 0 }); + const postsJson = posts.map((p) => p.toJSON()); + + await Promise.all( + postsJson.map(async (post) => { + const sound = (post as Record)["sound"] as { id: string } | null | undefined; + if (sound) { + try { + (sound as Record)["waveform"] = await computeWaveform(sound.id); + } catch { + // skip if waveform computation fails + } + } + }), + ); + + initialData["/api/v1/posts?limit=30&offset=0"] = postsJson; + + // Find the first post with images for LCP preload + for (const post of postsJson) { + const images = (post as Record)["images"] as Array<{ id: string }> | undefined; + if (images && images.length > 0) { + const firstImageId = images[0]!.id; + preloadLinks.push( + ``, + ); + break; + } + } + + let injectedHtml = baseHtml; + + if (preloadLinks.length > 0) { + injectedHtml = injectedHtml.replace("", `${preloadLinks.join("\n")}\n`); + } + + if (Object.keys(initialData).length > 0) { + const script = ``; + injectedHtml = injectedHtml.replace("", `${script}\n`); + } + + return injectedHtml; +} + +async function getHomeHtml(baseHtml: string): Promise { + if (cachedHomeHtml) return cachedHomeHtml; + if (homeBuildPromise) return homeBuildPromise; + + homeBuildPromise = buildHomeHtml(baseHtml) + .then((html) => { + cachedHomeHtml = html; + homeBuildPromise = null; + return html; + }) + .catch((err) => { + homeBuildPromise = null; + throw err; + }); + + return homeBuildPromise; +} + +export function clearHomeCache(): void { + cachedHomeHtml = null; + homeBuildPromise = null; +} + +export async function warmHomeCache(): Promise { + const html = getHtmlTemplate(); + if (html == null) return; + clearHomeCache(); + await getHomeHtml(html); +} + +// --- Route-specific initial data injection (Change 2) --- +async function buildRouteHtml( + baseHtml: string, + reqPath: string, + query: Record, +): Promise { + const initialData: Record = {}; + + try { + const postDetailMatch = reqPath.match(/^\/posts\/([^/]+)$/); + const userProfileMatch = reqPath.match(/^\/users\/([^/]+)$/); + + if (postDetailMatch) { + const postId = postDetailMatch[1]; + const { Post, Comment } = await import("@web-speed-hackathon-2026/server/src/models"); + const { computeWaveform } = + await import("@web-speed-hackathon-2026/server/src/routes/api/sound"); + + const post = await Post.findByPk(postId); + if (post) { + const postJson = post.toJSON() as Record; + const sound = postJson["sound"] as { id: string } | null | undefined; + if (sound) { + try { + (sound as Record)["waveform"] = await computeWaveform(sound.id); + } catch { + /* skip */ + } + } + initialData[`/api/v1/posts/${postId}`] = postJson; + + const comments = await Comment.findAll({ + limit: 30, + offset: 0, + where: { postId }, + }); + initialData[`/api/v1/posts/${postId}/comments?limit=30&offset=0`] = comments.map((c) => + c.toJSON(), + ); + } + } else if (userProfileMatch) { + const username = userProfileMatch[1]; + const { User, Post } = await import("@web-speed-hackathon-2026/server/src/models"); + const { computeWaveform } = + await import("@web-speed-hackathon-2026/server/src/routes/api/sound"); + + const user = await User.findOne({ where: { username } }); + if (user) { + initialData[`/api/v1/users/${username}`] = user.toJSON(); + + const posts = await Post.findAll({ + limit: 30, + offset: 0, + where: { userId: user.id }, + }); + const postsJson = posts.map((p) => p.toJSON()); + await Promise.all( + postsJson.map(async (post) => { + const sound = (post as Record)["sound"] as + | { id: string } + | null + | undefined; + if (sound) { + try { + (sound as Record)["waveform"] = await computeWaveform(sound.id); + } catch { + /* skip */ + } + } + }), + ); + initialData[`/api/v1/users/${username}/posts?limit=30&offset=0`] = postsJson; + } + } else if ( + reqPath === "/search" && + typeof query["q"] === "string" && + query["q"].trim() !== "" + ) { + const { Op } = await import("sequelize"); + const { Post } = await import("@web-speed-hackathon-2026/server/src/models"); + const { parseSearchQuery } = + await import("@web-speed-hackathon-2026/server/src/utils/parse_search_query.js"); + + const q = query["q"] as string; + const { keywords, sinceDate, untilDate } = parseSearchQuery(q); + + if (keywords || sinceDate || untilDate) { + const searchTerm = keywords ? `%${keywords}%` : null; + const dateConditions: Record[] = []; + if (sinceDate) dateConditions.push({ [Op.gte]: sinceDate }); + if (untilDate) dateConditions.push({ [Op.lte]: untilDate }); + const dateWhere = + dateConditions.length > 0 ? { createdAt: Object.assign({}, ...dateConditions) } : {}; + + const whereConditions: Record[] = []; + if (searchTerm) { + whereConditions.push({ + [Op.or]: [ + { text: { [Op.like]: searchTerm } }, + { "$user.username$": { [Op.like]: searchTerm } }, + { "$user.name$": { [Op.like]: searchTerm } }, + ], + }); + } + if (Object.keys(dateWhere).length > 0) { + whereConditions.push(dateWhere); + } + + const matchingRows = await Post.unscoped().findAll({ + attributes: ["id"], + include: [{ association: "user", attributes: [], required: false }], + where: whereConditions.length > 0 ? { [Op.and]: whereConditions } : {}, + limit: 30, + offset: 0, + order: [["createdAt", "DESC"]], + subQuery: false, + raw: true, + }); + + const ids = matchingRows.map((r: { id: string }) => r.id); + const posts = ids.length > 0 ? await Post.findAll({ where: { id: { [Op.in]: ids } } }) : []; + posts.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + + const encodedQ = encodeURIComponent(q); + initialData[`/api/v1/search?q=${encodedQ}&limit=30&offset=0`] = posts.map((p) => + p.toJSON(), + ); + } + } + } catch { + // If data fetch fails, serve HTML without initial data + } + + if (Object.keys(initialData).length === 0) { + return baseHtml; + } + + const script = ``; + return baseHtml.replace("", `${script}\n`); +} + +// SPA fallback: serve index.html with injected initial data for all page routes +staticRouter.use(async (req, res, next) => { + if (req.method !== "GET") { + return next(); + } + + // Skip API routes (handled by apiRouter before staticRouter) + if (req.path.startsWith("/api/")) { + return next(); + } + + // Skip requests that have file extensions (real static files that weren't found) + if (req.path !== "/" && /\.\w+$/.test(req.path)) { + return next(); + } + + const html = getHtmlTemplate(); + if (html == null) { + return next(); + } + + let responseHtml: string; + + try { + if (req.path === "/" || req.path === "/index.html") { + responseHtml = await getHomeHtml(html); + } else { + responseHtml = await buildRouteHtml(html, req.path, req.query as Record); + } + } catch { + responseHtml = html; + } + + res.setHeader("Content-Type", "text/html"); + res.setHeader("Cache-Control", "no-cache"); + res.send(responseHtml); +});