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
44 changes: 25 additions & 19 deletions application/client/babel.config.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,27 @@
module.exports = {
presets: [
["@babel/preset-typescript"],
[
"@babel/preset-env",
{
targets: "ie 11",
corejs: "3",
modules: "commonjs",
useBuiltIns: false,
},
module.exports = (api) => {
api.cache(true);

return {
presets: [
["@babel/preset-typescript"],
[
"@babel/preset-env",
{
targets: {
chrome: "120",
},
corejs: "3",
modules: false,
useBuiltIns: "usage",
},
],
[
"@babel/preset-react",
{
development: false,
runtime: "automatic",
},
],
],
[
"@babel/preset-react",
{
development: true,
runtime: "automatic",
},
],
],
};
};
10 changes: 1 addition & 9 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": "webpack --mode production",
"typecheck": "tsc"
},
"dependencies": {
Expand All @@ -22,19 +22,14 @@
"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",
Expand All @@ -61,12 +56,9 @@
"@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",
Expand Down
68 changes: 27 additions & 41 deletions application/client/src/components/foundation/PausableMovie.tsx
Original file line number Diff line number Diff line change
@@ -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 { 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;
Expand All @@ -16,55 +12,37 @@ interface Props {
* クリックすると再生・一時停止を切り替えます。
*/
export const PausableMovie = ({ src }: Props) => {
const { data, isLoading } = useFetch(src, fetchBinary);
const videoRef = useRef<HTMLVideoElement>(null);
const videoCallbackRef = useCallback<RefCallback<HTMLVideoElement>>((el) => {
videoRef.current = el;
if (el === null) {
return;
}

const animatorRef = useRef<Animator>(null);
const canvasCallbackRef = useCallback<RefCallback<HTMLCanvasElement>>(
(el) => {
animatorRef.current?.stop();
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
el.pause();
setIsPlaying(false);
return;
}

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],
);
void el.play().then(
() => setIsPlaying(true),
() => setIsPlaying(false),
);
}, []);

const [isPlaying, setIsPlaying] = useState(true);
const handleClick = useCallback(() => {
setIsPlaying((isPlaying) => {
if (isPlaying) {
animatorRef.current?.stop();
videoRef.current?.pause();
} else {
animatorRef.current?.start();
void videoRef.current?.play();
}
return !isPlaying;
});
}, []);

if (isLoading || data === null) {
return null;
}

return (
<AspectRatioBox aspectHeight={1} aspectWidth={1}>
<button
Expand All @@ -73,7 +51,15 @@ export const PausableMovie = ({ src }: Props) => {
onClick={handleClick}
type="button"
>
<canvas ref={canvasCallbackRef} className="w-full" />
<video
ref={videoCallbackRef}
className="h-full w-full object-cover"
loop
muted
playsInline
preload="metadata"
src={src}
/>
<div
className={classNames(
"absolute left-1/2 top-1/2 flex items-center justify-center w-16 h-16 text-cax-surface-raised text-3xl bg-cax-overlay/50 rounded-full -translate-x-1/2 -translate-y-1/2",
Expand Down
44 changes: 28 additions & 16 deletions application/client/src/components/foundation/SoundWaveSVG.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import _ from "lodash";
import { useEffect, useRef, useState } from "react";

interface ParsedData {
Expand All @@ -9,23 +8,36 @@ interface ParsedData {
async function calculate(data: ArrayBuffer): Promise<ParsedData> {
const audioCtx = new AudioContext();

// 音声をデコードする
const buffer = await audioCtx.decodeAudioData(data.slice(0));
// 左の音声データの絶対値を取る
const leftData = _.map(buffer.getChannelData(0), Math.abs);
// 右の音声データの絶対値を取る
const rightData = _.map(buffer.getChannelData(1), Math.abs);
try {
// 音声をデコードする
const buffer = await audioCtx.decodeAudioData(data.slice(0));

// 左右の音声データの平均を取る
const normalized = _.map(_.zip(leftData, rightData), _.mean);
// 100 個の chunk に分ける
const chunks = _.chunk(normalized, Math.ceil(normalized.length / 100));
// chunk ごとに平均を取る
const peaks = _.map(chunks, _.mean);
// chunk の平均の中から最大値を取る
const max = _.max(peaks) ?? 0;
const leftData = buffer.getChannelData(0);
const rightData = buffer.numberOfChannels > 1 ? buffer.getChannelData(1) : leftData;

return { max, peaks };
const sampleCount = leftData.length;
const chunkSize = Math.max(1, Math.ceil(sampleCount / 100));
const peaks: number[] = [];

for (let i = 0; i < sampleCount; i += chunkSize) {
const end = Math.min(sampleCount, i + chunkSize);
let sum = 0;

for (let j = i; j < end; j += 1) {
const left = Math.abs(leftData[j] ?? 0);
const right = Math.abs(rightData[j] ?? 0);
sum += (left + right) / 2;
}

peaks.push(sum / (end - i));
}

const max = peaks.reduce((cur, value) => (value > cur ? value : cur), 0);

return { max, peaks };
} finally {
await audioCtx.close();
}
}

interface Props {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { MagickFormat } from "@imagemagick/magick-wasm";
import type { MagickFormat } from "@imagemagick/magick-wasm";
import { ChangeEventHandler, FormEventHandler, useCallback, useState } from "react";

import { FontAwesomeIcon } from "@web-speed-hackathon-2026/client/src/components/foundation/FontAwesomeIcon";
Expand All @@ -10,6 +10,7 @@ import { convertMovie } from "@web-speed-hackathon-2026/client/src/utils/convert
import { convertSound } from "@web-speed-hackathon-2026/client/src/utils/convert_sound";

const MAX_UPLOAD_BYTES_LIMIT = 10 * 1024 * 1024;
const JPEG_FORMAT: MagickFormat = "JPG";

interface SubmitParams {
images: File[];
Expand Down Expand Up @@ -55,7 +56,7 @@ export const NewPostModalPage = ({ id, hasError, isLoading, onResetError, onSubm

Promise.all(
files.map((file) =>
convertImage(file, { extension: MagickFormat.Jpg }).then(
convertImage(file, { extension: JPEG_FORMAT }).then(
(blob) => new File([blob], "converted.jpg", { type: "image/jpeg" }),
),
),
Expand Down Expand Up @@ -103,13 +104,13 @@ export const NewPostModalPage = ({ id, hasError, isLoading, onResetError, onSubm
if (isValid) {
setIsConverting(true);

convertMovie(file, { extension: "gif", size: undefined })
convertMovie(file, { extension: "mp4", size: undefined })
.then((converted) => {
setParams((params) => ({
...params,
images: [],
movie: new File([converted], "converted.gif", {
type: "image/gif",
movie: new File([converted], "converted.mp4", {
type: "video/mp4",
}),
sound: undefined,
}));
Expand Down
12 changes: 10 additions & 2 deletions application/client/src/containers/AuthModalContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,20 @@ interface Props {
onUpdateActiveUser: (user: Models.User) => void;
}

interface ApiErrorResponse {
code?: unknown;
}

interface FetcherError {
responseJSON?: ApiErrorResponse;
}

const ERROR_MESSAGES: Record<string, string> = {
INVALID_USERNAME: "ユーザー名に使用できない文字が含まれています",
USERNAME_TAKEN: "ユーザー名が使われています",
};

function getErrorCode(err: JQuery.jqXHR<unknown>, type: "signin" | "signup"): string {
function getErrorCode(err: FetcherError, type: "signin" | "signup"): string {
const responseJSON = err.responseJSON;
if (
typeof responseJSON !== "object" ||
Expand Down Expand Up @@ -68,7 +76,7 @@ export const AuthModalContainer = ({ id, onUpdateActiveUser }: Props) => {
}
handleRequestCloseModal();
} catch (err: unknown) {
const error = getErrorCode(err as JQuery.jqXHR<unknown>, values.type);
const error = getErrorCode(err as FetcherError, values.type);
throw new SubmissionError({
_error: error,
});
Expand Down
24 changes: 16 additions & 8 deletions application/client/src/hooks/use_infinite_fetch.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";

const LIMIT = 30;
const LIMIT = 12;

interface ReturnValues<T> {
data: Array<T>;
Expand All @@ -13,7 +13,7 @@ export function useInfiniteFetch<T>(
apiPath: string,
fetcher: (apiPath: string) => Promise<T[]>,
): ReturnValues<T> {
const internalRef = useRef({ isLoading: false, offset: 0 });
const internalRef = useRef({ hasMore: true, isLoading: false, offset: 0 });

const [result, setResult] = useState<Omit<ReturnValues<T>, "fetchMore">>({
data: [],
Expand All @@ -22,8 +22,8 @@ export function useInfiniteFetch<T>(
});

const fetchMore = useCallback(() => {
const { isLoading, offset } = internalRef.current;
if (isLoading) {
const { hasMore, isLoading, offset } = internalRef.current;
if (isLoading || !hasMore) {
return;
}

Expand All @@ -32,20 +32,26 @@ export function useInfiniteFetch<T>(
isLoading: true,
}));
internalRef.current = {
hasMore,
isLoading: true,
offset,
};

void fetcher(apiPath).then(
(allData) => {
const separator = apiPath.includes("?") ? "&" : "?";
const pagedApiPath = `${apiPath}${separator}limit=${LIMIT}&offset=${offset}`;

void fetcher(pagedApiPath).then(
(pageData) => {
const hasMore = pageData.length >= LIMIT;
setResult((cur) => ({
...cur,
data: [...cur.data, ...allData.slice(offset, offset + LIMIT)],
data: [...cur.data, ...pageData],
isLoading: false,
}));
internalRef.current = {
hasMore,
isLoading: false,
offset: offset + LIMIT,
offset: offset + pageData.length,
};
},
(error) => {
Expand All @@ -55,6 +61,7 @@ export function useInfiniteFetch<T>(
isLoading: false,
}));
internalRef.current = {
hasMore,
isLoading: false,
offset,
};
Expand All @@ -69,6 +76,7 @@ export function useInfiniteFetch<T>(
isLoading: true,
}));
internalRef.current = {
hasMore: true,
isLoading: false,
offset: 0,
};
Expand Down
Loading
Loading