diff --git a/application/client/babel.config.js b/application/client/babel.config.js index c3c574591a..6a0079da4e 100644 --- a/application/client/babel.config.js +++ b/application/client/babel.config.js @@ -4,16 +4,15 @@ module.exports = { [ "@babel/preset-env", { - targets: "ie 11", - corejs: "3", - modules: "commonjs", + targets: "last 1 Chrome version", + modules: false, useBuiltIns: false, }, ], [ "@babel/preset-react", { - development: true, + development: process.env.NODE_ENV !== "production", runtime: "automatic", }, ], diff --git a/application/client/package.json b/application/client/package.json index 9f8e80a6a8..a899353261 100644 --- a/application/client/package.json +++ b/application/client/package.json @@ -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": { @@ -57,6 +57,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", @@ -83,6 +84,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", diff --git a/application/client/postcss.config.js b/application/client/postcss.config.js index d7ee920b94..bcb66acf3f 100644 --- a/application/client/postcss.config.js +++ b/application/client/postcss.config.js @@ -1,9 +1,11 @@ +const tailwindcss = require("@tailwindcss/postcss"); const postcssImport = require("postcss-import"); const postcssPresetEnv = require("postcss-preset-env"); module.exports = { plugins: [ postcssImport(), + tailwindcss(), postcssPresetEnv({ stage: 3, }), diff --git a/application/client/src/auth/validation.ts b/application/client/src/auth/validation.ts index 2a83bbfb15..7a9a8f0ff6 100644 --- a/application/client/src/auth/validation.ts +++ b/application/client/src/auth/validation.ts @@ -1,9 +1,9 @@ -import { FormErrors } from "redux-form"; - import { AuthFormData } from "@web-speed-hackathon-2026/client/src/auth/types"; -export const validate = (values: AuthFormData): FormErrors => { - const errors: FormErrors = {}; +export type AuthFormErrors = Partial>; + +export const validate = (values: AuthFormData): AuthFormErrors => { + const errors: AuthFormErrors = {}; const normalizedName = values.name?.trim() || ""; const normalizedPassword = values.password?.trim() || ""; diff --git a/application/client/src/buildinfo.ts b/application/client/src/buildinfo.ts index 48b5dbef9b..6302960b67 100644 --- a/application/client/src/buildinfo.ts +++ b/application/client/src/buildinfo.ts @@ -3,6 +3,7 @@ declare global { BUILD_DATE: string | undefined; COMMIT_HASH: string | undefined; }; + var __BOOTSTRAP_DATA__: Record | undefined; } /** @note 競技用サーバーで参照します。可能な限りコード内に含めてください */ diff --git a/application/client/src/components/application/AccountMenu.tsx b/application/client/src/components/application/AccountMenu.tsx index b6df12bbab..2fe03f34c2 100644 --- a/application/client/src/components/application/AccountMenu.tsx +++ b/application/client/src/components/application/AccountMenu.tsx @@ -40,7 +40,10 @@ export const AccountMenu = ({ user, onLogout }: Props) => { {user.profileImage.alt}
{user.name}
diff --git a/application/client/src/components/application/SearchPage.tsx b/application/client/src/components/application/SearchPage.tsx index e99045de45..9d86945521 100644 --- a/application/client/src/components/application/SearchPage.tsx +++ b/application/client/src/components/application/SearchPage.tsx @@ -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"; @@ -22,6 +21,7 @@ const SearchInput = ({ input, meta }: WrappedFieldProps) => (
{ - if (isMounted) { - setIsNegative(result.label === "negative"); - } - }) - .catch(() => { - if (isMounted) { - setIsNegative(false); - } - }); + const run = () => { + void import("@web-speed-hackathon-2026/client/src/utils/negaposi_analyzer") + .then(({ analyzeSentiment }) => analyzeSentiment(parsed.keywords)) + .then((result) => { + if (isMounted) { + setIsNegative(result.label === "negative"); + } + }) + .catch(() => { + if (isMounted) { + setIsNegative(false); + } + }); + }; + + const timerId = window.setTimeout(() => { + if ("requestIdleCallback" in window) { + window.requestIdleCallback(run, { timeout: 5000 }); + } else { + run(); + } + }, 2000); return () => { isMounted = false; + window.clearTimeout(timerId); }; }, [parsed.keywords]); @@ -82,7 +94,7 @@ const SearchPageComponent = ({ parts.push(`${parsed.untilDate} 以前`); } return parts.join(" "); - }, [parsed]); + }, [parsed.keywords, parsed.sinceDate, parsed.untilDate]); const onSubmit = (values: SearchFormData) => { const sanitizedText = sanitizeSearchText(values.searchText.trim()); diff --git a/application/client/src/components/auth_modal/AuthModalPage.tsx b/application/client/src/components/auth_modal/AuthModalPage.tsx index 08996f9afd..1035b0afd2 100644 --- a/application/client/src/components/auth_modal/AuthModalPage.tsx +++ b/application/client/src/components/auth_modal/AuthModalPage.tsx @@ -1,82 +1,134 @@ -import { useSelector } from "react-redux"; -import { Field, formValueSelector, InjectedFormProps, reduxForm } from "redux-form"; +import { ChangeEvent, FormEvent, useCallback, useMemo, useState } from "react"; import { AuthFormData } from "@web-speed-hackathon-2026/client/src/auth/types"; import { validate } from "@web-speed-hackathon-2026/client/src/auth/validation"; -import { FormInputField } from "@web-speed-hackathon-2026/client/src/components/foundation/FormInputField"; +import { Input } from "@web-speed-hackathon-2026/client/src/components/foundation/Input"; import { Link } from "@web-speed-hackathon-2026/client/src/components/foundation/Link"; import { ModalErrorMessage } from "@web-speed-hackathon-2026/client/src/components/modal/ModalErrorMessage"; import { ModalSubmitButton } from "@web-speed-hackathon-2026/client/src/components/modal/ModalSubmitButton"; interface Props { onRequestCloseModal: () => void; + onSubmit: (values: AuthFormData) => Promise; } -const AuthModalPageComponent = ({ - onRequestCloseModal, - handleSubmit, - error, - invalid, - submitting, - initialValues, - change, -}: Props & InjectedFormProps) => { - const currentType: "signin" | "signup" = useSelector((state) => - // @ts-ignore: formValueSelectorの型付けが弱いため、型に嘘をつく - formValueSelector("auth")(state, "type"), +const INITIAL_VALUES: AuthFormData = { + type: "signin", + username: "", + name: "", + password: "", +}; + +export const AuthModalPage = ({ onRequestCloseModal, onSubmit }: Props) => { + const [values, setValues] = useState(INITIAL_VALUES); + const [submitError, setSubmitError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const errors = useMemo(() => validate(values), [values]); + const isInvalid = Object.values(errors).some(Boolean); + + const handleChange = useCallback( + (key: keyof AuthFormData) => (event: ChangeEvent) => { + const nextValue = event.target.value; + setSubmitError(null); + setValues((current) => ({ + ...current, + [key]: nextValue, + })); + }, + [], + ); + + const handleToggleType = useCallback(() => { + setSubmitError(null); + setValues((current) => ({ + ...INITIAL_VALUES, + type: current.type === "signin" ? "signup" : "signin", + })); + }, []); + + const handleSubmit = useCallback( + async (event: FormEvent) => { + event.preventDefault(); + if (isSubmitting || isInvalid) { + return; + } + + setIsSubmitting(true); + setSubmitError(null); + try { + const error = await onSubmit(values); + if (error !== null) { + setSubmitError(error); + } + } finally { + setIsSubmitting(false); + } + }, + [isInvalid, isSubmitting, onSubmit, values], ); - const type = currentType ?? initialValues.type; return (

- {type === "signin" ? "サインイン" : "新規登録"} + {values.type === "signin" ? "サインイン" : "新規登録"}

-
- @, - autoComplete: "username", - }} - /> - - {type === "signup" && ( - + + @} + value={values.username} + onChange={handleChange("username")} + /> + {errors.username ? {errors.username} : null} +
+ + {values.type === "signup" ? ( +
+ + + {errors.name ? {errors.name} : null} +
+ ) : null} + +
+ + - )} - - + {errors.password ? {errors.password} : null} +
- {type === "signup" ? ( + {values.type === "signup" ? (

利用規約 @@ -85,19 +137,11 @@ const AuthModalPageComponent = ({

) : null} - - {type === "signin" ? "サインイン" : "登録する"} + + {values.type === "signin" ? "サインイン" : "登録する"} - {error} + {submitError} ); }; - -export const AuthModalPage = reduxForm({ - form: "auth", - validate, - initialValues: { - type: "signin", - }, -})(AuthModalPageComponent); diff --git a/application/client/src/components/crok/AssistantMarkdown.tsx b/application/client/src/components/crok/AssistantMarkdown.tsx new file mode 100644 index 0000000000..b1e7b668ae --- /dev/null +++ b/application/client/src/components/crok/AssistantMarkdown.tsx @@ -0,0 +1,24 @@ +import "katex/dist/katex.min.css"; + +import Markdown from "react-markdown"; +import rehypeKatex from "rehype-katex"; +import remarkGfm from "remark-gfm"; +import remarkMath from "remark-math"; + +import { CodeBlock } from "@web-speed-hackathon-2026/client/src/components/crok/CodeBlock"; + +interface Props { + content: string; +} + +export const AssistantMarkdown = ({ content }: Props) => { + return ( + + {content} + + ); +}; diff --git a/application/client/src/components/crok/ChatInput.tsx b/application/client/src/components/crok/ChatInput.tsx index 6f8c17796b..c334d5be38 100644 --- a/application/client/src/components/crok/ChatInput.tsx +++ b/application/client/src/components/crok/ChatInput.tsx @@ -80,10 +80,12 @@ export const ChatInput = ({ isStreaming, onSendMessage }: Props) => { const textareaRef = useRef(null); const suggestionsRef = useRef(null); const [tokenizer, setTokenizer] = useState | null>(null); + const [shouldInitTokenizer, setShouldInitTokenizer] = useState(false); const [inputValue, setInputValue] = useState(""); const [suggestions, setSuggestions] = useState([]); const [queryTokens, setQueryTokens] = useState([]); const [showSuggestions, setShowSuggestions] = useState(false); + const trimmedInputValue = inputValue.trim(); // サジェストが更新されたら一番下にスクロール useLayoutEffect(() => { @@ -92,8 +94,30 @@ export const ChatInput = ({ isStreaming, onSendMessage }: Props) => { } }, [suggestions, showSuggestions]); - // 初回にkuromojiトークナイザーを構築 useEffect(() => { + const enableTokenizer = () => { + setShouldInitTokenizer(true); + }; + + if ("requestIdleCallback" in window) { + const idleId = window.requestIdleCallback(enableTokenizer, { timeout: 2000 }); + return () => { + window.cancelIdleCallback(idleId); + }; + } + + const timeoutId = globalThis.setTimeout(enableTokenizer, 300); + return () => { + globalThis.clearTimeout(timeoutId); + }; + }, []); + + // 初回の入力導線を邪魔しないよう、形態素解析器は idle 後に構築する + useEffect(() => { + if (!shouldInitTokenizer || tokenizer !== null) { + return; + } + let mounted = true; const init = async () => { @@ -108,13 +132,12 @@ export const ChatInput = ({ isStreaming, onSendMessage }: Props) => { return () => { mounted = false; }; - }, []); + }, [shouldInitTokenizer, tokenizer]); useEffect(() => { let cancelled = false; - const updateSuggestions = async () => { - if (!tokenizer || !inputValue.trim()) { + if (!tokenizer || trimmedInputValue.length < 8) { setSuggestions([]); setQueryTokens([]); setShowSuggestions(false); @@ -140,12 +163,15 @@ export const ChatInput = ({ isStreaming, onSendMessage }: Props) => { setShowSuggestions(results.length > 0); }; - void updateSuggestions(); + const timeoutId = window.setTimeout(() => { + void updateSuggestions(); + }, 600); return () => { cancelled = true; + window.clearTimeout(timeoutId); }; - }, [inputValue, tokenizer]); + }, [inputValue, tokenizer, trimmedInputValue]); const adjustTextareaHeight = () => { const textarea = textareaRef.current; @@ -163,6 +189,9 @@ export const ChatInput = ({ isStreaming, onSendMessage }: Props) => { const handleInputChange = (e: ChangeEvent) => { const value = e.target.value; + if (!shouldInitTokenizer) { + setShouldInitTokenizer(true); + } setInputValue(value); adjustTextareaHeight(); }; @@ -220,6 +249,7 @@ export const ChatInput = ({ isStreaming, onSendMessage }: Props) => { ref={textareaRef} className="text-cax-text placeholder-cax-text-subtle max-h-[200px] min-h-[52px] flex-1 resize-none overflow-y-auto bg-transparent py-3 pr-2 pl-4 focus:outline-none" onChange={handleInputChange} + onFocus={() => setShouldInitTokenizer(true)} onKeyDown={handleKeyDown} placeholder="メッセージを入力..." lang="ja" diff --git a/application/client/src/components/crok/ChatMessage.tsx b/application/client/src/components/crok/ChatMessage.tsx index ea4a10d027..ad3315d70b 100644 --- a/application/client/src/components/crok/ChatMessage.tsx +++ b/application/client/src/components/crok/ChatMessage.tsx @@ -1,17 +1,18 @@ -import "katex/dist/katex.min.css"; -import Markdown from "react-markdown"; -import rehypeKatex from "rehype-katex"; -import remarkGfm from "remark-gfm"; -import remarkMath from "remark-math"; +import { Suspense, lazy } from "react"; -import { CodeBlock } from "@web-speed-hackathon-2026/client/src/components/crok/CodeBlock"; import { TypingIndicator } from "@web-speed-hackathon-2026/client/src/components/crok/TypingIndicator"; import { CrokLogo } from "@web-speed-hackathon-2026/client/src/components/foundation/CrokLogo"; interface Props { + isStreaming?: boolean; message: Models.ChatMessage; } +const LazyAssistantMarkdown = lazy(async () => { + const module = await import("@web-speed-hackathon-2026/client/src/components/crok/AssistantMarkdown"); + return { default: module.AssistantMarkdown }; +}); + const UserMessage = ({ content }: { content: string }) => { return (
@@ -22,7 +23,7 @@ const UserMessage = ({ content }: { content: string }) => { ); }; -const AssistantMessage = ({ content }: { content: string }) => { +const AssistantMessage = ({ content, isStreaming = false }: { content: string; isStreaming?: boolean }) => { return (
@@ -32,14 +33,13 @@ const AssistantMessage = ({ content }: { content: string }) => {
Crok
{content ? ( - - {content} - + isStreaming ? ( +

{content}

+ ) : ( + {content}

}> + +
+ ) ) : ( )} @@ -49,9 +49,9 @@ const AssistantMessage = ({ content }: { content: string }) => { ); }; -export const ChatMessage = ({ message }: Props) => { +export const ChatMessage = ({ message, isStreaming = false }: Props) => { if (message.role === "user") { return ; } - return ; + return ; }; diff --git a/application/client/src/components/crok/CrokPage.tsx b/application/client/src/components/crok/CrokPage.tsx index 0be7678f84..ed6707caf4 100644 --- a/application/client/src/components/crok/CrokPage.tsx +++ b/application/client/src/components/crok/CrokPage.tsx @@ -28,7 +28,11 @@ export const CrokPage = ({ messages, isStreaming, onSendMessage }: Props) => { {messages.length === 0 && } {messages.map((message, index) => ( - + ))}
diff --git a/application/client/src/components/direct_message/DirectMessageListPage.tsx b/application/client/src/components/direct_message/DirectMessageListPage.tsx index 5a373e918e..b7f524c35e 100644 --- a/application/client/src/components/direct_message/DirectMessageListPage.tsx +++ b/application/client/src/components/direct_message/DirectMessageListPage.tsx @@ -1,10 +1,11 @@ -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 { consumeBootstrapData, peekBootstrapData } from "@web-speed-hackathon-2026/client/src/utils/bootstrap_data"; +import { formatRelativeFromNowJa } from "@web-speed-hackathon-2026/client/src/utils/date_format"; import { fetchJSON } from "@web-speed-hackathon-2026/client/src/utils/fetchers"; import { getProfileImagePath } from "@web-speed-hackathon-2026/client/src/utils/get_path"; @@ -14,8 +15,9 @@ interface Props { } export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => { - const [conversations, setConversations] = - useState | null>(null); + const [conversations, setConversations] = useState | null>( + () => peekBootstrapData>("/api/v1/dm"), + ); const [error, setError] = useState(null); const loadConversations = useCallback(async () => { @@ -34,12 +36,25 @@ export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => { }, [activeUser]); useEffect(() => { + const bootstrapConversations = consumeBootstrapData>( + "/api/v1/dm", + ); + if (bootstrapConversations !== null) { + setConversations(bootstrapConversations); + setError(null); + return; + } + void loadConversations(); }, [loadConversations]); - useWs("/api/v1/dm/unread", () => { - void loadConversations(); - }); + useWs( + "/api/v1/dm/unread", + () => { + void loadConversations(); + }, + { delayMs: 5000 }, + ); if (conversations == null) { return null; @@ -81,13 +96,21 @@ export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => { .some((m) => !m.isRead); return ( -
  • +
  • {peer.profileImage.alt}
    @@ -100,7 +123,7 @@ export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => { className="text-cax-text-subtle text-xs" dateTime={lastMessage.createdAt} > - {moment(lastMessage.createdAt).locale("ja").fromNow()} + {formatRelativeFromNowJa(lastMessage.createdAt)} )}
    diff --git a/application/client/src/components/direct_message/DirectMessageNotificationBadge.tsx b/application/client/src/components/direct_message/DirectMessageNotificationBadge.tsx index 84b62972fa..e50d40fb84 100644 --- a/application/client/src/components/direct_message/DirectMessageNotificationBadge.tsx +++ b/application/client/src/components/direct_message/DirectMessageNotificationBadge.tsx @@ -13,9 +13,13 @@ export const DirectMessageNotificationBadge = () => { const [unreadCount, updateUnreadCount] = useState(0); const displayCount = unreadCount > 99 ? "99+" : String(unreadCount); - useWs("/api/v1/dm/unread", (event: DmUnreadEvent) => { - updateUnreadCount(event.payload.unreadCount); - }); + useWs( + "/api/v1/dm/unread", + (event: DmUnreadEvent) => { + updateUnreadCount(event.payload.unreadCount); + }, + { delayMs: 5000 }, + ); if (unreadCount === 0) { return null; diff --git a/application/client/src/components/direct_message/DirectMessagePage.tsx b/application/client/src/components/direct_message/DirectMessagePage.tsx index 098c7d2894..ea3f818b27 100644 --- a/application/client/src/components/direct_message/DirectMessagePage.tsx +++ b/application/client/src/components/direct_message/DirectMessagePage.tsx @@ -1,5 +1,4 @@ import classNames from "classnames"; -import moment from "moment"; import { ChangeEvent, useCallback, @@ -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 { formatTimeJa } from "@web-speed-hackathon-2026/client/src/utils/date_format"; import { getProfileImagePath } from "@web-speed-hackathon-2026/client/src/utils/get_path"; interface Props { @@ -43,8 +43,6 @@ export const DirectMessagePage = ({ const [text, setText] = useState(""); const textAreaRows = Math.min((text || "").split("\n").length, 5); const isInvalid = text.trim().length === 0; - const scrollHeightRef = useRef(0); - const handleChange = useCallback( (event: ChangeEvent) => { setText(event.target.value); @@ -74,16 +72,8 @@ export const DirectMessagePage = ({ ); useEffect(() => { - const id = setInterval(() => { - const height = Number(window.getComputedStyle(document.body).height.replace("px", "")); - if (height !== scrollHeightRef.current) { - scrollHeightRef.current = height; - window.scrollTo(0, height); - } - }, 1); - - return () => clearInterval(id); - }, []); + window.scrollTo(0, document.body.scrollHeight); + }, [conversation.messages.length, isPeerTyping]); if (conversationError != null) { return ( @@ -99,7 +89,11 @@ export const DirectMessagePage = ({ {peer.profileImage.alt}

    @@ -140,9 +134,7 @@ export const DirectMessagePage = ({ {message.body}

    - + {isActiveUserSend && message.isRead && ( 既読 )} diff --git a/application/client/src/components/foundation/AspectRatioBox.tsx b/application/client/src/components/foundation/AspectRatioBox.tsx index 0ae891963c..df7c4d6635 100644 --- a/application/client/src/components/foundation/AspectRatioBox.tsx +++ b/application/client/src/components/foundation/AspectRatioBox.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useEffect, useRef, useState } from "react"; +import { ReactNode } from "react"; interface Props { aspectHeight: number; @@ -10,28 +10,12 @@ interface Props { * 親要素の横幅を基準にして、指定したアスペクト比のブロック要素を作ります */ export const AspectRatioBox = ({ aspectHeight, aspectWidth, children }: Props) => { - const ref = useRef(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 ( -
    - {/* 高さが計算できるまで render しない */} - {clientHeight !== 0 ?
    {children}
    : null} +
    +
    {children}
    ); }; diff --git a/application/client/src/components/foundation/CoveredImage.tsx b/application/client/src/components/foundation/CoveredImage.tsx index 8ad9cc1f7d..6c51292ed1 100644 --- a/application/client/src/components/foundation/CoveredImage.tsx +++ b/application/client/src/components/foundation/CoveredImage.tsx @@ -1,70 +1,61 @@ -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, 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 { + alt?: string; + decoding?: "async" | "auto" | "sync"; + fetchPriority?: "auto" | "high" | "low"; + loading?: "eager" | "lazy"; src: string; } /** * アスペクト比を維持したまま、要素のコンテンツボックス全体を埋めるように画像を拡大縮小します */ -export const CoveredImage = ({ src }: Props) => { +export const CoveredImage = ({ + alt = "", + decoding = "async", + fetchPriority = "auto", + loading = "lazy", + src, +}: Props) => { const dialogId = useId(); // ダイアログの背景をクリックしたときに投稿詳細ページに遷移しないようにする const handleDialogClick = useCallback((ev: MouseEvent) => { ev.stopPropagation(); }, []); - const { data, isLoading } = useFetch(src, fetchBinary); + const [description, setDescription] = useState(alt); - const imageSize = useMemo(() => { - return data != null ? sizeOf(Buffer.from(data)) : { height: 0, width: 0 }; - }, [data]); + const handleOpenDescription = useCallback(() => { + if (description !== "") { + return; + } - 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>((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; + void fetchBinary(src) + .then((data) => { + const exif = load(Buffer.from(data).toString("binary")); + const raw = exif?.["0th"]?.[ImageIFD.ImageDescription]; + if (raw != null) { + setDescription(new TextDecoder().decode(Buffer.from(raw, "binary"))); + } + }) + .catch(() => {}); + }, [description, src]); return ( -
    +
    {alt} imageRatio, - "w-full h-auto": containerRatio <= imageRatio, - }, - )} - src={blobUrl} + className="h-full w-full object-cover" + decoding={decoding} + fetchPriority={fetchPriority} + loading={loading} + src={src} /> @@ -80,7 +72,7 @@ export const CoveredImage = ({ src }: Props) => {

    画像の説明

    -

    {alt}

    +

    {description}

    diff --git a/application/client/src/components/foundation/SoundPlayer.tsx b/application/client/src/components/foundation/SoundPlayer.tsx index 2fb4784189..d92991561b 100644 --- a/application/client/src/components/foundation/SoundPlayer.tsx +++ b/application/client/src/components/foundation/SoundPlayer.tsx @@ -1,9 +1,8 @@ -import { ReactEventHandler, useCallback, useMemo, useRef, useState } from "react"; +import { ReactEventHandler, 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 { SoundWaveSVG } from "@web-speed-hackathon-2026/client/src/components/foundation/SoundWaveSVG"; -import { useFetch } from "@web-speed-hackathon-2026/client/src/hooks/use_fetch"; import { fetchBinary } from "@web-speed-hackathon-2026/client/src/utils/fetchers"; import { getSoundPath } from "@web-speed-hackathon-2026/client/src/utils/get_path"; @@ -12,11 +11,7 @@ interface Props { } export const SoundPlayer = ({ sound }: Props) => { - const { data, isLoading } = useFetch(getSoundPath(sound.id), fetchBinary); - - const blobUrl = useMemo(() => { - return data !== null ? URL.createObjectURL(new Blob([data])) : null; - }, [data]); + const [waveformData, setWaveformData] = useState(null); const [currentTimeRatio, setCurrentTimeRatio] = useState(0); const handleTimeUpdate = useCallback>((ev) => { @@ -31,19 +26,28 @@ export const SoundPlayer = ({ sound }: Props) => { if (isPlaying) { audioRef.current?.pause(); } else { - audioRef.current?.play(); + void audioRef.current?.play(); + if (waveformData === null) { + void fetchBinary(getSoundPath(sound.id)) + .then((data) => { + setWaveformData(data); + }) + .catch(() => {}); + } } return !isPlaying; }); - }, []); - - if (isLoading || data === null || blobUrl === null) { - return null; - } + }, [sound.id, waveformData]); return (
    -