diff --git a/frontend/src/App.module.css b/frontend/src/App.module.css
index 3b02c627..05eb2765 100644
--- a/frontend/src/App.module.css
+++ b/frontend/src/App.module.css
@@ -17,6 +17,14 @@
left: 0;
bottom: 0;
z-index: 100;
+ transition: transform 0.2s ease;
+}
+
+/* タイトル行 + 折りたたみボタンを横並びにするヘッダー。 */
+.sidebarHeader {
+ display: flex;
+ align-items: stretch;
+ border-bottom: 1px solid var(--sidebar-border);
}
.sidebarTitle {
@@ -24,10 +32,65 @@
font-weight: 700;
padding: 0 1.2rem 1rem;
margin: 0;
- border-bottom: 1px solid var(--sidebar-border);
+ flex: 1;
color: var(--sidebar-title);
}
+/* サイドバーを折りたたむボタン(ヘッダー右端)。 */
+.sidebarCollapseButton {
+ background: none;
+ border: none;
+ color: var(--sidebar-text);
+ font-size: 1.1rem;
+ line-height: 1;
+ padding: 0 1rem;
+ cursor: pointer;
+ transition: color 0.15s, background 0.15s;
+}
+
+.sidebarCollapseButton:hover {
+ color: var(--sidebar-hover-text);
+ background: var(--sidebar-hover-bg);
+}
+
+/* 折りたたみ時: サイドバーを画面外へスライドし、本文を全幅化。
+ visibility:hidden / pointer-events:none で内部のリンク・ボタンを
+ タブ順とポインタ操作の両方から除外する(画面外でも focus 可能なまま残さない)。
+ visibility はスライドアウト完了後に切り替わるよう遅延させ、展開時は即座に戻す。 */
+.sidebarCollapsed .sidebar {
+ transform: translateX(-100%);
+ visibility: hidden;
+ pointer-events: none;
+ transition: transform 0.2s ease, visibility 0s linear 0.2s;
+}
+
+.sidebarCollapsed .mainContent {
+ margin-left: 0;
+}
+
+/* 折りたたみ中に表示する再展開ボタン(左上に固定)。 */
+.sidebarOpenButton {
+ position: fixed;
+ top: 0.6rem;
+ left: 0.6rem;
+ z-index: 110;
+ background: var(--sidebar-bg);
+ color: var(--sidebar-text);
+ border: 1px solid var(--sidebar-border);
+ border-radius: 6px;
+ padding: 0.3rem 0.65rem;
+ font-size: 1rem;
+ line-height: 1;
+ cursor: pointer;
+ box-shadow: var(--shadow-card);
+ transition: color 0.15s, background 0.15s;
+}
+
+.sidebarOpenButton:hover {
+ color: var(--sidebar-hover-text);
+ background: var(--sidebar-hover-bg);
+}
+
.sidebarNav {
display: flex;
flex-direction: column;
@@ -138,6 +201,7 @@
overflow-y: auto;
height: 100vh;
background: var(--bg-page);
+ transition: margin-left 0.2s ease;
}
@media (max-width: 768px) {
@@ -160,14 +224,21 @@
z-index: 100;
}
- .sidebarTitle {
- padding: 0.8rem 1rem;
+ .sidebarHeader {
border-bottom: none;
border-right: 1px solid var(--sidebar-border);
+ }
+
+ .sidebarTitle {
+ padding: 0.8rem 1rem;
white-space: nowrap;
font-size: 1rem;
}
+ .sidebarCollapseButton {
+ padding: 0 0.7rem;
+ }
+
.sidebarNav {
flex-direction: row;
gap: 0;
@@ -198,4 +269,9 @@
margin-left: 0;
margin-top: var(--topbar-height);
}
+
+ /* モバイルでも折りたたみ時は本文を最上部まで広げる。 */
+ .sidebarCollapsed .mainContent {
+ margin-top: 0;
+ }
}
diff --git a/frontend/src/components/AuthenticatedLayout.tsx b/frontend/src/components/AuthenticatedLayout.tsx
index e0a975c0..2bdfa7d5 100644
--- a/frontend/src/components/AuthenticatedLayout.tsx
+++ b/frontend/src/components/AuthenticatedLayout.tsx
@@ -3,6 +3,7 @@ import { NavLink, Outlet, useNavigate } from "react-router-dom";
import type { AuthUser } from "../router/guards";
import type { Theme } from "../hooks/useTheme";
+import { UI_MESSAGES } from "../constants/messages";
import { NotificationBell } from "./NotificationBell";
import { UserMenu } from "./UserMenu";
import { ChevronDownIcon } from "./icons/ChevronDownIcon";
@@ -28,6 +29,8 @@ export function AuthenticatedLayout({
// GitHub 連携オプション(フォーク含む)の開閉とチェック状態。
const [githubOptionsOpen, setGithubOptionsOpen] = useState(false);
const [includeForks, setIncludeForks] = useState(false);
+ // サイドバーの折りたたみ状態。折りたたむと本文領域が全幅に広がる。
+ const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
/**
* GitHub 連携を実行する。
@@ -45,9 +48,32 @@ export function AuthenticatedLayout({
return (
diff --git a/frontend/src/constants/messages.ts b/frontend/src/constants/messages.ts
index c71e4c03..daf5bbea 100644
--- a/frontend/src/constants/messages.ts
+++ b/frontend/src/constants/messages.ts
@@ -83,6 +83,10 @@ export const UI_MESSAGES = {
"ページの表示中に問題が発生しました。再読み込みするか、ホームへ戻ってください。",
GITHUB_LINK_EMPTY:
"まだ連携データがありません。連携してアクティビティを可視化しましょう。",
+ SIDEBAR_COLLAPSE: "サイドバーを閉じる",
+ SIDEBAR_EXPAND: "サイドバーを開く",
+ PDF_PANEL_COLLAPSE: "PDFパネルを閉じる",
+ PDF_PANEL_EXPAND: "PDFパネルを開く",
} as const;
/** PDF 取り込み補助(PDF ビュー上の選択 → 流し込み)UI の文言 */
@@ -96,6 +100,9 @@ export const IMPORT_ASSIST_MESSAGES = {
NO_TEXT:
"このPDFから文字を選択できませんでした(スキャンPDFの可能性があります)。文字を選択できるPDFをお試しください。",
RENDER_FAILED: "PDFの表示に失敗しました。別のファイルをお試しください。",
+ /** ファイルサイズが上限を超えた場合(ブラウザのフリーズ/メモリ枯渇を防ぐためのガード)。 */
+ TOO_LARGE: (limitMb: number) =>
+ `ファイルサイズが大きすぎます(上限${limitMb}MB)。ページ数の少ないPDFや、軽量化したPDFをお試しください。`,
NO_TARGET: "先にフォームの入力欄をクリックして、流し込み先を選んでください。",
TAB_FALLBACK: "PDF",
ZOOM_IN: "拡大",
diff --git a/frontend/src/hooks/career/useResumeImportAssist.test.ts b/frontend/src/hooks/career/useResumeImportAssist.test.ts
index 830f8737..0fcdd004 100644
--- a/frontend/src/hooks/career/useResumeImportAssist.test.ts
+++ b/frontend/src/hooks/career/useResumeImportAssist.test.ts
@@ -38,6 +38,39 @@ describe("useResumeImportAssist", () => {
expect(result.current.fileName).toBe("resume.pdf");
});
+ it("20MB を超える巨大ファイルは弾いてエラーを出す(file は保持しない)", () => {
+ const { result } = renderHook(() => useResumeImportAssist());
+ // 実バイト列を確保せず size だけ巨大に見せる(描画ガードの検証が目的)。
+ const huge = new File(["%PDF-1.4"], "huge.pdf", { type: "application/pdf" });
+ Object.defineProperty(huge, "size", { value: 21 * 1024 * 1024, configurable: true });
+
+ act(() => result.current.handleFileChange(makeChangeEvent(huge)));
+
+ expect(result.current.file).toBeNull();
+ expect(result.current.fileName).toBeNull();
+ expect(result.current.error).toBe(IMPORT_ASSIST_MESSAGES.TOO_LARGE(20));
+ });
+
+ it("有効な PDF 選択後に巨大ファイルを選ぶと、直前の選択をクリアする", () => {
+ const { result } = renderHook(() => useResumeImportAssist());
+ const valid = new File(["%PDF-1.4"], "resume.pdf", { type: "application/pdf" });
+
+ act(() => result.current.handleFileChange(makeChangeEvent(valid)));
+ expect(result.current.file).toBe(valid);
+ expect(result.current.fileName).toBe("resume.pdf");
+
+ // 実バイト列を確保せず size だけ巨大に見せる(描画ガードの検証が目的)。
+ const huge = new File(["%PDF-1.4"], "huge.pdf", { type: "application/pdf" });
+ Object.defineProperty(huge, "size", { value: 21 * 1024 * 1024, configurable: true });
+
+ act(() => result.current.handleFileChange(makeChangeEvent(huge)));
+
+ // 古い PDF が裏で描画され続けないよう、選択状態がクリアされていること。
+ expect(result.current.file).toBeNull();
+ expect(result.current.fileName).toBeNull();
+ expect(result.current.error).toBe(IMPORT_ASSIST_MESSAGES.TOO_LARGE(20));
+ });
+
it("fillSelection はフォーカス中の入力欄へ流し込む", () => {
const { result } = renderHook(() => useResumeImportAssist());
const input = document.createElement("input");
diff --git a/frontend/src/hooks/career/useResumeImportAssist.ts b/frontend/src/hooks/career/useResumeImportAssist.ts
index 9d74e52c..63ec3d87 100644
--- a/frontend/src/hooks/career/useResumeImportAssist.ts
+++ b/frontend/src/hooks/career/useResumeImportAssist.ts
@@ -15,6 +15,18 @@ import { IMPORT_ASSIST_MESSAGES } from "../../constants/messages";
* フォーカスを戻し、選択状態(:focus の緑枠)を維持して連続流し込みできるようにする。
*/
+/**
+ * 取り込み可能な PDF の最大バイト数(20MB)。
+ *
+ * このガードは Cloud Run のコスト対策ではない(取り込みは pdf.js による
+ * ブラウザ内描画のみで、PDF バイト列はサーバーに送信されない)。目的は
+ * 巨大ファイルを {@link PdfDocumentView} が全ページ一括レンダリングして
+ * ブラウザがフリーズ/OOM するのを防ぐこと。職務経歴書 PDF は通常数 MB のため、
+ * 明らかに異常な巨大ファイルだけを弾く緩めの閾値にしている。
+ */
+const MAX_FILE_BYTES = 20 * 1024 * 1024;
+const MAX_FILE_MB = MAX_FILE_BYTES / (1024 * 1024);
+
/** controlled input/textarea に値を流し込み、React の onChange を発火させる。 */
function assignToElement(el: HTMLInputElement | HTMLTextAreaElement, text: string): void {
const isTextarea = el.tagName === "TEXTAREA";
@@ -81,6 +93,15 @@ export function useResumeImportAssist(): UseResumeImportAssistReturn {
const selected = e.target.files?.[0];
e.target.value = ""; // 同じファイルを再選択できるようにリセット
if (!selected) return;
+ // 巨大ファイルは pdf.js の全ページ描画でブラウザがフリーズするため、描画前に弾く。
+ if (selected.size > MAX_FILE_BYTES) {
+ // 前回選択した PDF が残ったままだと、エラー表示の裏で古い原本が描画され続けるため
+ // 選択状態をクリアしてからエラーを出す。
+ setFile(null);
+ setFileName(null);
+ setError(IMPORT_ASSIST_MESSAGES.TOO_LARGE(MAX_FILE_MB));
+ return;
+ }
setError(null);
setFile(selected);
setFileName(selected.name);
diff --git a/frontend/src/styles/shared.module.css b/frontend/src/styles/shared.module.css
index 7d05d84d..d9b957f7 100644
--- a/frontend/src/styles/shared.module.css
+++ b/frontend/src/styles/shared.module.css
@@ -217,4 +217,13 @@
.pageBody {
padding: 1rem;
}
+
+ /* 狭い画面では各セクションの余白を詰めて入力欄の表示幅を確保する。 */
+ .section {
+ padding: 0.8rem;
+ }
+
+ .form {
+ gap: 0.8rem;
+ }
}