Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
08f3efb
セットアップ
tossyhal Mar 20, 2026
e19255b
perf(client): enable webpack production optimization for T01
tossyhal Mar 20, 2026
ea300ab
perf(client): apply phase0 T02-T03 bundle optimization
tossyhal Mar 20, 2026
9cf4e00
fix(client): apply review fixes for tailwind utility and chunk split
tossyhal Mar 20, 2026
f336efc
perf(media): migrate GIF/JPEG assets to MP4/WebP in phase0
tossyhal Mar 20, 2026
42f8d8d
perf(client): complete phase0 dependency and runtime slimming
tossyhal Mar 20, 2026
1a58dec
docs(plan): record phase0 implementation progress
tossyhal Mar 20, 2026
47ad0d7
perf(client): address phase0 review blockers and lazy-load heavy libs
tossyhal Mar 20, 2026
07ce43e
docs(plan): update phase0 improvement plan details
tossyhal Mar 20, 2026
5ff4c9d
docs(plan): update phase roadmap to v2
tossyhal Mar 20, 2026
1f1380c
perf(client): implement phase1 bundle-size reducers
tossyhal Mar 20, 2026
1cee87b
docs(plan): record phase1 implementation entries
tossyhal Mar 20, 2026
483a4ed
perf(client): fix FCP by enabling immediate render and proper chunk i…
tossyhal Mar 20, 2026
38ad503
perf(server): complete phase2 caching and compression
tossyhal Mar 20, 2026
906bc47
perf(client): lazy-load auth modal for phase3
tossyhal Mar 20, 2026
cf85a97
perf(client): replace CoveredImage blob flow with direct img rendering
tossyhal Mar 20, 2026
3c09b14
perf(client): optimize infinite scroll reach detection
tossyhal Mar 20, 2026
1fddcb7
fix(search): stabilize result heading wording for keyword queries
tossyhal Mar 20, 2026
8dd2772
docs(plan): record cancellation of phase4 font optimization attempt
tossyhal Mar 20, 2026
811ae11
docs(plan): record cache-header cancellation after home regression
tossyhal Mar 20, 2026
221388a
docs(plan): update improvement roadmap to v3
tossyhal Mar 20, 2026
48405f0
fix(client): implement step1 flow stability updates
tossyhal Mar 20, 2026
2b08b31
fix(client): preserve structured auth errors
tossyhal Mar 20, 2026
d27cca3
fix(client): stabilize DM, search, and movie flows
tossyhal Mar 20, 2026
536b3e4
fix(client): normalize search query sanitization
tossyhal Mar 20, 2026
e120922
chore(e2e): take initial VRT baseline screenshots
tossyhal Mar 21, 2026
efbf268
Merge remote-tracking branch 'upstream/main'
tossyhal Mar 21, 2026
4976ee3
chore(vrt): stabilize UI for VRT pass and provisional submission
tossyhal Mar 21, 2026
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
7 changes: 3 additions & 4 deletions application/client/babel.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
],
Expand Down
21 changes: 3 additions & 18 deletions application/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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",
Expand All @@ -45,29 +35,23 @@
"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": {
"@babel/core": "7.28.4",
"@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",
Expand All @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions application/client/postcss.config.js
Original file line number Diff line number Diff line change
@@ -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,
}),
Expand Down
68 changes: 43 additions & 25 deletions application/client/src/components/application/SearchPage.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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) => (
<div className="flex flex-1 flex-col">
<input
{...input}
className={`flex-1 rounded border px-4 py-2 focus:outline-none ${
meta.touched && meta.error
? "border-cax-danger focus:border-cax-danger"
: "border-cax-border focus:border-cax-brand-strong"
}`}
placeholder="検索 (例: キーワード since:2025-01-01 until:2025-12-31)"
type="text"
/>
{meta.touched && meta.error && (
<span className="text-cax-danger mt-1 text-xs">{meta.error}</span>
)}
</div>
);
const SearchInput = ({ input, meta, extraError }: WrappedFieldProps & { extraError?: string }) => {
const error = meta.error || extraError;
const showError = (meta.touched || extraError) && error;
return (
<div className="flex flex-1 flex-col">
<input
{...input}
aria-label={SEARCH_INPUT_LABEL}
className={`flex-1 rounded border px-4 py-2 focus:outline-none ${
showError
? "border-cax-danger focus:border-cax-danger"
: "border-cax-border focus:border-cax-brand-strong"
}`}
placeholder={SEARCH_INPUT_LABEL}
type="text"
/>
{showError && (
<span className="text-cax-danger mt-1 text-xs">{error}</span>
)}
</div>
);
};

const SearchPageComponent = ({
query,
results,
handleSubmit,
}: Props & InjectedFormProps<SearchFormData, Props>) => {
const navigate = useNavigate();
const [isNegative, setIsNegative] = useState(false);
const [submitError, setSubmitError] = useState<string | undefined>();

const parsed = parseSearchQuery(query);

Expand Down Expand Up @@ -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} 以降`);
Expand All @@ -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<FormEventHandler<HTMLFormElement>>((ev) => {
ev.preventDefault();
const input = ev.currentTarget.querySelector<HTMLInputElement>("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 (
<div className="flex flex-col gap-4">
<div className="bg-cax-surface p-4 shadow">
<form onSubmit={handleSubmit(onSubmit)}>
<form onSubmit={onFormSubmit}>
<div className="flex gap-2">
<Field name="searchText" component={SearchInput} />
<Field name="searchText" component={SearchInput} extraError={submitError} />
<Button variant="primary" type="submit">
検索
</Button>
Expand Down
9 changes: 4 additions & 5 deletions application/client/src/components/crok/ChatInput.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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)}
</time>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import classNames from "classnames";
import moment from "moment";
import {
ChangeEvent,
useCallback,
Expand All @@ -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 {
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")}
{formatJaTime(message.createdAt)}
</time>
{isActiveUserSend && message.isRead && (
<span className="text-cax-text-muted">既読</span>
Expand Down
55 changes: 5 additions & 50 deletions application/client/src/components/foundation/CoveredImage.tsx
Original file line number Diff line number Diff line change
@@ -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<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">
<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}
/>
<div className="relative h-full w-full overflow-hidden">
<img alt={alt} className="absolute h-full w-full object-cover" src={src} />

<button
className="border-cax-border bg-cax-surface-raised/90 text-cax-text-muted hover:bg-cax-surface absolute right-1 bottom-1 rounded-full border px-2 py-1 text-center text-xs"
Expand Down
Loading
Loading