diff --git a/.gitignore b/.gitignore index c9fdd6f0b0..ab2810b1e8 100644 --- a/.gitignore +++ b/.gitignore @@ -220,3 +220,6 @@ $RECYCLE.BIN/ application/upload/** !application/upload/**/ !application/upload/**/.gitkeep + +# ローカル Lighthouse 計測の出力(任意) +scoring-tool/lighthouse*.json diff --git a/Dockerfile b/Dockerfile index 2c95811428..4c48a1d373 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,7 @@ RUN --mount=type=cache,target=/pnpm/store pnpm install --frozen-lockfile COPY ./application . -RUN NODE_OPTIONS="--max-old-space-size=4096" pnpm build +RUN NODE_OPTIONS="--max-old-space-size=4096" NODE_ENV=production pnpm build RUN --mount=type=cache,target=/pnpm/store CI=true pnpm install --frozen-lockfile --prod --filter @web-speed-hackathon-2026/server diff --git a/README.md b/README.md index 854d089d1d..76658b55c5 100644 --- a/README.md +++ b/README.md @@ -55,4 +55,4 @@ https://github.com/CyberAgentHack/web-speed-hackathon-2026/issues/new?template=a - 例のアレ明朝: OFL 1.1 by CyberAgent, Inc. - (Original Font) Source Han Serif JP: OFT 1.1 by Adobe http://www.adobe.com/ - Text - - 太宰治『走れメロス』(1940年) + - 太宰治『走れメロス』(1940年) \ No newline at end of file diff --git a/application/README.md b/application/README.md index f03680422d..9758dfa1af 100644 --- a/application/README.md +++ b/application/README.md @@ -14,10 +14,14 @@ CaX のアプリケーションコードです。 ### ビルド・起動 -1. アプリケーションをビルドします +1. アプリケーションをビルドします(本番相当の最適化。Docker ビルドと同じ `NODE_ENV=production` です) - ```bash pnpm run build ``` + - 以前の「非最小化・`mode: none`」に近いビルドが必要な場合のみ(デバッグ用): + - ```bash + pnpm --filter @web-speed-hackathon-2026/client run build:dev + ``` 2. サーバーを起動します - ```bash pnpm run start diff --git a/application/client/babel.config.js b/application/client/babel.config.js index c3c574591a..e2d3b43018 100644 --- a/application/client/babel.config.js +++ b/application/client/babel.config.js @@ -1,19 +1,22 @@ +const isProduction = process.env.NODE_ENV === "production"; + module.exports = { presets: [ ["@babel/preset-typescript"], [ "@babel/preset-env", { - targets: "ie 11", + // 本番はレギュレーションどおり最新 Chrome 想定。modules:false で webpack が import() 分割できるようにする + targets: isProduction ? { chrome: "120" } : "ie 11", corejs: "3", - modules: "commonjs", + modules: isProduction ? false : "commonjs", useBuiltIns: false, }, ], [ "@babel/preset-react", { - development: true, + development: !isProduction, runtime: "automatic", }, ], diff --git a/application/client/package.json b/application/client/package.json index 9f8e80a6a8..2014e72189 100644 --- a/application/client/package.json +++ b/application/client/package.json @@ -5,7 +5,8 @@ "license": "MPL-2.0", "author": "CyberAgent, Inc.", "scripts": { - "build": "NODE_ENV=development webpack", + "build": "NODE_ENV=production webpack", + "build:dev": "NODE_ENV=development webpack", "typecheck": "tsc" }, "dependencies": { @@ -57,6 +58,7 @@ "@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", @@ -83,6 +85,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..21c397a6ff 100644 --- a/application/client/postcss.config.js +++ b/application/client/postcss.config.js @@ -1,11 +1,5 @@ -const postcssImport = require("postcss-import"); -const postcssPresetEnv = require("postcss-preset-env"); - module.exports = { plugins: [ - postcssImport(), - postcssPresetEnv({ - stage: 3, - }), + require("@tailwindcss/postcss")(), ], }; diff --git a/application/client/src/components/application/NavigationItem.tsx b/application/client/src/components/application/NavigationItem.tsx index 57e64b004a..f1a0cde808 100644 --- a/application/client/src/components/application/NavigationItem.tsx +++ b/application/client/src/components/application/NavigationItem.tsx @@ -1,7 +1,9 @@ import classNames from "classnames"; +import { MouseEventHandler, useCallback } from "react"; import { useLocation } from "react-router"; import { Link } from "@web-speed-hackathon-2026/client/src/components/foundation/Link"; +import { showDialog } from "@web-speed-hackathon-2026/client/src/utils/dialog"; interface Props { badge?: React.ReactNode; @@ -15,10 +17,17 @@ interface Props { export const NavigationItem = ({ badge, href, icon, command, commandfor, text }: Props) => { const location = useLocation(); const isActive = location.pathname === href; + const handleClick = useCallback>(() => { + if (command === "show-modal" && typeof commandfor === "string") { + showDialog(commandfor); + } + }, [command, commandfor]); + return (
  • {href !== undefined ? ( ) : ( diff --git a/application/client/src/components/crok/CrokPage.tsx b/application/client/src/components/crok/CrokPage.tsx index 0be7678f84..75f38e24f0 100644 --- a/application/client/src/components/crok/CrokPage.tsx +++ b/application/client/src/components/crok/CrokPage.tsx @@ -28,7 +28,11 @@ export const CrokPage = ({ messages, isStreaming, onSendMessage }: Props) => { {messages.length === 0 && } {messages.map((message, index) => ( - + ))}
    diff --git a/application/client/src/components/crok/SimpleMarkdown.tsx b/application/client/src/components/crok/SimpleMarkdown.tsx new file mode 100644 index 0000000000..be77509d99 --- /dev/null +++ b/application/client/src/components/crok/SimpleMarkdown.tsx @@ -0,0 +1,146 @@ +import { Fragment, type ReactNode } from "react"; + +import { CodeBlock } from "@web-speed-hackathon-2026/client/src/components/crok/CodeBlock"; + +type MarkdownBlock = + | { type: "paragraph"; text: string } + | { type: "heading"; text: string } + | { type: "list"; items: string[] } + | { type: "code"; code: string; language: string }; + +function parseMarkdown(content: string): MarkdownBlock[] { + const lines = content.replace(/\r\n?/g, "\n").split("\n"); + const blocks: MarkdownBlock[] = []; + + for (let index = 0; index < lines.length; ) { + const line = lines[index] ?? ""; + const trimmed = line.trim(); + + if (trimmed === "") { + index += 1; + continue; + } + + if (trimmed.startsWith("```")) { + const language = trimmed.slice(3).trim(); + const codeLines: string[] = []; + index += 1; + + while (index < lines.length && !(lines[index] ?? "").trim().startsWith("```")) { + codeLines.push(lines[index] ?? ""); + index += 1; + } + + if (index < lines.length) { + index += 1; + } + + blocks.push({ + type: "code", + code: codeLines.join("\n"), + language, + }); + continue; + } + + if (trimmed.startsWith("## ")) { + blocks.push({ type: "heading", text: trimmed.slice(3).trim() }); + index += 1; + continue; + } + + if (trimmed.startsWith("- ")) { + const items: string[] = []; + while (index < lines.length) { + const item = (lines[index] ?? "").trim(); + if (!item.startsWith("- ")) { + break; + } + items.push(item.slice(2).trim()); + index += 1; + } + blocks.push({ type: "list", items }); + continue; + } + + const paragraphLines: string[] = []; + while (index < lines.length) { + const paragraph = lines[index] ?? ""; + const paragraphTrimmed = paragraph.trim(); + if ( + paragraphTrimmed === "" || + paragraphTrimmed.startsWith("## ") || + paragraphTrimmed.startsWith("- ") || + paragraphTrimmed.startsWith("```") + ) { + break; + } + paragraphLines.push(paragraph); + index += 1; + } + + blocks.push({ + type: "paragraph", + text: paragraphLines.join("\n"), + }); + } + + return blocks; +} + +function renderInline(text: string): ReactNode { + return text.split(/(`[^`]+`)/g).map((segment, index) => { + if (segment.startsWith("`") && segment.endsWith("`") && segment.length >= 2) { + return ( + + {segment.slice(1, -1)} + + ); + } + + return {segment}; + }); +} + +export const SimpleMarkdown = ({ content }: { content: string }) => { + return parseMarkdown(content).map((block, index) => { + if (block.type === "heading") { + return ( +

    + {renderInline(block.text)} +

    + ); + } + + if (block.type === "list") { + return ( +
      + {block.items.map((item, itemIndex) => ( +
    • + {renderInline(item)} +
    • + ))} +
    + ); + } + + if (block.type === "code") { + return ( + + + {block.code} + + + ); + } + + return ( +

    + {renderInline(block.text)} +

    + ); + }); +}; diff --git a/application/client/src/components/crok/SyntaxHighlighterLoader.tsx b/application/client/src/components/crok/SyntaxHighlighterLoader.tsx new file mode 100644 index 0000000000..4c0b6be3b5 --- /dev/null +++ b/application/client/src/components/crok/SyntaxHighlighterLoader.tsx @@ -0,0 +1,19 @@ +import type { CSSProperties } from "react"; +import SyntaxHighlighter from "react-syntax-highlighter"; +import { atomOneLight } from "react-syntax-highlighter/dist/esm/styles/hljs"; + +type Props = { + children: string; + customStyle?: CSSProperties; + language: string; +}; + +const SyntaxHighlighterLoader = ({ children, customStyle, language }: Props) => { + return ( + + {children} + + ); +}; + +export default SyntaxHighlighterLoader; diff --git a/application/client/src/components/direct_message/DirectMessageGate.tsx b/application/client/src/components/direct_message/DirectMessageGate.tsx index effa57979c..6c19cc481a 100644 --- a/application/client/src/components/direct_message/DirectMessageGate.tsx +++ b/application/client/src/components/direct_message/DirectMessageGate.tsx @@ -1,5 +1,7 @@ import { Helmet } from "react-helmet"; +import { showDialog } from "@web-speed-hackathon-2026/client/src/utils/dialog"; + interface Props { headline: string; description?: string; @@ -23,9 +25,10 @@ export const DirectMessageGate = ({ {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..4f8e59c400 100644 --- a/application/client/src/components/direct_message/DirectMessageListPage.tsx +++ b/application/client/src/components/direct_message/DirectMessageListPage.tsx @@ -1,4 +1,4 @@ -import moment from "moment"; +import { fromNow } from "@web-speed-hackathon-2026/client/src/utils/date_utils"; import { useCallback, useEffect, useState } from "react"; import { Button } from "@web-speed-hackathon-2026/client/src/components/foundation/Button"; @@ -42,7 +42,47 @@ export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => { }); if (conversations == null) { - return null; + // LCP 候補を遅延させないため、会話データ取得前でもヘッダと “p” を先に描画する + return ( +
    +
    +

    ダイレクトメッセージ

    +
    + +
    +
    + + +
    + ); } return ( @@ -100,7 +140,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..600ee0402b 100644 --- a/application/client/src/components/direct_message/DirectMessagePage.tsx +++ b/application/client/src/components/direct_message/DirectMessagePage.tsx @@ -1,5 +1,5 @@ import classNames from "classnames"; -import moment from "moment"; +import { formatTime } from "@web-speed-hackathon-2026/client/src/utils/date_utils"; import { ChangeEvent, useCallback, @@ -43,7 +43,6 @@ export const DirectMessagePage = ({ const [text, setText] = useState(""); const textAreaRows = Math.min((text || "").split("\n").length, 5); const isInvalid = text.trim().length === 0; - const scrollHeightRef = useRef(0); const handleChange = useCallback( (event: ChangeEvent) => { @@ -74,16 +73,21 @@ export const DirectMessagePage = ({ ); useEffect(() => { - const id = setInterval(() => { - const height = Number(window.getComputedStyle(document.body).height.replace("px", "")); - if (height !== scrollHeightRef.current) { - scrollHeightRef.current = height; - window.scrollTo(0, height); - } - }, 1); - - return () => clearInterval(id); - }, []); + let raf = 0; + const scrollToBottom = () => { + cancelAnimationFrame(raf); + raf = requestAnimationFrame(() => { + window.scrollTo(0, document.documentElement.scrollHeight); + }); + }; + const ro = new ResizeObserver(scrollToBottom); + ro.observe(document.body); + scrollToBottom(); + return () => { + cancelAnimationFrame(raf); + ro.disconnect(); + }; + }, [conversation.messages.length]); if (conversationError != null) { return ( @@ -141,7 +145,7 @@ export const DirectMessagePage = ({

    {isActiveUserSend && message.isRead && ( 既読 diff --git a/application/client/src/components/foundation/AspectRatioBox.tsx b/application/client/src/components/foundation/AspectRatioBox.tsx index 0ae891963c..fcf890ee7f 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 type { ReactNode } from "react"; interface Props { aspectHeight: number; @@ -10,28 +10,15 @@ interface Props { * 親要素の横幅を基準にして、指定したアスペクト比のブロック要素を作ります */ export const AspectRatioBox = ({ aspectHeight, aspectWidth, children }: Props) => { - const ref = useRef(null); - const [clientHeight, setClientHeight] = useState(0); - - useEffect(() => { - // clientWidth とアスペクト比から clientHeight を計算する - function calcStyle() { - const clientWidth = ref.current?.clientWidth ?? 0; - setClientHeight((clientWidth / aspectWidth) * aspectHeight); - } - setTimeout(() => calcStyle(), 500); - - // ウィンドウサイズが変わるたびに計算する - window.addEventListener("resize", calcStyle, { passive: false }); - return () => { - window.removeEventListener("resize", calcStyle); - }; - }, [aspectHeight, aspectWidth]); - return ( -
    - {/* 高さが計算できるまで render しない */} - {clientHeight !== 0 ?
    {children}
    : null} +
    +
    {children}
    ); }; diff --git a/application/client/src/components/foundation/Button.tsx b/application/client/src/components/foundation/Button.tsx index 0d29d74fbb..3275a69d5d 100644 --- a/application/client/src/components/foundation/Button.tsx +++ b/application/client/src/components/foundation/Button.tsx @@ -1,5 +1,7 @@ import classNames from "classnames"; -import { ComponentPropsWithRef, ReactNode } from "react"; +import { ComponentPropsWithRef, MouseEventHandler, ReactNode, useCallback } from "react"; + +import { closeDialog, showDialog } from "@web-speed-hackathon-2026/client/src/utils/dialog"; interface Props extends ComponentPropsWithRef<"button"> { variant?: "primary" | "secondary"; @@ -13,8 +15,27 @@ export const Button = ({ rightItem, className, children, + command, + commandfor, + onClick, ...props }: Props) => { + const handleClick = useCallback>( + (event) => { + onClick?.(event); + if (event.defaultPrevented || typeof commandfor !== "string") { + return; + } + + if (command === "show-modal") { + showDialog(commandfor); + } else if (command === "close") { + closeDialog(commandfor); + } + }, + [command, commandfor, onClick], + ); + return ( diff --git a/application/client/src/components/foundation/InfiniteScroll.tsx b/application/client/src/components/foundation/InfiniteScroll.tsx index 408f24c107..fa94992db5 100644 --- a/application/client/src/components/foundation/InfiniteScroll.tsx +++ b/application/client/src/components/foundation/InfiniteScroll.tsx @@ -13,10 +13,8 @@ export const InfiniteScroll = ({ children, fetchMore, items }: Props) => { useEffect(() => { const handler = () => { - // 念の為 2の18乗 回、最下部かどうかを確認する - const hasReached = Array.from(Array(2 ** 18), () => { - return window.innerHeight + Math.ceil(window.scrollY) >= document.body.offsetHeight; - }).every(Boolean); + const hasReached = + window.innerHeight + Math.ceil(window.scrollY) >= document.body.offsetHeight; // 画面最下部にスクロールしたタイミングで、登録したハンドラを呼び出す if (hasReached && !prevReachedRef.current) { @@ -33,10 +31,10 @@ export const InfiniteScroll = ({ children, fetchMore, items }: Props) => { prevReachedRef.current = false; handler(); - document.addEventListener("wheel", handler, { passive: false }); - document.addEventListener("touchmove", handler, { passive: false }); - document.addEventListener("resize", handler, { passive: false }); - document.addEventListener("scroll", handler, { passive: false }); + document.addEventListener("wheel", handler, { passive: true }); + document.addEventListener("touchmove", handler, { passive: true }); + document.addEventListener("resize", handler, { passive: true }); + document.addEventListener("scroll", handler, { passive: true }); return () => { document.removeEventListener("wheel", handler); document.removeEventListener("touchmove", handler); diff --git a/application/client/src/components/foundation/PausableMovie.tsx b/application/client/src/components/foundation/PausableMovie.tsx index 98b0df55b0..e84638c487 100644 --- a/application/client/src/components/foundation/PausableMovie.tsx +++ b/application/client/src/components/foundation/PausableMovie.tsx @@ -1,12 +1,8 @@ import classNames from "classnames"; -import { Animator, Decoder } from "gifler"; -import { GifReader } from "omggif"; -import { RefCallback, useCallback, useRef, useState } from "react"; +import { useState } from "react"; import { AspectRatioBox } from "@web-speed-hackathon-2026/client/src/components/foundation/AspectRatioBox"; import { FontAwesomeIcon } from "@web-speed-hackathon-2026/client/src/components/foundation/FontAwesomeIcon"; -import { useFetch } from "@web-speed-hackathon-2026/client/src/hooks/use_fetch"; -import { fetchBinary } from "@web-speed-hackathon-2026/client/src/utils/fetchers"; interface Props { src: string; @@ -16,64 +12,25 @@ interface Props { * クリックすると再生・一時停止を切り替えます。 */ export const PausableMovie = ({ src }: Props) => { - const { data, isLoading } = useFetch(src, fetchBinary); - - const animatorRef = useRef(null); - const canvasCallbackRef = useCallback>( - (el) => { - animatorRef.current?.stop(); - - if (el === null || data === null) { - return; - } - - // GIF を解析する - const reader = new GifReader(new Uint8Array(data)); - const frames = Decoder.decodeFramesSync(reader); - const animator = new Animator(reader, frames); - - animator.animateInCanvas(el); - animator.onFrame(frames[0]!); - - // 視覚効果 off のとき GIF を自動再生しない - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { - setIsPlaying(false); - animator.stop(); - } else { - setIsPlaying(true); - animator.start(); - } - - animatorRef.current = animator; - }, - [data], - ); - const [isPlaying, setIsPlaying] = useState(true); - const handleClick = useCallback(() => { - setIsPlaying((isPlaying) => { - if (isPlaying) { - animatorRef.current?.stop(); - } else { - animatorRef.current?.start(); - } - return !isPlaying; - }); - }, []); - - if (isLoading || data === null) { - return null; - } return (