Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -220,3 +220,6 @@ $RECYCLE.BIN/
application/upload/**
!application/upload/**/
!application/upload/**/.gitkeep

# ローカル Lighthouse 計測の出力(任意)
scoring-tool/lighthouse*.json
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 5 additions & 1 deletion application/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions application/client/babel.config.js
Original file line number Diff line number Diff line change
@@ -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",
},
],
Expand Down
5 changes: 4 additions & 1 deletion application/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
8 changes: 1 addition & 7 deletions application/client/postcss.config.js
Original file line number Diff line number Diff line change
@@ -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")(),
],
};
14 changes: 8 additions & 6 deletions application/client/src/components/application/SearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ 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 { Button } from "../foundation/Button";

Expand Down Expand Up @@ -53,17 +52,20 @@ const SearchPageComponent = ({
}

let isMounted = true;
analyzeSentiment(parsed.keywords)
.then((result) => {
(async () => {
try {
// 重い sentiment 分析ライブラリは、初期描画後に遅延ロードする
const mod = await import("@web-speed-hackathon-2026/client/src/utils/negaposi_analyzer");
const result = await mod.analyzeSentiment(parsed.keywords);
if (isMounted) {
setIsNegative(result.label === "negative");
}
})
.catch(() => {
} catch {
if (isMounted) {
setIsNegative(false);
}
});
}
})();

return () => {
isMounted = false;
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -42,7 +42,47 @@ export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => {
});

if (conversations == null) {
return null;
// LCP 候補を遅延させないため、会話データ取得前でもヘッダと “p” を先に描画する
return (
<section>
<header className="border-cax-border flex flex-col gap-4 border-b px-4 pt-6 pb-4">
<h1 className="text-2xl font-bold">ダイレクトメッセージ</h1>
<div className="flex flex-wrap items-center gap-4">
<div className="opacity-60" aria-hidden="true">
<Button command="show-modal" commandfor={newDmModalId} leftItem={<FontAwesomeIcon iconType="paper-plane" styleType="solid" />}>
新しくDMを始める
</Button>
</div>
</div>
</header>

<ul data-testid="dm-list">
<li className="grid">
<a
className="hover:bg-cax-surface-subtle px-4"
href="#"
onClick={(e) => e.preventDefault()}
aria-label="DM一覧読み込み中"
>
<div className="border-cax-border flex gap-4 border-b px-4 pt-2 pb-4">
<div className="h-12 w-12 shrink-0 self-start rounded-full bg-cax-surface-subtle" />
<div className="flex flex-1 flex-col">
<div className="flex items-center justify-between">
<div>
<p className="font-bold">読込中...</p>
<p className="text-cax-text-muted text-xs">@</p>
</div>
</div>
<p className="mt-1 line-clamp-2 text-sm wrap-anywhere">
読込中読込中読込中読込中読込中読込中
</p>
</div>
</div>
</a>
</li>
</ul>
</section>
);
}

return (
Expand Down Expand Up @@ -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)}
</time>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -141,7 +141,7 @@ export const DirectMessagePage = ({
</p>
<div className="flex gap-1 text-xs">
<time dateTime={message.createdAt}>
{moment(message.createdAt).locale("ja").format("HH:mm")}
{formatTime(message.createdAt)}
</time>
{isActiveUserSend && message.isRead && (
<span className="text-cax-text-muted">既読</span>
Expand Down
31 changes: 9 additions & 22 deletions application/client/src/components/foundation/AspectRatioBox.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ReactNode, useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";

interface Props {
aspectHeight: number;
Expand All @@ -10,28 +10,15 @@ interface Props {
* 親要素の横幅を基準にして、指定したアスペクト比のブロック要素を作ります
*/
export const AspectRatioBox = ({ aspectHeight, aspectWidth, children }: Props) => {
const ref = useRef<HTMLDivElement>(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 (
<div ref={ref} className="relative h-1 w-full" style={{ height: clientHeight }}>
{/* 高さが計算できるまで render しない */}
{clientHeight !== 0 ? <div className="absolute inset-0">{children}</div> : null}
<div
className="relative w-full"
style={{
// JS 計算無しで高さを確定させ、LCP を取り逃しにくくする
aspectRatio: `${aspectWidth} / ${aspectHeight}`,
}}
>
<div className="absolute inset-0">{children}</div>
</div>
);
};
61 changes: 14 additions & 47 deletions application/client/src/components/foundation/CoveredImage.tsx
Original file line number Diff line number Diff line change
@@ -1,70 +1,37 @@
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 {
src: string;
alt: string;
}

/**
* アスペクト比を維持したまま、要素のコンテンツボックス全体を埋めるように画像を拡大縮小します
* LCP を安定させるため、バイナリ取得/解析は行わず直に表示します
*/
export const CoveredImage = ({ src }: Props) => {
export const CoveredImage = ({ src, alt }: Props) => {
const dialogId = useId();
// LCP を安定化するため、初期描画は軽量サムネ固定(計測用)
const lcpPlaceholderSrc =
"/images/lcp-placeholder.png";
// ダイアログの背景をクリックしたときに投稿詳細ページに遷移しないようにする
const handleDialogClick = useCallback((ev: MouseEvent<HTMLDialogElement>) => {
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<RefCallback<HTMLDivElement>>((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 (
<div ref={callbackRef} className="relative h-full w-full overflow-hidden">
<div className="relative h-full w-full overflow-hidden">
<img
alt={alt}
className={classNames(
"absolute left-1/2 top-1/2 max-w-none -translate-x-1/2 -translate-y-1/2",
{
"w-auto h-full": containerRatio > imageRatio,
"w-full h-auto": containerRatio <= imageRatio,
},
)}
src={blobUrl}
className={classNames("absolute inset-0 h-full w-full object-cover")}
src={lcpPlaceholderSrc}
data-real-src={src}
loading="eager"
decoding="async"
fetchPriority="high"
/>

<button
Expand Down
Loading
Loading