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
2 changes: 2 additions & 0 deletions application/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"license": "MPL-2.0",
"author": "CyberAgent, Inc.",
"scripts": {
"analyze": "NODE_ENV=development ANALYZE=true webpack",
"build": "NODE_ENV=development webpack",
"typecheck": "tsc"
},
Expand Down Expand Up @@ -85,6 +86,7 @@
"react-markdown": "10.1.0",
"typescript": "5.9.3",
"webpack": "5.102.1",
"webpack-bundle-analyzer": "4.10.2",
"webpack-cli": "6.0.1",
"webpack-dev-server": "5.2.2"
},
Expand Down
2 changes: 1 addition & 1 deletion application/client/src/auth/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const validate = (values: AuthFormData): FormErrors<AuthFormData> => {
errors.name = "名前を入力してください";
}

if (/^(?:[^\P{Letter}&&\P{Number}]*){16,}$/v.test(normalizedPassword)) {
if (/^[\p{Letter}\p{Number}]+$/v.test(normalizedPassword)) {
errors.password = "パスワードには記号を含める必要があります";
}
if (normalizedPassword.length === 0) {
Expand Down
53 changes: 6 additions & 47 deletions application/client/src/components/foundation/CoveredImage.tsx
Original file line number Diff line number Diff line change
@@ -1,70 +1,29 @@
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">
<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}
/>

<button
Expand Down
15 changes: 7 additions & 8 deletions application/client/src/components/foundation/InfiniteScroll.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,7 @@ 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) {
Expand All @@ -33,10 +30,12 @@ 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 });
// passive: false はブラウザに「このハンドラが preventDefault() を呼ぶかもしれない」と伝えるフラグ
// ブラウザがスクロールを遅延させてハンドラの完了を待つ原因になる
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);
Expand Down
2 changes: 1 addition & 1 deletion application/client/src/components/post/ImageArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const ImageArea = ({ images }: Props) => {
"row-span-2": images.length <= 2 || (images.length === 3 && idx === 0),
})}
>
<CoveredImage src={getImagePath(image.id)} />
<CoveredImage alt={image.alt} src={getImagePath(image.id)} />
</div>
);
})}
Expand Down
12 changes: 5 additions & 7 deletions application/client/src/containers/AuthModalContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,10 @@ export const AuthModalContainer = ({ id, onUpdateActiveUser }: Props) => {
const element = ref.current;

const handleToggle = () => {
// モーダル開閉時にkeyを更新することでフォームの状態をリセットする
setResetKey((key) => key + 1);
// ダイアログが閉じた時だけフォームをリセット(開いた時はリセットしない)
if (!element.open) {
setResetKey((key) => key + 1);
}
};
element.addEventListener("toggle", handleToggle);
return () => {
Expand Down Expand Up @@ -79,11 +81,7 @@ export const AuthModalContainer = ({ id, onUpdateActiveUser }: Props) => {

return (
<Modal id={id} ref={ref} closedby="any">
<AuthModalPage
key={resetKey}
onRequestCloseModal={handleRequestCloseModal}
onSubmit={handleSubmit}
/>
<AuthModalPage key={resetKey} onRequestCloseModal={handleRequestCloseModal} onSubmit={handleSubmit} />
</Modal>
);
};
12 changes: 5 additions & 7 deletions application/client/src/hooks/use_infinite_fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,7 @@ interface ReturnValues<T> {
fetchMore: () => void;
}

export function useInfiniteFetch<T>(
apiPath: string,
fetcher: (apiPath: string) => Promise<T[]>,
): ReturnValues<T> {
export function useInfiniteFetch<T>(apiPath: string, fetcher: (apiPath: string) => Promise<T[]>): ReturnValues<T> {
const internalRef = useRef({ isLoading: false, offset: 0 });

const [result, setResult] = useState<Omit<ReturnValues<T>, "fetchMore">>({
Expand All @@ -36,11 +33,12 @@ export function useInfiniteFetch<T>(
offset,
};

void fetcher(apiPath).then(
(allData) => {
const separator = apiPath.includes("?") ? "&" : "?";
void fetcher(`${apiPath}${separator}limit=${LIMIT}&offset=${offset}`).then(
(pageData) => {
setResult((cur) => ({
...cur,
data: [...cur.data, ...allData.slice(offset, offset + LIMIT)],
data: [...cur.data, ...pageData],
isLoading: false,
}));
internalRef.current = {
Expand Down
6 changes: 3 additions & 3 deletions application/client/src/search/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ export const sanitizeSearchText = (input: string): string => {
};

export const parseSearchQuery = (query: string) => {
const sincePattern = /since:((\d|\d\d|\d\d\d\d-\d\d-\d\d)+)+$/;
const untilPattern = /until:((\d|\d\d|\d\d\d\d-\d\d-\d\d)+)+$/;
const sincePattern = /since:(\d{4}-\d{2}-\d{2})/;
const untilPattern = /until:(\d{4}-\d{2}-\d{2})/;

const sincePart = query.match(/since:[^\s]*/)?.[0] || "";
const untilPart = query.match(/until:[^\s]*/)?.[0] || "";
Expand All @@ -38,7 +38,7 @@ export const parseSearchQuery = (query: string) => {
};

export const isValidDate = (dateStr: string): boolean => {
const slowDateLike = /^(\d+)+-(\d+)+-(\d+)+$/;
const slowDateLike = /^\d+-\d+-\d+$/;
if (!slowDateLike.test(dateStr)) return false;

const date = new Date(dateStr);
Expand Down
3 changes: 1 addition & 2 deletions application/client/src/utils/convert_image.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { initializeImageMagick, ImageMagick, MagickFormat } from "@imagemagick/magick-wasm";
import magickWasm from "@imagemagick/magick-wasm/magick.wasm?binary";
import { dump, insert, ImageIFD } from "piexifjs";

interface Options {
extension: MagickFormat;
}

export async function convertImage(file: File, options: Options): Promise<Blob> {
await initializeImageMagick(magickWasm);
await initializeImageMagick(new URL("/static/magick.wasm", location.origin));

const byteArray = new Uint8Array(await file.arrayBuffer());

Expand Down
4 changes: 0 additions & 4 deletions application/client/src/utils/fetchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { gzip } from "pako";

export async function fetchBinary(url: string): Promise<ArrayBuffer> {
const result = await $.ajax({
async: false,
dataType: "binary",
method: "GET",
responseType: "arraybuffer",
Expand All @@ -14,7 +13,6 @@ export async function fetchBinary(url: string): Promise<ArrayBuffer> {

export async function fetchJSON<T>(url: string): Promise<T> {
const result = await $.ajax({
async: false,
dataType: "json",
method: "GET",
url,
Expand All @@ -24,7 +22,6 @@ export async function fetchJSON<T>(url: string): Promise<T> {

export async function sendFile<T>(url: string, file: File): Promise<T> {
const result = await $.ajax({
async: false,
data: file,
dataType: "json",
headers: {
Expand All @@ -43,7 +40,6 @@ export async function sendJSON<T>(url: string, data: object): Promise<T> {
const compressed = gzip(uint8Array);

const result = await $.ajax({
async: false,
data: compressed,
dataType: "json",
headers: {
Expand Down
8 changes: 2 additions & 6 deletions application/client/src/utils/load_ffmpeg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,8 @@ export async function loadFFmpeg(): Promise<FFmpeg> {
const ffmpeg = new FFmpeg();

await ffmpeg.load({
coreURL: await import("@ffmpeg/core?binary").then(({ default: b }) => {
return URL.createObjectURL(new Blob([b], { type: "text/javascript" }));
}),
wasmURL: await import("@ffmpeg/core/wasm?binary").then(({ default: b }) => {
return URL.createObjectURL(new Blob([b], { type: "application/wasm" }));
}),
coreURL: "/static/ffmpeg-core.js",
wasmURL: "/static/ffmpeg-core.wasm",
});

return ffmpeg;
Expand Down
31 changes: 16 additions & 15 deletions application/client/webpack.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/// <reference types="webpack-dev-server" />
const path = require("path");

const { BundleAnalyzerPlugin } = require("webpack-bundle-analyzer");
const CopyWebpackPlugin = require("copy-webpack-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
Expand Down Expand Up @@ -88,12 +89,27 @@ const config = {
from: path.resolve(__dirname, "node_modules/katex/dist/fonts"),
to: path.resolve(DIST_PATH, "styles/fonts"),
},
{
from: path.resolve(__dirname, "node_modules/@ffmpeg/core/dist/umd/ffmpeg-core.js"),
to: path.resolve(DIST_PATH, "static/ffmpeg-core.js"),
},
{
from: path.resolve(__dirname, "node_modules/@ffmpeg/core/dist/umd/ffmpeg-core.wasm"),
to: path.resolve(DIST_PATH, "static/ffmpeg-core.wasm"),
},
{
from: path.resolve(__dirname, "node_modules/@imagemagick/magick-wasm/dist/magick.wasm"),
to: path.resolve(DIST_PATH, "static/magick.wasm"),
},
],
}),
new HtmlWebpackPlugin({
inject: false,
template: path.resolve(SRC_PATH, "./index.html"),
}),
...(process.env.ANALYZE === "true"
? [new BundleAnalyzerPlugin({ analyzerMode: "static", reportFilename: "bundle-report.html", openAnalyzer: false })]
: []),
],
resolve: {
extensions: [".tsx", ".ts", ".mjs", ".cjs", ".jsx", ".js"],
Expand All @@ -105,21 +121,6 @@ const config = {
"node_modules",
"@ffmpeg/ffmpeg/dist/esm/index.js",
),
"@ffmpeg/core$": path.resolve(
__dirname,
"node_modules",
"@ffmpeg/core/dist/umd/ffmpeg-core.js",
),
"@ffmpeg/core/wasm$": path.resolve(
__dirname,
"node_modules",
"@ffmpeg/core/dist/umd/ffmpeg-core.wasm",
),
"@imagemagick/magick-wasm/magick.wasm$": path.resolve(
__dirname,
"node_modules",
"@imagemagick/magick-wasm/dist/magick.wasm",
),
},
fallback: {
fs: false,
Expand Down
Loading
Loading