Skip to content
Merged
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
82 changes: 79 additions & 3 deletions frontend/src/App.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,80 @@
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 {
font-size: 1.1rem;
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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

.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;
Expand Down Expand Up @@ -138,6 +201,7 @@
overflow-y: auto;
height: 100vh;
background: var(--bg-page);
transition: margin-left 0.2s ease;
}

@media (max-width: 768px) {
Expand All @@ -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;
Expand Down Expand Up @@ -198,4 +269,9 @@
margin-left: 0;
margin-top: var(--topbar-height);
}

/* モバイルでも折りたたみ時は本文を最上部まで広げる。 */
.sidebarCollapsed .mainContent {
margin-top: 0;
}
}
32 changes: 29 additions & 3 deletions frontend/src/components/AuthenticatedLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -28,6 +29,8 @@ export function AuthenticatedLayout({
// GitHub 連携オプション(フォーク含む)の開閉とチェック状態。
const [githubOptionsOpen, setGithubOptionsOpen] = useState(false);
const [includeForks, setIncludeForks] = useState(false);
// サイドバーの折りたたみ状態。折りたたむと本文領域が全幅に広がる。
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);

/**
* GitHub 連携を実行する。
Expand All @@ -45,9 +48,32 @@ export function AuthenticatedLayout({

return (
<div className={shared.page}>
<div className={styles.appLayout}>
<aside className={styles.sidebar}>
<p className={styles.sidebarTitle}>DevForge</p>
<div
className={`${styles.appLayout} ${sidebarCollapsed ? styles.sidebarCollapsed : ""}`}
>
{/* 折りたたみ中のみ表示する、サイドバーを再展開するための固定ボタン。 */}
{sidebarCollapsed && (
<button
type="button"
className={styles.sidebarOpenButton}
aria-label={UI_MESSAGES.SIDEBAR_EXPAND}
onClick={() => setSidebarCollapsed(false)}
>
»
</button>
)}
<aside className={styles.sidebar} aria-hidden={sidebarCollapsed}>
<div className={styles.sidebarHeader}>
<p className={styles.sidebarTitle}>DevForge</p>
<button
type="button"
className={styles.sidebarCollapseButton}
aria-label={UI_MESSAGES.SIDEBAR_COLLAPSE}
onClick={() => setSidebarCollapsed(true)}
>
«
</button>
</div>
<nav className={styles.sidebarNav}>
<NavLink
to="/career"
Expand Down
69 changes: 69 additions & 0 deletions frontend/src/components/forms/CareerResumeForm.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,59 @@
padding-left: 1rem;
}

/* PDF パネルの開閉トグル。展開時はカラム右上に小さく配置する。 */
.pdfToggle {
align-self: flex-end;
flex-shrink: 0;
margin-bottom: 0.4rem;
padding: 0.15rem 0.5rem;
font-size: 0.85rem;
line-height: 1;
border: 1px solid var(--border-input);
border-radius: 4px;
background: var(--bg-input);
color: var(--text-primary);
cursor: pointer;
}

.pdfToggle:hover {
background: var(--ghost-hover);
}

/* 折りたたみ時: 幅を CSS 変数に依らず固定の細いレールにし、トグルを縦書きで全高表示する。
panel 本体は JSX 側で非レンダリングなので、ここはレールの見た目だけを定義する。 */
.pdfColCollapsed {
position: relative;
width: 2.5rem;
align-items: center;
padding-left: 0;
}

.pdfColCollapsed .pdfToggle {
writing-mode: vertical-rl;
align-self: center;
margin-bottom: 0;
padding: 0.6rem 0.2rem;
height: 100%;
}

/* 折りたたみレールに表示する取り込み補助エラーのインジケータ。
トグルの上に小さな赤丸として固定し、クリックでパネルを展開する。 */
.collapsedError {
position: absolute;
top: 0.4rem;
align-self: center;
width: 1.4rem;
height: 1.4rem;
border: none;
border-radius: 50%;
background: var(--error, #d33);
color: #fff;
font-weight: 700;
line-height: 1;
cursor: pointer;
}

/* 横幅が足りない時(2 カラムを並べると最小幅を割る幅)は縦積みに切り替える。
閾値 = PDF 最小(280) + フォーム最小(360) + gap/splitter(38) ≒ 680px。
これ未満では左右に並べず、フォームの下に PDF を全幅で積む(要素が切れない)。 */
Expand Down Expand Up @@ -174,4 +227,20 @@
padding-left: 0;
padding-top: 1rem;
}

