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
6 changes: 3 additions & 3 deletions application/client/babel.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@ module.exports = {
[
"@babel/preset-env",
{
targets: "ie 11",
targets: "> 0.5%, last 2 versions, not dead",
corejs: "3",
modules: "commonjs",
modules: false,
useBuiltIns: false,
},
],
[
"@babel/preset-react",
{
development: true,
development: false,
runtime: "automatic",
},
],
Expand Down
24 changes: 9 additions & 15 deletions 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": "vite build",
"dev": "vite",
"typecheck": "tsc"
},
"dependencies": {
Expand All @@ -31,7 +32,6 @@
"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",
Expand All @@ -53,10 +53,7 @@
"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.2.2",
"@tsconfig/strictest": "2.0.8",
"@types/bluebird": "3.5.42",
"@types/common-tags": "1.8.4",
Expand All @@ -73,20 +70,17 @@
"@types/react-dom": "19.2.1",
"@types/react-syntax-highlighter": "15.5.13",
"@types/redux-form": "^8.3.11",
"babel-loader": "10.0.0",
"copy-webpack-plugin": "13.0.1",
"css-loader": "7.1.2",
"html-webpack-plugin": "5.6.4",
"mini-css-extract-plugin": "2.9.4",
"@vitejs/plugin-react": "^4.5.0",
"postcss": "8.5.6",
"postcss-import": "16.1.1",
"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",
"webpack-dev-server": "5.2.2"
"vite": "^6.3.0",
"vite-plugin-compression2": "^1.3.4",
"vite-plugin-node-polyfills": "^0.22.0",
"vite-plugin-static-copy": "^2.3.0"
},
"engines": {
"node": "24.14.0"
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 tailwindcss = require("@tailwindcss/postcss");
const postcssPresetEnv = require("postcss-preset-env");

module.exports = {
plugins: [
postcssImport(),
tailwindcss(),
postcssPresetEnv({
stage: 3,
}),
Expand Down
7 changes: 5 additions & 2 deletions application/client/src/buildinfo.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
declare const __BUILD_DATE__: string;
declare const __COMMIT_HASH__: string;

declare global {
var __BUILD_INFO__: {
BUILD_DATE: string | undefined;
Expand All @@ -7,8 +10,8 @@ declare global {

/** @note 競技用サーバーで参照します。可能な限りコード内に含めてください */
window.__BUILD_INFO__ = {
BUILD_DATE: process.env["BUILD_DATE"],
COMMIT_HASH: process.env["COMMIT_HASH"],
BUILD_DATE: __BUILD_DATE__,
COMMIT_HASH: __COMMIT_HASH__,
};

export {};
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import moment from "moment";
import { useCallback, useEffect, useState } from "react";

import { Button } from "@web-speed-hackathon-2026/client/src/components/foundation/Button";
Expand All @@ -13,6 +12,19 @@ interface Props {
newDmModalId: string;
}

const rtf = new Intl.RelativeTimeFormat("ja", { numeric: "auto" });

function formatRelativeTime(dateStr: string): string {
const diffMs = new Date(dateStr).getTime() - Date.now();
const diffSeconds = Math.round(diffMs / 1000);
if (Math.abs(diffSeconds) < 60) return rtf.format(diffSeconds, "second");
const diffMinutes = Math.round(diffSeconds / 60);
if (Math.abs(diffMinutes) < 60) return rtf.format(diffMinutes, "minute");
const diffHours = Math.round(diffMinutes / 60);
if (Math.abs(diffHours) < 24) return rtf.format(diffHours, "hour");
return rtf.format(Math.round(diffHours / 24), "day");
}

export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => {
const [conversations, setConversations] =
useState<Array<Models.DirectMessageConversation> | null>(null);
Expand Down Expand Up @@ -100,7 +112,7 @@ export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => {
className="text-cax-text-subtle text-xs"
dateTime={lastMessage.createdAt}
>
{moment(lastMessage.createdAt).locale("ja").fromNow()}
{formatRelativeTime(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 @@ -25,6 +24,12 @@ interface Props {
onSubmit: (params: DirectMessageFormData) => Promise<void>;
}

const timeFormatter = new Intl.DateTimeFormat("ja-JP", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});

export const DirectMessagePage = ({
conversationError,
conversation,
Expand Down Expand Up @@ -141,7 +146,7 @@ export const DirectMessagePage = ({
</p>
<div className="flex gap-1 text-xs">
<time dateTime={message.createdAt}>
{moment(message.createdAt).locale("ja").format("HH:mm")}
{timeFormatter.format(new Date(message.createdAt))}
</time>
{isActiveUserSend && message.isRead && (
<span className="text-cax-text-muted">既読</span>
Expand Down
25 changes: 3 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 { ReactNode } from "react";

interface Props {
aspectHeight: number;
Expand All @@ -10,28 +10,9 @@ 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={{ aspectRatio: `${aspectWidth} / ${aspectHeight}` }}>
<div className="absolute inset-0">{children}</div>
</div>
);
};
66 changes: 22 additions & 44 deletions application/client/src/components/foundation/CoveredImage.tsx
Original file line number Diff line number Diff line change
@@ -1,77 +1,55 @@
import classNames from "classnames";
import sizeOf from "image-size";
import { load, ImageIFD } from "piexifjs";
import { MouseEvent, RefCallback, useCallback, useId, useMemo, useState } from "react";
import { Buffer } from "buffer";
import { MouseEvent, useCallback, useId, useState } 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;
priority?: boolean;
}

/**
* アスペクト比を維持したまま、要素のコンテンツボックス全体を埋めるように画像を拡大縮小します
*/
export const CoveredImage = ({ src }: Props) => {
export const CoveredImage = ({ src, priority = false }: Props) => {
const dialogId = useId();
// ダイアログの背景をクリックしたときに投稿詳細ページに遷移しないようにする
const handleDialogClick = useCallback((ev: MouseEvent<HTMLDialogElement>) => {
ev.stopPropagation();
}, []);

const { data, isLoading } = useFetch(src, fetchBinary);
const [alt, setAlt] = useState<string>("");
const [altLoaded, setAltLoaded] = useState(false);

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 handleShowAlt = useCallback(async () => {
if (altLoaded) return;
const [data, { load, ImageIFD }] = await Promise.all([
fetchBinary(src),
import("piexifjs"),
]);
const exif = load(Buffer.from(data).toString("binary"));
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;
setAlt(raw != null ? new TextDecoder().decode(Buffer.from(raw, "binary")) : "");
setAltLoaded(true);
}, [src, altLoaded]);

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="h-full w-full object-cover"
src={src}
loading={priority ? "eager" : "lazy"}
fetchPriority={priority ? "high" : "auto"}
/>

<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"
type="button"
command="show-modal"
commandfor={dialogId}
onClick={handleShowAlt}
>
ALT を表示する
</button>
Expand Down
47 changes: 17 additions & 30 deletions application/client/src/components/foundation/InfiniteScroll.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,43 +7,30 @@ interface Props {
}

export const InfiniteScroll = ({ children, fetchMore, items }: Props) => {
const sentinelRef = useRef<HTMLDivElement>(null);
const latestItem = items[items.length - 1];

const prevReachedRef = useRef(false);

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 sentinel = sentinelRef.current;
if (sentinel == null) return;

// 画面最下部にスクロールしたタイミングで、登録したハンドラを呼び出す
if (hasReached && !prevReachedRef.current) {
// アイテムがないときは追加で読み込まない
if (latestItem !== undefined) {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && latestItem !== undefined) {
fetchMore();
}
}

prevReachedRef.current = hasReached;
};

// 最初は実行されないので手動で呼び出す
prevReachedRef.current = false;
handler();
},
{ rootMargin: "200px" },
);

document.addEventListener("wheel", handler, { passive: false });
document.addEventListener("touchmove", handler, { passive: false });
document.addEventListener("resize", handler, { passive: false });
document.addEventListener("scroll", handler, { passive: false });
return () => {
document.removeEventListener("wheel", handler);
document.removeEventListener("touchmove", handler);
document.removeEventListener("resize", handler);
document.removeEventListener("scroll", handler);
};
observer.observe(sentinel);
return () => observer.disconnect();
}, [latestItem, fetchMore]);

return <>{children}</>;
return (
<>
{children}
<div ref={sentinelRef} />
</>
);
};
Loading