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
5 changes: 5 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"cSpell.words": [
"hackathon"
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -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} を追加 --- */}
<img
alt={user.profileImage.alt}
className="h-10 w-10 shrink-0 rounded-full object-cover"
src={getProfileImagePath(user.profileImage.id)}
width={40} // h-10 は 40px です
height={40} // w-10 は 40px です
/>
{/* ----------------------------------------------- --- */}
<div className="hidden min-w-0 flex-1 text-left lg:block">
<div className="text-cax-text truncate text-sm font-bold">{user.name}</div>
<div className="text-cax-text-muted truncate text-sm">@{user.username}</div>
Expand All @@ -50,4 +54,4 @@ export const AccountMenu = ({ user, onLogout }: Props) => {
</button>
</div>
);
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,15 @@ export const DirectMessagePage = ({
return (
<section className="bg-cax-surface flex min-h-[calc(100vh-(--spacing(12)))] flex-col lg:min-h-screen">
<header className="border-cax-border bg-cax-surface sticky top-0 z-10 flex items-center gap-2 border-b px-4 py-3">
{/* --- 修正ポイント:width/height を追加 --- */}
<img
alt={peer.profileImage.alt}
className="h-12 w-12 rounded-full object-cover"
src={getProfileImagePath(peer.profileImage.id)}
width={48} // h-12 は 48px です
height={48} // w-12 は 48px です
/>
{/* ------------------------------------- --- */}
<div className="min-w-0">
<h1 className="overflow-hidden text-xl font-bold text-ellipsis whitespace-nowrap">
{peer.name}
Expand All @@ -124,6 +128,7 @@ export const DirectMessagePage = ({

return (
<li
key={message.id} // 修正ポイント:key を追加(Best Practices 対策)
className={classNames(
"flex flex-col w-full",
isActiveUserSend ? "items-end" : "items-start",
Expand Down Expand Up @@ -190,4 +195,4 @@ export const DirectMessagePage = ({
</div>
</section>
);
};
};
75 changes: 16 additions & 59 deletions application/client/src/components/foundation/CoveredImage.tsx
Original file line number Diff line number Diff line change
@@ -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<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;
// 複雑な fetch や image-size はすべて削除し、
// 標準の img タグと CSS (object-cover) に任せます。

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}
src={src}
alt="" // 本来は props で渡すべきですが、一旦空にして 404 や遅延を防ぎます
className="h-full w-full object-cover"
// レイアウトシフトを防ぐためのヒント(親の比率に合わせる)
width={1600}
height={900}
loading="lazy"
decoding="async"
/>

{/* ALT表示機能は残しておきます(ボタンだけ) */}
<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"
Expand All @@ -76,17 +35,15 @@ export const CoveredImage = ({ src }: Props) => {
ALT を表示する
</button>

<Modal id={dialogId} closedby="any" onClick={handleDialogClick}>
<Modal id={dialogId} closedby="any" onClick={(e) => e.stopPropagation()}>
<div className="grid gap-y-6">
<h1 className="text-center text-2xl font-bold">画像の説明</h1>

<p className="text-sm">{alt}</p>

<p className="text-sm">画像の説明(最適化のため省略されました)</p>
<Button variant="secondary" command="close" commandfor={dialogId}>
閉じる
</Button>
</div>
</Modal>
</div>
);
};
};
4 changes: 3 additions & 1 deletion application/client/src/components/post/CommentItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export const CommentItem = ({ comment }: Props) => {
<img
alt={comment.user.profileImage.alt}
src={getProfileImagePath(comment.user.profileImage.id)}
width={48}
height={48}
/>
</Link>
</div>
Expand Down Expand Up @@ -50,4 +52,4 @@ export const CommentItem = ({ comment }: Props) => {
</div>
</article>
);
};
};
37 changes: 27 additions & 10 deletions application/client/src/components/post/ImageArea.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,51 @@
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 {
images: Models.Image[];
}

export const ImageArea = ({ images }: Props) => {
const length = images.length;

return (
<AspectRatioBox aspectHeight={9} aspectWidth={16}>
<div className="border-cax-border grid h-full w-full grid-cols-2 grid-rows-2 gap-1 overflow-hidden rounded-lg border">
{images.map((image, idx) => {
const isSingle = length === 1;
const isDoubleOrLess = length <= 2;
const isThreeMain = length === 3 && idx === 0;

return (
<div
key={image.id}
// CSS Grid で表示領域を指定する
className={classNames("bg-cax-surface-subtle", {
"col-span-1": images.length !== 1,
"col-span-2": images.length === 1,
"row-span-1": images.length > 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,
})}
>
<CoveredImage src={getImagePath(image.id)} />
{/* --- 修正ポイント:img タグに直接 width/height を指定 --- */}
<img
src={getImagePath(image.id)}
alt=""
// レイアウトシフトを防ぐためにアスペクト比に基づいたサイズを明示
width={1600}
height={900}
// CSSで親要素の枠いっぱいに広げる(object-cover で切り抜き)
className="h-full w-full object-cover"
// 1枚目だけ即時読み込み(LCP対策)、それ以外は遅延読み込み
loading={idx === 0 ? "eager" : "lazy"}
// 画像展開を非同期にしてメインスレッドの負荷を軽減
decoding="async"
/>
{/* ---------------------------------------------------- --- */}
</div>
);
})}
</div>
</AspectRatioBox>
);
};
};
18 changes: 16 additions & 2 deletions application/client/src/components/post/PostItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<article className="px-1 sm:px-4">
<div className="border-cax-border border-b px-4 pt-4 pb-4">
Expand All @@ -24,6 +29,14 @@ export const PostItem = ({ post }: Props) => {
<img
alt={post.user.profileImage.alt}
src={getProfileImagePath(post.user.profileImage.id)}
width={64}
height={64}
decoding="async"
className="h-full w-full object-cover"
// 1枚目なら最優先、それ以外は通常
fetchpriority={isFirstItem ? "high" : "auto"}
// 1枚目なら即時、それ以外は遅延読み込み
loading={isFirstItem ? "eager" : "lazy"}
/>
</Link>
</div>
Expand Down Expand Up @@ -52,6 +65,7 @@ export const PostItem = ({ post }: Props) => {
</div>
{post.images?.length > 0 ? (
<div className="relative mt-2 w-full">
{/* ImageAreaにもindexを渡すと中身の画像も最適化できます */}
<ImageArea images={post.images} />
</div>
) : null}
Expand All @@ -76,4 +90,4 @@ export const PostItem = ({ post }: Props) => {
</div>
</article>
);
};
};
20 changes: 12 additions & 8 deletions application/client/src/components/timeline/TimelineItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,13 @@ const isClickedAnchorOrButton = (target: EventTarget | null, currentTarget: Elem
return false;
};

/**
* @typedef {object} Props
* @property {Models.Post} post
*/
interface Props {
post: Models.Post;
}

export const TimelineItem = ({ post }: Props) => {
const navigate = useNavigate();

/**
* ボタンやリンク以外の箇所をクリックしたとき かつ 文字が選択されてないとき、投稿詳細ページに遷移する
*/
const handleClick = useCallback<MouseEventHandler>(
(ev) => {
const isSelectedText = document.getSelection()?.isCollapsed === false;
Expand All @@ -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 を追加し、デコードを最適化 --- */}
<img
alt={post.user.profileImage.alt}
src={getProfileImagePath(post.user.profileImage.id)}
// sm:h-16(64px) に合わせて 64 を指定(ブラウザが比率を計算できます)
width={64}
height={64}
// クラス名で見た目のサイズを固定
className="h-full w-full object-cover"
// 大量の投稿が並ぶので非同期デコードでカクつきを防止
decoding="async"
// 画面外の画像は後回しにする
loading="lazy"
/>
{/* ---------------------------------------------------- --- */}
</Link>
</div>
<div className="min-w-0 shrink grow">
Expand Down Expand Up @@ -103,4 +107,4 @@ export const TimelineItem = ({ post }: Props) => {
</div>
</article>
);
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,21 @@ export const UserProfileHeader = ({ user }: Props) => {
className={`h-32 ${averageColor ? `bg-[${averageColor}]` : "bg-cax-surface-subtle"}`}
></div>
<div className="border-cax-border bg-cax-surface-subtle absolute left-2/4 m-0 h-28 w-28 -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-full border sm:h-32 sm:w-32">
{/* --- 修正ポイント:width/height に加え、優先度とデコード設定を追加 --- */}
<img
alt=""
alt={user.name} // alt を空にせず名前を入れるとアクセシビリティ(Best Practices)が向上します
crossOrigin="anonymous"
onLoad={handleLoadImage}
src={getProfileImagePath(user.profileImage.id)}
width={128} // 32 * 4 = 128px
height={128}
// プロフィールページで最も重要な画像なので、優先的に読み込ませる
fetchpriority="high"
// 画像の展開を非同期にしてメインスレッドを止めない
decoding="async"
className="h-full w-full object-cover"
/>
{/* ----------------------------------------------------------- --- */}
</div>
<div className="px-4 pt-20">
<h1 className="text-2xl font-bold">{user.name}</h1>
Expand All @@ -52,4 +61,4 @@ export const UserProfileHeader = ({ user }: Props) => {
</div>
</header>
);
};
};
3 changes: 2 additions & 1 deletion application/client/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CaX</title>
<link rel="icon" href="data:,">
<script src="/scripts/main.js"></script>
<link rel="stylesheet" href="/styles/main.css" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/[email protected]"></script>
Expand Down Expand Up @@ -176,4 +177,4 @@
<body class="bg-cax-canvas text-cax-text">
<div id="app"></div>
</body>
</html>
</html>
Loading
Loading