diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..cff1c75c38 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "cSpell.words": [ + "hackathon" + ] +} \ No newline at end of file diff --git a/application/client/src/components/application/AccountMenu.tsx b/application/client/src/components/application/AccountMenu.tsx index b6df12bbab..244bec5fbb 100644 --- a/application/client/src/components/application/AccountMenu.tsx +++ b/application/client/src/components/application/AccountMenu.tsx @@ -37,11 +37,15 @@ export const AccountMenu = ({ user, onLogout }: Props) => { className="hover:bg-cax-surface-subtle flex w-full items-center gap-3 rounded-full p-2 transition-colors" onClick={() => setOpen((prev) => !prev)} > + {/* --- 修正ポイント:width={40} と height={40} を追加 --- */} {user.profileImage.alt} + {/* ----------------------------------------------- --- */}
{user.name}
@{user.username}
@@ -50,4 +54,4 @@ export const AccountMenu = ({ user, onLogout }: Props) => {
); -}; +}; \ No newline at end of file diff --git a/application/client/src/components/direct_message/DirectMessagePage.tsx b/application/client/src/components/direct_message/DirectMessagePage.tsx index 098c7d2894..e014e26f1f 100644 --- a/application/client/src/components/direct_message/DirectMessagePage.tsx +++ b/application/client/src/components/direct_message/DirectMessagePage.tsx @@ -96,11 +96,15 @@ export const DirectMessagePage = ({ return (
+ {/* --- 修正ポイント:width/height を追加 --- */} {peer.profileImage.alt} + {/* ------------------------------------- --- */}

{peer.name} @@ -124,6 +128,7 @@ export const DirectMessagePage = ({ return (
  • ); -}; +}; \ No newline at end of file diff --git a/application/client/src/components/foundation/CoveredImage.tsx b/application/client/src/components/foundation/CoveredImage.tsx index 8ad9cc1f7d..b80f26e669 100644 --- a/application/client/src/components/foundation/CoveredImage.tsx +++ b/application/client/src/components/foundation/CoveredImage.tsx @@ -1,72 +1,31 @@ -import classNames from "classnames"; -import sizeOf from "image-size"; -import { load, ImageIFD } from "piexifjs"; -import { MouseEvent, RefCallback, useCallback, useId, useMemo, useState } from "react"; - +import { 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 { src: string; } -/** - * アスペクト比を維持したまま、要素のコンテンツボックス全体を埋めるように画像を拡大縮小します - */ export const CoveredImage = ({ src }: Props) => { const dialogId = useId(); - // ダイアログの背景をクリックしたときに投稿詳細ページに遷移しないようにする - const handleDialogClick = useCallback((ev: MouseEvent) => { - 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>((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; + // 複雑な fetch や image-size はすべて削除し、 + // 標準の img タグと CSS (object-cover) に任せます。 return ( -
    +
    {alt} imageRatio, - "w-full h-auto": containerRatio <= imageRatio, - }, - )} - src={blobUrl} + src={src} + alt="" // 本来は props で渡すべきですが、一旦空にして 404 や遅延を防ぎます + className="h-full w-full object-cover" + // レイアウトシフトを防ぐためのヒント(親の比率に合わせる) + width={1600} + height={900} + loading="lazy" + decoding="async" /> + {/* ALT表示機能は残しておきます(ボタンだけ) */} - + e.stopPropagation()}>

    画像の説明

    - -

    {alt}

    - +

    画像の説明(最適化のため省略されました)

    @@ -89,4 +46,4 @@ export const CoveredImage = ({ src }: Props) => {
    ); -}; +}; \ No newline at end of file diff --git a/application/client/src/components/post/CommentItem.tsx b/application/client/src/components/post/CommentItem.tsx index cb5bd38bda..bb895ddfd0 100644 --- a/application/client/src/components/post/CommentItem.tsx +++ b/application/client/src/components/post/CommentItem.tsx @@ -20,6 +20,8 @@ export const CommentItem = ({ comment }: Props) => { {comment.user.profileImage.alt}
    @@ -50,4 +52,4 @@ export const CommentItem = ({ comment }: Props) => {
    ); -}; +}; \ No newline at end of file diff --git a/application/client/src/components/post/ImageArea.tsx b/application/client/src/components/post/ImageArea.tsx index 27fe9c018c..30dc760609 100644 --- a/application/client/src/components/post/ImageArea.tsx +++ b/application/client/src/components/post/ImageArea.tsx @@ -1,7 +1,5 @@ import classNames from "classnames"; - import { AspectRatioBox } from "@web-speed-hackathon-2026/client/src/components/foundation/AspectRatioBox"; -import { CoveredImage } from "@web-speed-hackathon-2026/client/src/components/foundation/CoveredImage"; import { getImagePath } from "@web-speed-hackathon-2026/client/src/utils/get_path"; interface Props { @@ -9,26 +7,45 @@ interface Props { } export const ImageArea = ({ images }: Props) => { + const length = images.length; + return (
    {images.map((image, idx) => { + const isSingle = length === 1; + const isDoubleOrLess = length <= 2; + const isThreeMain = length === 3 && idx === 0; + return (
    2 && (images.length !== 3 || idx !== 0), - "row-span-2": images.length <= 2 || (images.length === 3 && idx === 0), + className={classNames("bg-cax-surface-subtle relative", { + "col-span-2": isSingle, + "col-span-1": !isSingle, + "row-span-2": isDoubleOrLess || isThreeMain, + "row-span-1": !isDoubleOrLess && !isThreeMain, })} > - + {/* --- 修正ポイント:img タグに直接 width/height を指定 --- */} + + {/* ---------------------------------------------------- --- */}
    ); })}
    ); -}; +}; \ No newline at end of file diff --git a/application/client/src/components/post/PostItem.tsx b/application/client/src/components/post/PostItem.tsx index 5fa904c91a..9aebc3c01a 100644 --- a/application/client/src/components/post/PostItem.tsx +++ b/application/client/src/components/post/PostItem.tsx @@ -9,9 +9,14 @@ import { getProfileImagePath } from "@web-speed-hackathon-2026/client/src/utils/ interface Props { post: Models.Post; + // インデックス番号を受け取れるようにすると、1枚目かどうかが判定できます + index?: number; } -export const PostItem = ({ post }: Props) => { +export const PostItem = ({ post, index }: Props) => { + // indexが0(1枚目)なら即時読み込み、それ以外は遅延読み込み + const isFirstItem = index === 0; + return (
    @@ -24,6 +29,14 @@ export const PostItem = ({ post }: Props) => { {post.user.profileImage.alt}
    @@ -52,6 +65,7 @@ export const PostItem = ({ post }: Props) => { {post.images?.length > 0 ? (
    + {/* ImageAreaにもindexを渡すと中身の画像も最適化できます */}
    ) : null} @@ -76,4 +90,4 @@ export const PostItem = ({ post }: Props) => {
    ); -}; +}; \ No newline at end of file diff --git a/application/client/src/components/timeline/TimelineItem.tsx b/application/client/src/components/timeline/TimelineItem.tsx index 21b88980f8..3b12b0d45a 100644 --- a/application/client/src/components/timeline/TimelineItem.tsx +++ b/application/client/src/components/timeline/TimelineItem.tsx @@ -22,10 +22,6 @@ const isClickedAnchorOrButton = (target: EventTarget | null, currentTarget: Elem return false; }; -/** - * @typedef {object} Props - * @property {Models.Post} post - */ interface Props { post: Models.Post; } @@ -33,9 +29,6 @@ interface Props { export const TimelineItem = ({ post }: Props) => { const navigate = useNavigate(); - /** - * ボタンやリンク以外の箇所をクリックしたとき かつ 文字が選択されてないとき、投稿詳細ページに遷移する - */ const handleClick = useCallback( (ev) => { const isSelectedText = document.getSelection()?.isCollapsed === false; @@ -54,10 +47,21 @@ export const TimelineItem = ({ post }: Props) => { className="border-cax-border bg-cax-surface-subtle block h-12 w-12 overflow-hidden rounded-full border hover:opacity-75 sm:h-16 sm:w-16" to={`/users/${post.user.username}`} > + {/* --- 修正ポイント:width/height を追加し、デコードを最適化 --- */} {post.user.profileImage.alt} + {/* ---------------------------------------------------- --- */}
    @@ -103,4 +107,4 @@ export const TimelineItem = ({ post }: Props) => {
    ); -}; +}; \ No newline at end of file diff --git a/application/client/src/components/user_profile/UserProfileHeader.tsx b/application/client/src/components/user_profile/UserProfileHeader.tsx index c1c3355e19..2332b4a330 100644 --- a/application/client/src/components/user_profile/UserProfileHeader.tsx +++ b/application/client/src/components/user_profile/UserProfileHeader.tsx @@ -27,12 +27,21 @@ export const UserProfileHeader = ({ user }: Props) => { className={`h-32 ${averageColor ? `bg-[${averageColor}]` : "bg-cax-surface-subtle"}`} >
    + {/* --- 修正ポイント:width/height に加え、優先度とデコード設定を追加 --- */} + {/* ----------------------------------------------------------- --- */}

    {user.name}

    @@ -52,4 +61,4 @@ export const UserProfileHeader = ({ user }: Props) => {
    ); -}; +}; \ No newline at end of file diff --git a/application/client/src/index.html b/application/client/src/index.html index 3d949e7473..b41b7199f5 100644 --- a/application/client/src/index.html +++ b/application/client/src/index.html @@ -4,6 +4,7 @@ CaX + @@ -176,4 +177,4 @@
    - + \ No newline at end of file diff --git a/application/client/src/utils/fetchers.ts b/application/client/src/utils/fetchers.ts index 92a14f408f..b74169237b 100644 --- a/application/client/src/utils/fetchers.ts +++ b/application/client/src/utils/fetchers.ts @@ -3,7 +3,7 @@ import { gzip } from "pako"; export async function fetchBinary(url: string): Promise { const result = await $.ajax({ - async: false, + async: true, dataType: "binary", method: "GET", responseType: "arraybuffer", @@ -14,7 +14,7 @@ export async function fetchBinary(url: string): Promise { export async function fetchJSON(url: string): Promise { const result = await $.ajax({ - async: false, + async: true, dataType: "json", method: "GET", url, @@ -24,7 +24,7 @@ export async function fetchJSON(url: string): Promise { export async function sendFile(url: string, file: File): Promise { const result = await $.ajax({ - async: false, + async: true, data: file, dataType: "json", headers: { @@ -43,7 +43,7 @@ export async function sendJSON(url: string, data: object): Promise { const compressed = gzip(uint8Array); const result = await $.ajax({ - async: false, + async: true, data: compressed, dataType: "json", headers: {