/* 縦積みかつ折りたたみ時は、全幅の薄いバーにしてトグルを横書きへ戻す
(細い縦レールのままだと縦積みレイアウトで不自然なため)。 */
.pdfColCollapsed {
width: 100%;
min-height: auto;
align-items: stretch;
padding-top: 0.5rem;
}

.pdfColCollapsed .pdfToggle {
writing-mode: horizontal-tb;
align-self: flex-start;
height: auto;
padding: 0.3rem 0.6rem;
}
}
51 changes: 40 additions & 11 deletions frontend/src/components/forms/CareerResumeForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
getLatestCareerResume,
updateCareerResume,
} from "../../api";
import { IMPORT_ASSIST_MESSAGES } from "../../constants/messages";
import { IMPORT_ASSIST_MESSAGES, UI_MESSAGES } from "../../constants/messages";
import { createInitialCareerForm, mapCareerResumeToForm } from "../../formMappers";
import { useCareerDirty } from "../../hooks/career/useCareerDirty";
import { usePdfPanelLayout } from "../../hooks/career/usePdfPanelLayout";
Expand All @@ -32,6 +32,8 @@ import { CareerSelfPrSection } from "./sections/CareerSelfPrSection";

export function CareerResumeForm() {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
// PDF 原本ビュー(右カラム)の折りたたみ状態。折りたたむと入力フォームが全幅に広がる。
const [pdfCollapsed, setPdfCollapsed] = useState(false);
const assist = useResumeImportAssist();
const pdfInputRef = useRef<HTMLInputElement>(null);
const splitRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -241,18 +243,45 @@ export function CareerResumeForm() {
/>
</div>

{/* 中央: ドラッグでカラム幅を変えるスプリッター */}
<div
className={layout.splitter}
role="separator"
aria-orientation="vertical"
onMouseDown={startResize}
/>
{/* 中央: ドラッグでカラム幅を変えるスプリッター(折りたたみ時は非表示)。 */}
{!pdfCollapsed && (
<div
className={layout.splitter}
role="separator"
aria-orientation="vertical"
onMouseDown={startResize}
/>
)}

{/* 右: PDF 原本ビュー(独立スクロール)。文字を選択して入力欄へ流し込む。
幅は CSS 変数 --pdf-col-width を CSS 側で参照(縦積み時は全幅へ上書き)。 */}
<aside className={layout.pdfCol}>
<ResumePdfTracePanel assist={assist} />
幅は CSS 変数 --pdf-col-width を CSS 側で参照(縦積み時は全幅へ上書き)。
折りたたみ時は細いレールになり、トグルだけを表示する。 */}
<aside className={`${layout.pdfCol} ${pdfCollapsed ? layout.pdfColCollapsed : ""}`}>
<button
type="button"
className={layout.pdfToggle}
onClick={() => setPdfCollapsed((v) => !v)}
aria-label={
pdfCollapsed ? UI_MESSAGES.PDF_PANEL_EXPAND : UI_MESSAGES.PDF_PANEL_COLLAPSE
}
aria-expanded={!pdfCollapsed}
>
{pdfCollapsed ? "‹ PDF" : "›"}
</button>
{/* 折りたたみ中でも取り込み補助のエラー(サイズ超過・パース失敗)は隠さない。
クリックでパネルを展開し、全文を ResumePdfTracePanel で表示できるようにする。 */}
{pdfCollapsed && assist.error && (
<button
type="button"
className={layout.collapsedError}
onClick={() => setPdfCollapsed(false)}
title={assist.error}
aria-label={UI_MESSAGES.PDF_PANEL_EXPAND}
>
!
</button>
)}
{!pdfCollapsed && <ResumePdfTracePanel assist={assist} />}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</aside>
</div>
</div>
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/constants/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 の文言 */
Expand All @@ -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: "拡大",
Expand Down
33 changes: 33 additions & 0 deletions frontend/src/hooks/career/useResumeImportAssist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading
Loading