diff --git a/.dockerignore b/.dockerignore index da0b3191cc..a6018b6eaa 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,5 +2,4 @@ node_modules .git *.log -application/server/seeds application/server/upload diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..914c7b66ef --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +application/server/database.sqlite !text !filter !merge !diff diff --git a/Dockerfile b/Dockerfile index 2c95811428..3fadcf204c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,6 +23,8 @@ COPY ./application . RUN NODE_OPTIONS="--max-old-space-size=4096" pnpm build +RUN pnpm --filter @web-speed-hackathon-2026/server seed:insert + RUN --mount=type=cache,target=/pnpm/store CI=true pnpm install --frozen-lockfile --prod --filter @web-speed-hackathon-2026/server FROM base diff --git a/README.md b/README.md index 1ddb13e8ba..0448cb6eb9 100644 --- a/README.md +++ b/README.md @@ -56,3 +56,5 @@ https://github.com/CyberAgentHack/web-speed-hackathon-2026-scoring/issues/new?te - (Original Font) Source Han Serif JP: OFT 1.1 by Adobe http://www.adobe.com/ - Text - 太宰治『走れメロス』(1940年) + +deploy diff --git a/application/.gitignore b/application/.gitignore index 00b846e86b..c30953a31c 100644 --- a/application/.gitignore +++ b/application/.gitignore @@ -216,3 +216,6 @@ $RECYCLE.BIN/ *.lnk # End of https://www.toptal.com/developers/gitignore/api/node,macos,linux,windows + +.tanstack +.output diff --git a/application/client/index.html b/application/client/index.html new file mode 100644 index 0000000000..249815948b --- /dev/null +++ b/application/client/index.html @@ -0,0 +1,16 @@ + + + + + + + CaX + + + + +
+ + + + diff --git a/application/client/package.json b/application/client/package.json index 9f8e80a6a8..5167ea1b56 100644 --- a/application/client/package.json +++ b/application/client/package.json @@ -5,41 +5,29 @@ "license": "MPL-2.0", "author": "CyberAgent, Inc.", "scripts": { - "build": "NODE_ENV=development webpack", + "dev": "vite", + "build": "vite build", + "start:webpack": "webpack serve", + "build:webpack": "NODE_ENV=development 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", + "@fortawesome/fontawesome-free": "7.2.0", + "@tanstack/react-query": "5.91.3", + "@tanstack/react-router": "1.168.1", + "@tanstack/react-router-ssr-query": "1.166.10", "@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", + "date-fns": "4.1.0", "encoding-japanese": "2.2.0", "fast-average-color": "9.5.0", - "gifler": "github:themadcreator/gifler#v0.3.0", - "image-size": "2.0.2", - "jquery": "3.7.1", - "jquery-binarytransport": "1.0.0", - "json-repair-js": "1.0.0", "katex": "0.16.25", - "kuromoji": "0.1.2", - "langs": "2.0.0", - "lodash": "4.17.21", - "moment": "2.30.1", - "negaposi-analyzer-ja": "1.0.1", "normalize.css": "8.0.1", - "omggif": "1.0.10", - "pako": "2.1.0", - "piexifjs": "1.0.6", "react": "19.2.0", "react-dom": "19.2.0", - "react-helmet": "npm:@dr.pogodin/react-helmet@3.0.4", "react-redux": "9.2.0", "react-router": "7.9.4", "react-syntax-highlighter": "16.1.0", @@ -48,32 +36,26 @@ "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" + "remark-math": "6.0.0" }, "devDependencies": { "@babel/core": "7.28.4", "@babel/preset-env": "7.28.3", "@babel/preset-react": "7.27.1", "@babel/preset-typescript": "7.27.1", + "@rolldown/plugin-babel": "0.2.2", + "@tailwindcss/vite": "4.2.2", + "@tanstack/router-plugin": "1.167.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", + "@vitejs/plugin-react": "6.0.1", "babel-loader": "10.0.0", + "baseline-browser-mapping": "2.10.9", "copy-webpack-plugin": "13.0.1", "css-loader": "7.1.2", "html-webpack-plugin": "5.6.4", @@ -83,7 +65,11 @@ "postcss-loader": "8.2.0", "postcss-preset-env": "10.4.0", "react-markdown": "10.1.0", + "rollup-plugin-visualizer": "7.0.1", + "tailwindcss": "4.2.2", "typescript": "5.9.3", + "vite": "8.0.1", + "vite-bundle-analyzer": "1.3.6", "webpack": "5.102.1", "webpack-cli": "6.0.1", "webpack-dev-server": "5.2.2" @@ -91,5 +77,8 @@ "engines": { "node": "24.14.0" }, + "volta": { + "node": "24.14.0" + }, "packageManager": "pnpm@10.32.1" } diff --git a/application/client/src/auth/validation.ts b/application/client/src/auth/validation.ts index 2a83bbfb15..7568f9761c 100644 --- a/application/client/src/auth/validation.ts +++ b/application/client/src/auth/validation.ts @@ -1,6 +1,6 @@ -import { FormErrors } from "redux-form"; +import type { FormErrors } from "redux-form"; -import { AuthFormData } from "@web-speed-hackathon-2026/client/src/auth/types"; +import type { AuthFormData } from "@web-speed-hackathon-2026/client/src/auth/types"; export const validate = (values: AuthFormData): FormErrors => { const errors: FormErrors = {}; @@ -21,7 +21,8 @@ export const validate = (values: AuthFormData): FormErrors => { } if (!/^[a-zA-Z0-9_]*$/.test(normalizedUsername)) { - errors.username = "ユーザー名に使用できるのは英数字とアンダースコア(_)のみです"; + errors.username = + "ユーザー名に使用できるのは英数字とアンダースコア(_)のみです"; } if (normalizedUsername.length === 0) { errors.username = "ユーザー名を入力してください"; diff --git a/application/client/src/buildinfo.ts b/application/client/src/buildinfo.ts deleted file mode 100644 index 48b5dbef9b..0000000000 --- a/application/client/src/buildinfo.ts +++ /dev/null @@ -1,14 +0,0 @@ -declare global { - var __BUILD_INFO__: { - BUILD_DATE: string | undefined; - COMMIT_HASH: string | undefined; - }; -} - -/** @note 競技用サーバーで参照します。可能な限りコード内に含めてください */ -window.__BUILD_INFO__ = { - BUILD_DATE: process.env["BUILD_DATE"], - COMMIT_HASH: process.env["COMMIT_HASH"], -}; - -export {}; diff --git a/application/client/src/components/application/AccountMenu.tsx b/application/client/src/components/application/AccountMenu.tsx index b6df12bbab..edc6e48f99 100644 --- a/application/client/src/components/application/AccountMenu.tsx +++ b/application/client/src/components/application/AccountMenu.tsx @@ -43,8 +43,12 @@ export const AccountMenu = ({ user, onLogout }: Props) => { src={getProfileImagePath(user.profileImage.id)} />
-
{user.name}
-
@{user.username}
+
+ {user.name} +
+
+ @{user.username} +
··· diff --git a/application/client/src/components/application/AppPage.tsx b/application/client/src/components/application/AppPage.tsx index 525daa5380..ef07e8b1e0 100644 --- a/application/client/src/components/application/AppPage.tsx +++ b/application/client/src/components/application/AppPage.tsx @@ -3,24 +3,15 @@ import type { ReactNode } from "react"; import { Navigation } from "@web-speed-hackathon-2026/client/src/components/application/Navigation"; interface Props { - activeUser: Models.User | null; children: ReactNode; - authModalId: string; - newPostModalId: string; - onLogout: () => void; } -export const AppPage = ({ activeUser, children, authModalId, newPostModalId, onLogout }: Props) => { +export const AppPage = ({ children }: Props) => { return (
{children} diff --git a/application/client/src/components/application/Navigation.tsx b/application/client/src/components/application/Navigation.tsx index e2332b511b..2edbbe3c96 100644 --- a/application/client/src/components/application/Navigation.tsx +++ b/application/client/src/components/application/Navigation.tsx @@ -1,78 +1,167 @@ import { AccountMenu } from "@web-speed-hackathon-2026/client/src/components/application/AccountMenu"; -import { NavigationItem } from "@web-speed-hackathon-2026/client/src/components/application/NavigationItem"; +import { + NavigationItem, + NavigationItemButton, +} from "@web-speed-hackathon-2026/client/src/components/application/NavigationItem"; import { DirectMessageNotificationBadge } from "@web-speed-hackathon-2026/client/src/components/direct_message/DirectMessageNotificationBadge"; import { CrokLogo } from "@web-speed-hackathon-2026/client/src/components/foundation/CrokLogo"; -import { FontAwesomeIcon } from "@web-speed-hackathon-2026/client/src/components/foundation/FontAwesomeIcon"; +import { AUTH_MODAL_ID, NEW_POST_MODAL_ID } from "../../utils/constants"; +import { useCallback } from "react"; +import { sendJSON } from "../../utils/fetchers"; +import { useQuery } from "@tanstack/react-query"; +import { authQueryOptions } from "../../query/auth"; +import { useNavigate } from "@tanstack/react-router"; +import { NewPostModalContainer } from "../../containers/NewPostModalContainer"; +import { AuthModalContainer } from "../../containers/AuthModalContainer"; -interface Props { - activeUser: Models.User | null; - authModalId: string; - newPostModalId: string; - onLogout: () => void; -} +export const Navigation = () => { + const { data: activeUser, refetch } = useQuery(authQueryOptions); + + const navigate = useNavigate(); + const handleLogout = useCallback(async () => { + await sendJSON("/api/v1/signout", {}); + refetch(); + }, [navigate]); -export const Navigation = ({ activeUser, authModalId, newPostModalId, onLogout }: Props) => { return ( - + {activeUser != null ? ( + + ) : null} +
+ + + refetch()} /> + ); }; diff --git a/application/client/src/components/application/NavigationItem.tsx b/application/client/src/components/application/NavigationItem.tsx index 57e64b004a..b5c34410cd 100644 --- a/application/client/src/components/application/NavigationItem.tsx +++ b/application/client/src/components/application/NavigationItem.tsx @@ -1,50 +1,66 @@ -import classNames from "classnames"; -import { useLocation } from "react-router"; - import { Link } from "@web-speed-hackathon-2026/client/src/components/foundation/Link"; +import type { ComponentProps } from "react"; +import { NewPostModalContainer } from "../../containers/NewPostModalContainer"; -interface Props { +type Props = ComponentProps & { + badge?: React.ReactNode; + icon: React.ReactNode; + text: string; +}; + +export const NavigationItem = ({ badge, icon, text, ...props }: Props) => { + return ( +
  • + + + {icon} + {badge} + + + {text} + + +
  • + ); +}; + +type NavigationItemButtonProps = { badge?: React.ReactNode; icon: React.ReactNode; text: string; - href?: string; command?: string; commandfor?: string; -} +}; -export const NavigationItem = ({ badge, href, icon, command, commandfor, text }: Props) => { - const location = useLocation(); - const isActive = location.pathname === href; +export const NavigationItemButton = ({ + badge, + icon, + command, + commandfor, + text, +}: NavigationItemButtonProps) => { return (
  • - {href !== undefined ? ( - - - {icon} - {badge} - - {text} - - ) : ( - - )} +
  • ); }; diff --git a/application/client/src/components/application/SearchPage.tsx b/application/client/src/components/application/SearchPage.tsx index e99045de45..0bc2006cb6 100644 --- a/application/client/src/components/application/SearchPage.tsx +++ b/application/client/src/components/application/SearchPage.tsx @@ -1,19 +1,26 @@ -import { useEffect, useMemo, useState } from "react"; -import { useNavigate } from "react-router"; -import { Field, InjectedFormProps, reduxForm, WrappedFieldProps } from "redux-form"; +import { useMemo, useState } from "react"; +import { + Field, + type InjectedFormProps, + reduxForm, + type WrappedFieldProps, +} from "redux-form"; import { Timeline } from "@web-speed-hackathon-2026/client/src/components/timeline/Timeline"; import { parseSearchQuery, sanitizeSearchText, } from "@web-speed-hackathon-2026/client/src/search/services"; -import { SearchFormData } from "@web-speed-hackathon-2026/client/src/search/types"; +import { type 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 { Button } from "../foundation/Button"; +import { useDebounceEffect } from "../../hooks/use_debounce_effect"; +import { fetchNegaposi } from "../../utils/fetch_with_cache"; +import { useNavigate } from "@tanstack/react-router"; interface Props { + total: number; query: string; results: Models.Post[]; } @@ -39,36 +46,42 @@ const SearchInput = ({ input, meta }: WrappedFieldProps) => ( const SearchPageComponent = ({ query, results, + total, handleSubmit, }: Props & InjectedFormProps) => { const navigate = useNavigate(); const [isNegative, setIsNegative] = useState(false); const parsed = parseSearchQuery(query); - - useEffect(() => { - if (!parsed.keywords) { - setIsNegative(false); - return; - } - - let isMounted = true; - analyzeSentiment(parsed.keywords) - .then((result) => { - if (isMounted) { - setIsNegative(result.label === "negative"); - } - }) - .catch(() => { - if (isMounted) { - setIsNegative(false); - } - }); - - return () => { - isMounted = false; - }; - }, [parsed.keywords]); + const keywords = parsed.keywords; + + useDebounceEffect( + () => { + if (keywords == null || keywords.trim().length === 0) { + setIsNegative(false); + return; + } + + let isMounted = true; + fetchNegaposi(keywords) + .then((result) => { + if (isMounted) { + setIsNegative(result.label === "negative"); + } + }) + .catch(() => { + if (isMounted) { + setIsNegative(false); + } + }); + + return () => { + isMounted = false; + }; + }, + 100, + [keywords], + ); const searchConditionText = useMemo(() => { const parts: string[] = []; @@ -86,7 +99,12 @@ const SearchPageComponent = ({ const onSubmit = (values: SearchFormData) => { const sanitizedText = sanitizeSearchText(values.searchText.trim()); - navigate(`/search?q=${encodeURIComponent(sanitizedText)}`); + navigate({ + to: "/search", + search: { + q: sanitizedText, + }, + }); }; return ( @@ -108,7 +126,7 @@ const SearchPageComponent = ({ {query && (

    - {searchConditionText} の検索結果 ({results.length} 件) + {searchConditionText} の検索結果 ({total} 件)

    )} @@ -117,8 +135,12 @@ const SearchPageComponent = ({
    -

    どしたん話聞こうか?

    -

    言わなくてもいいけど、言ってもいいよ。

    +

    + どしたん話聞こうか? +

    +

    + 言わなくてもいいけど、言ってもいいよ。 +

    diff --git a/application/client/src/components/auth_modal/AuthModalPage.tsx b/application/client/src/components/auth_modal/AuthModalPage.tsx index 08996f9afd..abecb27a3b 100644 --- a/application/client/src/components/auth_modal/AuthModalPage.tsx +++ b/application/client/src/components/auth_modal/AuthModalPage.tsx @@ -1,7 +1,12 @@ import { useSelector } from "react-redux"; -import { Field, formValueSelector, InjectedFormProps, reduxForm } from "redux-form"; +import { + Field, + formValueSelector, + type InjectedFormProps, + reduxForm, +} from "redux-form"; -import { AuthFormData } from "@web-speed-hackathon-2026/client/src/auth/types"; +import type { AuthFormData } from "@web-speed-hackathon-2026/client/src/auth/types"; import { validate } from "@web-speed-hackathon-2026/client/src/auth/validation"; import { FormInputField } from "@web-speed-hackathon-2026/client/src/components/foundation/FormInputField"; import { Link } from "@web-speed-hackathon-2026/client/src/components/foundation/Link"; @@ -36,7 +41,9 @@ const AuthModalPageComponent = ({
    {type === "signup" ? (

    - + 利用規約 に同意して diff --git a/application/client/src/components/crok/ChatInput.tsx b/application/client/src/components/crok/ChatInput.tsx index 6f8c17796b..a629cc7fed 100644 --- a/application/client/src/components/crok/ChatInput.tsx +++ b/application/client/src/components/crok/ChatInput.tsx @@ -1,7 +1,4 @@ -import Bluebird from "bluebird"; -import kuromoji, { type Tokenizer, type IpadicFeatures } from "kuromoji"; import { - useEffect, useLayoutEffect, useRef, useState, @@ -9,13 +6,8 @@ import { type FormEvent, type KeyboardEvent, } 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"; +import { useDebounceEffect } from "../../hooks/use_debounce_effect"; +import { fetchSuggestions } from "../../utils/fetch_with_cache"; interface Props { isStreaming: boolean; @@ -23,7 +15,10 @@ interface Props { } // トークン単位でハイライト -function highlightMatchByTokens(text: string, queryTokens: string[]): React.ReactNode { +function highlightMatchByTokens( + text: string, + queryTokens: string[], +): React.ReactNode { if (queryTokens.length === 0) return text; const lowerText = text.toLowerCase(); @@ -79,7 +74,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,79 +86,42 @@ 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); - }; - - void updateSuggestions(); - - return () => { - cancelled = true; - }; - }, [inputValue, tokenizer]); - - const adjustTextareaHeight = () => { - const textarea = textareaRef.current; - if (textarea) { - textarea.style.height = "auto"; - textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`; - } - }; - - const resetTextareaHeight = () => { - if (textareaRef.current) { - textareaRef.current.style.height = "auto"; - } - }; + useDebounceEffect( + () => { + let cancelled = false; + + const updateSuggestions = async () => { + if (!inputValue.trim()) { + setSuggestions([]); + setQueryTokens([]); + setShowSuggestions(false); + return; + } + + const { suggestions: candidates, tokens } = + await fetchSuggestions(inputValue); + if (cancelled) { + return; + } + + setQueryTokens(tokens); + setSuggestions(candidates); + setShowSuggestions(candidates.length > 0); + }; + + void updateSuggestions(); + + return () => { + cancelled = true; + }; + }, + 100, + [inputValue], + ); const handleInputChange = (e: ChangeEvent) => { const value = e.target.value; setInputValue(value); - adjustTextareaHeight(); }; const handleSuggestionClick = (suggestion: string) => { @@ -182,7 +139,6 @@ export const ChatInput = ({ isStreaming, onSendMessage }: Props) => { setSuggestions([]); setQueryTokens([]); setShowSuggestions(false); - resetTextareaHeight(); } }; @@ -218,7 +174,7 @@ export const ChatInput = ({ isStreaming, onSendMessage }: Props) => {