diff --git a/application/client/babel.config.js b/application/client/babel.config.js index c3c574591a..69e6fcd0ea 100644 --- a/application/client/babel.config.js +++ b/application/client/babel.config.js @@ -4,16 +4,15 @@ module.exports = { [ "@babel/preset-env", { - targets: "ie 11", - corejs: "3", - modules: "commonjs", + targets: { chrome: "130" }, + modules: false, useBuiltIns: false, }, ], [ "@babel/preset-react", { - development: true, + development: false, runtime: "automatic", }, ], diff --git a/application/client/package.json b/application/client/package.json index 9f8e80a6a8..9ab723b714 100644 --- a/application/client/package.json +++ b/application/client/package.json @@ -5,7 +5,7 @@ "license": "MPL-2.0", "author": "CyberAgent, Inc.", "scripts": { - "build": "NODE_ENV=development webpack", + "build": "NODE_ENV=production webpack", "typecheck": "tsc" }, "dependencies": { @@ -15,27 +15,17 @@ "@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", @@ -45,11 +35,9 @@ "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,17 +45,13 @@ "@babel/preset-env": "7.28.3", "@babel/preset-react": "7.27.1", "@babel/preset-typescript": "7.27.1", + "@tailwindcss/postcss": "4.1.13", "@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", @@ -83,6 +67,7 @@ "postcss-loader": "8.2.0", "postcss-preset-env": "10.4.0", "react-markdown": "10.1.0", + "tailwindcss": "4.1.13", "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..0c9798a8d8 100644 --- a/application/client/postcss.config.js +++ b/application/client/postcss.config.js @@ -1,9 +1,11 @@ const postcssImport = require("postcss-import"); const postcssPresetEnv = require("postcss-preset-env"); +const tailwindcss = require("@tailwindcss/postcss"); module.exports = { plugins: [ postcssImport(), + tailwindcss(), postcssPresetEnv({ stage: 3, }), diff --git a/application/client/src/components/application/SearchPage.tsx b/application/client/src/components/application/SearchPage.tsx index e99045de45..68fb1e083f 100644 --- a/application/client/src/components/application/SearchPage.tsx +++ b/application/client/src/components/application/SearchPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { FormEventHandler, useCallback, useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router"; import { Field, InjectedFormProps, reduxForm, WrappedFieldProps } from "redux-form"; @@ -13,36 +13,43 @@ import { analyzeSentiment } from "@web-speed-hackathon-2026/client/src/utils/neg import { Button } from "../foundation/Button"; +const SEARCH_INPUT_LABEL = "検索 (例: キーワード since:2025-01-01 until:2025-12-31)"; + interface Props { query: string; results: Models.Post[]; } -const SearchInput = ({ input, meta }: WrappedFieldProps) => ( -
- - {meta.touched && meta.error && ( - {meta.error} - )} -
-); +const SearchInput = ({ input, meta, extraError }: WrappedFieldProps & { extraError?: string }) => { + const error = meta.error || extraError; + const showError = (meta.touched || extraError) && error; + return ( +
+ + {showError && ( + {error} + )} +
+ ); +}; const SearchPageComponent = ({ query, results, - handleSubmit, }: Props & InjectedFormProps) => { const navigate = useNavigate(); const [isNegative, setIsNegative] = useState(false); + const [submitError, setSubmitError] = useState(); const parsed = parseSearchQuery(query); @@ -73,7 +80,7 @@ const SearchPageComponent = ({ const searchConditionText = useMemo(() => { const parts: string[] = []; if (parsed.keywords) { - parts.push(`「${parsed.keywords}」`); + parts.push(`「${parsed.keywords}」を含むテキスト`); } if (parsed.sinceDate) { parts.push(`${parsed.sinceDate} 以降`); @@ -84,17 +91,28 @@ const SearchPageComponent = ({ return parts.join(" "); }, [parsed]); - const onSubmit = (values: SearchFormData) => { - const sanitizedText = sanitizeSearchText(values.searchText.trim()); + // React 19 removes UNSAFE_componentWillReceiveProps, breaking redux-form's + // built-in validation (syncErrors never computed). Validate manually on submit. + const onFormSubmit = useCallback>((ev) => { + ev.preventDefault(); + const input = ev.currentTarget.querySelector("input[name='searchText']"); + const currentValue = input?.value ?? ""; + const errors = validate({ searchText: currentValue }); + if (typeof errors.searchText === "string") { + setSubmitError(errors.searchText); + return; + } + setSubmitError(undefined); + const sanitizedText = sanitizeSearchText(currentValue.trim()); navigate(`/search?q=${encodeURIComponent(sanitizedText)}`); - }; + }, [navigate]); return (
-
+
- + diff --git a/application/client/src/components/crok/ChatInput.tsx b/application/client/src/components/crok/ChatInput.tsx index 6f8c17796b..86ab817ab6 100644 --- a/application/client/src/components/crok/ChatInput.tsx +++ b/application/client/src/components/crok/ChatInput.tsx @@ -1,5 +1,4 @@ -import Bluebird from "bluebird"; -import kuromoji, { type Tokenizer, type IpadicFeatures } from "kuromoji"; +import type { Tokenizer, IpadicFeatures } from "kuromoji"; import { useEffect, useLayoutEffect, @@ -16,6 +15,7 @@ import { filterSuggestionsBM25, } from "@web-speed-hackathon-2026/client/src/utils/bm25_search"; import { fetchJSON } from "@web-speed-hackathon-2026/client/src/utils/fetchers"; +import { buildKuromojiTokenizer } from "@web-speed-hackathon-2026/client/src/utils/kuromoji_loader"; interface Props { isStreaming: boolean; @@ -97,8 +97,7 @@ export const ChatInput = ({ isStreaming, onSendMessage }: Props) => { let mounted = true; const init = async () => { - const builder = Bluebird.promisifyAll(kuromoji.builder({ dicPath: "/dicts" })); - const nextTokenizer = await builder.buildAsync(); + const nextTokenizer = await buildKuromojiTokenizer(); if (mounted) { setTokenizer(nextTokenizer); } @@ -129,7 +128,7 @@ export const ChatInput = ({ isStreaming, onSendMessage }: Props) => { } const tokens = extractTokens(tokenizer.tokenize(inputValue)); - const results = filterSuggestionsBM25(tokenizer, candidates, tokens); + const results = await filterSuggestionsBM25(tokenizer, candidates, tokens); if (cancelled) { return; diff --git a/application/client/src/components/direct_message/DirectMessageListPage.tsx b/application/client/src/components/direct_message/DirectMessageListPage.tsx index 5a373e918e..47a6098828 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 { formatJaRelativeFromNow } from "@web-speed-hackathon-2026/client/src/utils/datetime"; import { fetchJSON } from "@web-speed-hackathon-2026/client/src/utils/fetchers"; import { getProfileImagePath } from "@web-speed-hackathon-2026/client/src/utils/get_path"; @@ -100,7 +100,7 @@ export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => { className="text-cax-text-subtle text-xs" dateTime={lastMessage.createdAt} > - {moment(lastMessage.createdAt).locale("ja").fromNow()} + {formatJaRelativeFromNow(lastMessage.createdAt)} )}
diff --git a/application/client/src/components/direct_message/DirectMessagePage.tsx b/application/client/src/components/direct_message/DirectMessagePage.tsx index 098c7d2894..230a55cf65 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 { formatJaTime } from "@web-speed-hackathon-2026/client/src/utils/datetime"; import { getProfileImagePath } from "@web-speed-hackathon-2026/client/src/utils/get_path"; interface Props { @@ -141,7 +141,7 @@ export const DirectMessagePage = ({

{isActiveUserSend && message.isRead && ( 既読 diff --git a/application/client/src/components/foundation/CoveredImage.tsx b/application/client/src/components/foundation/CoveredImage.tsx index 8ad9cc1f7d..9455660a6f 100644 --- a/application/client/src/components/foundation/CoveredImage.tsx +++ b/application/client/src/components/foundation/CoveredImage.tsx @@ -1,71 +1,26 @@ -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; } /** * アスペクト比を維持したまま、要素のコンテンツボックス全体を埋めるように画像を拡大縮小します */ -export const CoveredImage = ({ src }: Props) => { +export const CoveredImage = ({ alt, src }: 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} - /> +
+ {alt}