From 284350e5fe0874341dab5b67c3879fc362ddae5f Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Sun, 31 May 2026 13:52:25 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=E3=82=B5=E3=82=A4=E3=83=89=E3=83=90?= =?UTF-8?q?=E3=83=BC=E6=8A=98=E3=82=8A=E7=95=B3=E3=81=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.module.css | 76 ++++++++++++++++++- .../src/components/AuthenticatedLayout.tsx | 30 +++++++- .../forms/CareerResumeForm.module.css | 51 +++++++++++++ .../src/components/forms/CareerResumeForm.tsx | 38 +++++++--- frontend/src/constants/messages.ts | 4 + frontend/src/styles/shared.module.css | 9 +++ 6 files changed, 192 insertions(+), 16 deletions(-) diff --git a/frontend/src/App.module.css b/frontend/src/App.module.css index 3b02c627..94af66da 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,59 @@ 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); +} + +/* 折りたたみ時: サイドバーを画面外へスライドし、本文を全幅化。 */ +.sidebarCollapsed .sidebar { + transform: translateX(-100%); +} + +.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 +195,7 @@ overflow-y: auto; height: 100vh; background: var(--bg-page); + transition: margin-left 0.2s ease; } @media (max-width: 768px) { @@ -160,14 +218,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 +263,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..3d91ade9 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 (
-
+
+ {/* 折りたたみ中のみ表示する、サイドバーを再展開するための固定ボタン。 */} + {sidebarCollapsed && ( + + )}
- {/* 中央: ドラッグでカラム幅を変えるスプリッター */} -
+ {/* 中央: ドラッグでカラム幅を変えるスプリッター(折りたたみ時は非表示)。 */} + {!pdfCollapsed && ( +
+ )} {/* 右: PDF 原本ビュー(独立スクロール)。文字を選択して入力欄へ流し込む。 - 幅は CSS 変数 --pdf-col-width を CSS 側で参照(縦積み時は全幅へ上書き)。 */} -
diff --git a/frontend/src/constants/messages.ts b/frontend/src/constants/messages.ts index c71e4c03..2b23e8d6 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 の文言 */ 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; + } } From 93e0881770a641951b8669442232b1364bac25e8 Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Sun, 31 May 2026 14:16:07 +0900 Subject: [PATCH 2/3] heavy PDF guard --- frontend/src/constants/messages.ts | 3 +++ .../hooks/career/useResumeImportAssist.test.ts | 13 +++++++++++++ .../src/hooks/career/useResumeImportAssist.ts | 17 +++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/frontend/src/constants/messages.ts b/frontend/src/constants/messages.ts index 2b23e8d6..daf5bbea 100644 --- a/frontend/src/constants/messages.ts +++ b/frontend/src/constants/messages.ts @@ -100,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..d6e194c8 100644 --- a/frontend/src/hooks/career/useResumeImportAssist.test.ts +++ b/frontend/src/hooks/career/useResumeImportAssist.test.ts @@ -38,6 +38,19 @@ 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("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..28b3cce9 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,11 @@ export function useResumeImportAssist(): UseResumeImportAssistReturn { const selected = e.target.files?.[0]; e.target.value = ""; // 同じファイルを再選択できるようにリセット if (!selected) return; + // 巨大ファイルは pdf.js の全ページ描画でブラウザがフリーズするため、描画前に弾く。 + if (selected.size > MAX_FILE_BYTES) { + setError(IMPORT_ASSIST_MESSAGES.TOO_LARGE(MAX_FILE_MB)); + return; + } setError(null); setFile(selected); setFileName(selected.name); From 7bb6b9a764b9c4590770511559e7d4335e916eb2 Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Sun, 31 May 2026 17:02:33 +0900 Subject: [PATCH 3/3] infra ref --- frontend/src/App.module.css | 8 +++++++- .../src/components/AuthenticatedLayout.tsx | 2 +- .../forms/CareerResumeForm.module.css | 18 +++++++++++++++++ .../src/components/forms/CareerResumeForm.tsx | 13 ++++++++++++ .../career/useResumeImportAssist.test.ts | 20 +++++++++++++++++++ .../src/hooks/career/useResumeImportAssist.ts | 4 ++++ 6 files changed, 63 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.module.css b/frontend/src/App.module.css index 94af66da..05eb2765 100644 --- a/frontend/src/App.module.css +++ b/frontend/src/App.module.css @@ -53,9 +53,15 @@ 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 { diff --git a/frontend/src/components/AuthenticatedLayout.tsx b/frontend/src/components/AuthenticatedLayout.tsx index 3d91ade9..2bdfa7d5 100644 --- a/frontend/src/components/AuthenticatedLayout.tsx +++ b/frontend/src/components/AuthenticatedLayout.tsx @@ -62,7 +62,7 @@ export function AuthenticatedLayout({ » )} -
diff --git a/frontend/src/hooks/career/useResumeImportAssist.test.ts b/frontend/src/hooks/career/useResumeImportAssist.test.ts index d6e194c8..0fcdd004 100644 --- a/frontend/src/hooks/career/useResumeImportAssist.test.ts +++ b/frontend/src/hooks/career/useResumeImportAssist.test.ts @@ -51,6 +51,26 @@ describe("useResumeImportAssist", () => { 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 28b3cce9..63ec3d87 100644 --- a/frontend/src/hooks/career/useResumeImportAssist.ts +++ b/frontend/src/hooks/career/useResumeImportAssist.ts @@ -95,6 +95,10 @@ export function useResumeImportAssist(): UseResumeImportAssistReturn { 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; }