From 6e849f5aecb3232e1b3443e7dad54c9764721313 Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Mon, 29 Jun 2026 00:10:19 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(web):=20=E7=B5=8C=E6=AD=B4=E6=9B=B8=20?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E5=89=8D=E3=81=AE=E5=B7=AE=E5=88=86=E7=A2=BA?= =?UTF-8?q?=E8=AA=8D=E6=A9=9F=E8=83=BD=E3=82=92=E5=85=A8=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保存ボタン押下時に必ず開いていた左右 diff モーダル(CareerDiffModal)を 廃止し、保存フローを「バリデーション成功 → そのまま保存」に統一する。 差分確認は実際に使われておらず、保存のたびにモーダルを挟むぶん UX を 悪化させていたため、フロント・バックエンド双方の専用コードを削除する。 - フロント: CareerDiffModal / useResumeDiffPreview / careerDiff / careerReview / diffHighlight とテストを削除。CareerResumeForm・ useCareerFormModals・useCareerFormValidationFocus から差分確認分岐を除去 - バックエンド: /api/resumes/preview エンドポイント・build_resume_preview・ ResumePreviewResponse スキーマと関連テストを削除。_load_css の for_screen 分岐など派生デッドコードも整理 - messages.ts の CAREER_DIFF_LABELS / DIFF_DIALOG_MESSAGES を削除 - OpenAPI 生成物(generated.ts)を再生成 未保存マーク(useCareerDirty)と PDF/Markdown 生成は影響なし。 Co-Authored-By: Claude Opus 4.8 --- backend/app/routers/resumes.py | 17 - backend/app/schemas/__init__.py | 2 - backend/app/schemas/resume.py | 12 - .../pdf/generators/resume_generator.py | 26 +- backend/tests/test_endpoints.py | 79 ----- backend/tests/test_pdf_generator.py | 16 - web/src/api/generated.ts | 71 ---- web/src/api/paths.ts | 1 - web/src/api/resumes.ts | 19 +- web/src/api/types.ts | 3 - .../forms/CareerDiffModal.module.css | 264 --------------- web/src/components/forms/CareerDiffModal.tsx | 255 --------------- web/src/components/forms/CareerResumeForm.tsx | 42 +-- web/src/constants/messages.ts | 84 ----- .../hooks/career/useCareerFormModals.test.ts | 25 -- web/src/hooks/career/useCareerFormModals.ts | 13 +- .../useCareerFormValidationFocus.test.ts | 17 +- .../career/useCareerFormValidationFocus.ts | 26 +- .../hooks/career/useResumeDiffPreview.test.ts | 94 ------ web/src/hooks/career/useResumeDiffPreview.ts | 121 ------- web/src/test/factories/careerForm.ts | 4 +- web/src/utils/careerDiff.test.ts | 132 -------- web/src/utils/careerDiff.ts | 302 ------------------ web/src/utils/careerReview.test.ts | 59 ---- web/src/utils/careerReview.ts | 112 ------- web/src/utils/diffHighlight.test.ts | 198 ------------ web/src/utils/diffHighlight.ts | 240 -------------- 27 files changed, 14 insertions(+), 2220 deletions(-) delete mode 100644 web/src/components/forms/CareerDiffModal.module.css delete mode 100644 web/src/components/forms/CareerDiffModal.tsx delete mode 100644 web/src/hooks/career/useResumeDiffPreview.test.ts delete mode 100644 web/src/hooks/career/useResumeDiffPreview.ts delete mode 100644 web/src/utils/careerDiff.test.ts delete mode 100644 web/src/utils/careerDiff.ts delete mode 100644 web/src/utils/careerReview.test.ts delete mode 100644 web/src/utils/careerReview.ts delete mode 100644 web/src/utils/diffHighlight.test.ts delete mode 100644 web/src/utils/diffHighlight.ts diff --git a/backend/app/routers/resumes.py b/backend/app/routers/resumes.py index 05624117..c384ac5a 100644 --- a/backend/app/routers/resumes.py +++ b/backend/app/routers/resumes.py @@ -11,7 +11,6 @@ from ..repositories import ResumeRepository from ..schemas import ( ResumeCreate, - ResumePreviewResponse, ResumeResponse, ResumeUpdate, ) @@ -20,7 +19,6 @@ ) from ..services.pdf.generators.resume_generator import ( build_resume_pdf, - build_resume_preview, ) from .download_utils import stream_markdown, stream_pdf @@ -70,21 +68,6 @@ def create_resume( ) from exc -@router.post("/preview", response_model=ResumePreviewResponse) -def preview_resume( - payload: ResumeCreate, - current_user: User = Depends(get_current_user), -) -> ResumePreviewResponse: - """保存せずに、職務経歴書を PDF と同じレイアウトに整形した HTML と画面用 CSS を返す。 - - 左右 diff プレビュー(左=保存済み / 右=編集中)の描画に使う。HTML 内の各値ノードには - form パス(``data-fp``)が付与され、FE が変更箇所のハイライト・スクロール先特定に使う。 - DB は更新しない。WeasyPrint を通さず HTML 文字列生成のみのため軽量。 - """ - html, css = build_resume_preview(payload.model_dump()) - return ResumePreviewResponse(html=html, css=css) - - @router.get("/latest", response_model=ResumeResponse) def get_latest_resume( db: Session = Depends(get_db), diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index c207a940..dc6b33e1 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -29,7 +29,6 @@ Project, ProjectTeam, ResumeCreate, - ResumePreviewResponse, ResumeQualificationItem, ResumeResponse, ResumeUpdate, @@ -59,7 +58,6 @@ "Project", "ProjectTeam", "ResumeCreate", - "ResumePreviewResponse", "ResumeQualificationItem", "ResumeResponse", "ResumeUpdate", diff --git a/backend/app/schemas/resume.py b/backend/app/schemas/resume.py index c1e28401..3b25ddf5 100644 --- a/backend/app/schemas/resume.py +++ b/backend/app/schemas/resume.py @@ -280,15 +280,3 @@ class ResumeResponse(ResumeBase): def serialize_as_jst(self, dt: datetime) -> str: """UTC datetime を JST (UTC+9) の ISO 8601 文字列にシリアライズする。""" return to_jst(dt).isoformat() - - -class ResumePreviewResponse(BaseModel): - """保存前プレビュー(左右 diff 表示)用の整形済み HTML と画面用 CSS。 - - DB を更新せず、編集中 payload を PDF と同じレイアウトに整形した HTML を返す。 - HTML 内の各値ノードには form パス(``data-fp``)が付与され、FE が変更箇所の - ハイライト・スクロール先特定に使う。 - """ - - html: str - css: str diff --git a/backend/app/services/pdf/generators/resume_generator.py b/backend/app/services/pdf/generators/resume_generator.py index 26e983e7..f3f4bac3 100644 --- a/backend/app/services/pdf/generators/resume_generator.py +++ b/backend/app/services/pdf/generators/resume_generator.py @@ -1,4 +1,3 @@ -import re from datetime import datetime from html import escape as _html_escape from pathlib import Path @@ -22,9 +21,6 @@ Path(__file__).resolve().parent.parent.parent.parent / "fonts" / "NotoSansJP-Regular.ttf" ) -# 画面プレビュー(左右 diff)で除去する PDF 専用フォント定義。url 解決が画面では不要なため。 -_FONT_FACE_RE = re.compile(r"@font-face\s*\{[^}]*\}") - def _esc(text: str) -> str: """HTMLエスケープのショートカット""" @@ -367,34 +363,18 @@ def _build_html(resume: dict) -> str: return "\n".join(parts) -def _load_css(for_screen: bool) -> str: - """resume.css を読み込む。 - - for_screen=True(左右 diff プレビュー): PDF 用フォント(@font-face)を除去し、 - body の font-family フォールバック(sans-serif)に任せる。 - for_screen=False(PDF 生成): {{ font_path }} を実フォントの URI に置換する。 - """ +def _load_css() -> str: + """resume.css を読み込み、{{ font_path }} を実フォントの URI に置換する(PDF 生成用)。""" css_text = _CSS_PATH.read_text(encoding="utf-8") - if for_screen: - return _FONT_FACE_RE.sub("", css_text) return css_text.replace("{{ font_path }}", _FONT_PATH.as_uri()) -def build_resume_preview(resume: dict) -> tuple[str, str]: - """保存前プレビュー(左右 diff)用に (body HTML, 画面用 CSS) を返す。 - - HTML は data-fp 付き(`_build_html`)。CSS は PDF 専用フォントを除いた画面表示版。 - frontend は両者を iframe srcdoc に流し込んで baseline / 編集中を並べて描画する。 - """ - return _build_html(resume), _load_css(for_screen=True) - - def build_resume_pdf(resume: dict) -> bytes: """職務経歴書データからPDFバイト列を生成する""" html_body = _build_html(resume) # CSSテンプレートを読み込み、フォントパスを埋め込む - css_text = _load_css(for_screen=False) + css_text = _load_css() full_html = ( "" diff --git a/backend/tests/test_endpoints.py b/backend/tests/test_endpoints.py index a97f4050..407f2725 100644 --- a/backend/tests/test_endpoints.py +++ b/backend/tests/test_endpoints.py @@ -10,7 +10,6 @@ "method,path", [ ("post", "/api/resumes"), - ("post", "/api/resumes/preview"), ("get", "/api/resumes/latest"), ], ) @@ -347,81 +346,3 @@ def test_resume_not_found(client: TestClient) -> None: headers=headers, ) assert resp.status_code == 404 - - -# ── 保存前プレビュー(左右 diff 用 HTML)──────────────────────── - - -def test_resume_preview_returns_annotated_html(client: TestClient) -> None: - """保存せずに整形 HTML(data-fp 付き)と CSS を返す。""" - headers = auth_header(client, "resume-preview-user") - resp = client.post( - "/api/resumes/preview", - json={ - "full_name": "山田太郎", - "email": "yamada@example.com", - "career_summary": "キャリアサマリー", - "self_pr": "自己PR", - "experiences": [ - { - "company": "Example株式会社", - "business_description": "SES事業", - "start_date": "2020-04", - "end_date": "2024-03", - "is_current": False, - "is_it_company": True, - "clients": [ - { - "name": "取引先A", - "has_client": True, - "projects": [ - { - "name": "案件X", - "role": "SE", - "periods": [ - { - "start_date": "2020-04", - "end_date": "2021-03", - "is_current": False, - } - ], - } - ], - } - ], - } - ], - "qualifications": [{"acquired_date": "2020-04", "name": "応用情報技術者"}], - }, - headers=headers, - ) - assert resp.status_code == 200 - body = resp.json() - html = body["html"] - # form パス(data-fp)が careerDiff のパス文字列と一致して付与されていること - assert 'data-fp="full_name"' in html - assert 'data-fp="experiences.0.company"' in html - assert 'data-fp="experiences.0.clients.0.name"' in html - assert 'data-fp="experiences.0.clients.0.projects.0.role"' in html - assert 'data-fp="qualifications.0.name"' in html - # 画面用 CSS は PDF 専用フォント定義を含まない - assert "@font-face" not in body["css"] - assert ".project-table" in body["css"] - - -def test_resume_preview_validation_error(client: TestClient) -> None: - """必須欠落(full_name 空)は 422 を返す。""" - headers = auth_header(client, "resume-preview-invalid-user") - resp = client.post( - "/api/resumes/preview", - json={ - "full_name": "", - "email": "yamada@example.com", - "career_summary": "x", - "self_pr": "y", - "experiences": [], - "qualifications": [], - }, - headers=headers, - ) - assert resp.status_code == 422 diff --git a/backend/tests/test_pdf_generator.py b/backend/tests/test_pdf_generator.py index 423bdfde..38c8f580 100644 --- a/backend/tests/test_pdf_generator.py +++ b/backend/tests/test_pdf_generator.py @@ -3,7 +3,6 @@ from app.services.pdf.generators.resume_generator import ( _build_html, build_resume_pdf, - build_resume_preview, ) from app.services.pdf.utils.pdf_utils import ( decode_photo as _decode_photo, @@ -249,21 +248,6 @@ def test_build_resume_pdf_still_works_with_annotations() -> None: assert pdf_bytes.startswith(b"%PDF") -def test_build_resume_preview_returns_html_and_screen_css() -> None: - """プレビューは (data-fp 付き HTML, 画面用 CSS) を返し、CSS は @font-face を含まない。""" - payload = { - "full_name": "山田太郎", - "career_summary": "要約", - "self_pr": "PR", - "qualifications": [], - "experiences": [], - } - html, css = build_resume_preview(payload) - assert 'data-fp="full_name"' in html - assert "@font-face" not in css - assert ".company" in css - - def test_parse_date_ym() -> None: assert _parse_date_ym("2020-04") == ("2020", "4") assert _parse_date_ym("2020-12-01") == ("2020", "12") diff --git a/web/src/api/generated.ts b/web/src/api/generated.ts index 72d2e268..06f9c965 100644 --- a/web/src/api/generated.ts +++ b/web/src/api/generated.ts @@ -663,30 +663,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/resumes/preview": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Preview Resume - * @description 保存せずに、職務経歴書を PDF と同じレイアウトに整形した HTML と画面用 CSS を返す。 - * - * 左右 diff プレビュー(左=保存済み / 右=編集中)の描画に使う。HTML 内の各値ノードには - * form パス(``data-fp``)が付与され、FE が変更箇所のハイライト・スクロール先特定に使う。 - * DB は更新しない。WeasyPrint を通さず HTML 文字列生成のみのため軽量。 - */ - post: operations["preview_resume_api_resumes_preview_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/resumes/{resume_id}": { parameters: { query?: never; @@ -1789,20 +1765,6 @@ export interface components { /** Self Pr */ self_pr: string; }; - /** - * ResumePreviewResponse - * @description 保存前プレビュー(左右 diff 表示)用の整形済み HTML と画面用 CSS。 - * - * DB を更新せず、編集中 payload を PDF と同じレイアウトに整形した HTML を返す。 - * HTML 内の各値ノードには form パス(``data-fp``)が付与され、FE が変更箇所の - * ハイライト・スクロール先特定に使う。 - */ - ResumePreviewResponse: { - /** Css */ - css: string; - /** Html */ - html: string; - }; /** ResumeQualificationItem */ ResumeQualificationItem: { /** Acquired Date */ @@ -3066,39 +3028,6 @@ export interface operations { }; }; }; - preview_resume_api_resumes_preview_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["ResumeCreate"]; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ResumePreviewResponse"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; get_resume_api_resumes__resume_id__get: { parameters: { query?: never; diff --git a/web/src/api/paths.ts b/web/src/api/paths.ts index 5bfc797c..de699210 100644 --- a/web/src/api/paths.ts +++ b/web/src/api/paths.ts @@ -41,7 +41,6 @@ export const PATHS = { resumes: { base: "/api/resumes", latest: "/api/resumes/latest", - preview: "/api/resumes/preview", byId: (id: string) => `/api/resumes/${id}`, pdf: (id: string) => `/api/resumes/${id}/pdf`, markdown: (id: string) => `/api/resumes/${id}/markdown`, diff --git a/web/src/api/resumes.ts b/web/src/api/resumes.ts index 93ebb98b..8ff87fd7 100644 --- a/web/src/api/resumes.ts +++ b/web/src/api/resumes.ts @@ -1,11 +1,7 @@ import { request } from "./client"; import { downloadBlob, getBlobUrl } from "./download"; import { PATHS } from "./paths"; -import type { ResumeCreate, ResumePreviewResponse, ResumeResponse } from "./types"; - -// 保存前プレビュー(左右 diff 表示)のレスポンス型は OpenAPI 生成物(generated.ts)から -// 取り込んだ ResumePreviewResponse を types.ts 経由で再エクスポートする。 -export type { ResumePreviewResponse }; +import type { ResumeCreate, ResumeResponse } from "./types"; export function getLatestCareerResume(): Promise { return request(PATHS.resumes.latest); @@ -43,16 +39,3 @@ export function downloadCareerResumeMarkdown(id: string): Promise { export function getCareerResumePdfBlobUrl(id: string): Promise { return getBlobUrl(PATHS.resumes.pdf(id)); } - -/** - * 保存せずに、職務経歴書を整形した HTML(data-fp 付き)と画面用 CSS を取得する。 - * 左右 diff プレビュー(左=保存済み / 右=編集中)の描画に使う。 - */ -export function getCareerResumePreview( - payload: ResumeCreate, -): Promise { - return request(PATHS.resumes.preview, { - method: "POST", - body: JSON.stringify(payload), - }); -} diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 0a3e91fa..90a991b4 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -59,9 +59,6 @@ export type ResumeResponse = Schemas["ResumeResponse"]; /** 職務経歴の作成/更新ペイロード。backend `schemas/resume.py:ResumeCreate`(ResumeBase 同形)。 */ export type ResumeCreate = Schemas["ResumeCreate"]; -/** 保存前プレビュー(左右 diff 表示)のレスポンス。backend `schemas/resume.py:ResumePreviewResponse`。 */ -export type ResumePreviewResponse = Schemas["ResumePreviewResponse"]; - /** 職歴(1 社分)。backend `schemas/resume.py:Experience`。 */ export type Experience = Schemas["Experience"]; diff --git a/web/src/components/forms/CareerDiffModal.module.css b/web/src/components/forms/CareerDiffModal.module.css deleted file mode 100644 index 029f2562..00000000 --- a/web/src/components/forms/CareerDiffModal.module.css +++ /dev/null @@ -1,264 +0,0 @@ -.overlay { - position: fixed; - inset: 0; - background: var(--overlay-bg); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; - padding: 1rem; -} - -.modal { - background: var(--bg-card); - border-radius: 12px; - width: 94vw; - height: 92vh; - display: flex; - flex-direction: column; - overflow: hidden; -} - -.header { - padding: 1rem 1.25rem 0.5rem; -} - -.title { - margin: 0 0 0.25rem; - font-size: 1.1rem; -} - -.description { - margin: 0; - font-size: 0.82rem; - color: var(--text-muted, #666); - line-height: 1.5; -} - -/* 左ペイン / 右ペイン / サイドバー の 3 カラム。 */ -.body { - flex: 1; - min-height: 0; - display: grid; - grid-template-columns: 1fr 1fr 320px; - /* - * 行を明示的に minmax(0, 1fr) で固定する。未指定だと暗黙の auto 行になり、 - * 変更点が多いときにサイドバーの中身(.list)の高さに合わせて行が伸び、 - * モーダル(92vh / overflow:hidden)からはみ出した分が clip されてロール - * バックボタンや操作ボタンが見えなくなる。0 を下限にすることで中身では - * 伸びず、.list の overflow-y:auto が効いて全項目をスクロールで辿れる。 - */ - grid-template-rows: minmax(0, 1fr); - gap: 0.75rem; - padding: 0.5rem 1rem 0.75rem; -} - -.pane { - display: flex; - flex-direction: column; - min-height: 0; - border: 1px solid var(--border-color, #e0e0e0); - border-radius: 8px; - overflow: hidden; - position: relative; -} - -.paneHead { - flex-shrink: 0; - padding: 0.4rem 0.7rem; - font-size: 0.8rem; - font-weight: 700; - background: var(--bg-page, #f5f5f5); - border-bottom: 1px solid var(--border-color, #e0e0e0); -} - -.frame { - flex: 1; - border: none; - width: 100%; - background: #fff; -} - -.placeholder { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - color: var(--text-muted, #888); - font-size: 0.85rem; - padding: 1rem; - text-align: center; -} - -/* 右ペインの再取得中インジケータ(既存表示の上に重ねる)。 */ -.refetching { - position: absolute; - top: 0.4rem; - right: 0.5rem; - font-size: 0.7rem; - color: var(--text-muted, #888); - background: var(--bg-card, #fff); - border: 1px solid var(--border-color, #e0e0e0); - border-radius: 999px; - padding: 0.1rem 0.5rem; -} - -.sidebar { - display: flex; - flex-direction: column; - min-height: 0; - border: 1px solid var(--border-color, #e0e0e0); - border-radius: 8px; - overflow: hidden; -} - -.sidebarHead { - flex-shrink: 0; - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 0.5rem; - padding: 0.4rem 0.7rem; - font-size: 0.8rem; - font-weight: 700; - background: var(--bg-page, #f5f5f5); - border-bottom: 1px solid var(--border-color, #e0e0e0); -} - -.list { - list-style: none; - margin: 0; - padding: 0.5rem; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 0.5rem; - flex: 1 1 auto; - min-height: 0; -} - -/* 1 フィールド分のレビュー項目(差分+校正をまとめたカード)。 */ -.entry { - display: flex; - flex-direction: column; - border: 1px solid var(--border-color, #e0e0e0); - border-radius: 6px; - overflow: hidden; -} - -/* 見出し(クリックで右ペインの該当箇所へスクロール)。 */ -.entryHead { - text-align: left; - background: var(--bg-page, #f7f7f8); - color: inherit; - border: none; - border-bottom: 1px solid var(--border-color, #ececec); - padding: 0.35rem 0.55rem; - cursor: pointer; -} - -/* 差分行: バッジ+旧→新 と 元に戻すボタン。 */ -.entryDiff { - display: flex; - align-items: center; - gap: 0.4rem; - padding: 0.35rem 0.5rem 0.35rem 0.55rem; -} - -.diffMain { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 0.2rem; -} - -.badge { - flex-shrink: 0; - font-size: 0.65rem; - font-weight: 600; - padding: 0.05rem 0.4rem; - border-radius: 999px; - color: #fff; -} - -.modified { - background: #ca8a04; -} - -.added { - background: #16a34a; -} - -.removed { - background: #dc2626; -} - -.label { - font-size: 0.78rem; - font-weight: 600; - overflow-wrap: break-word; -} - -.values { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 0.3rem; - font-size: 0.78rem; -} - -.oldValue { - color: var(--text-muted, #888); - text-decoration: line-through; - overflow-wrap: break-word; -} - -.arrow { - color: var(--text-muted, #888); -} - -.newValue { - color: var(--text-primary, #111); - font-weight: 500; - overflow-wrap: break-word; -} - -/* VSCode の diff の revert 矢印風。各変更行を編集前(保存済み)へ戻すアイコンボタン。 */ -.rollback { - flex-shrink: 0; - align-self: center; - width: 26px; - height: 26px; - padding: 0; - display: flex; - align-items: center; - justify-content: center; - font-size: 0.95rem; - line-height: 1; - background: transparent; - color: var(--accent, #c96442); - border: 1px solid var(--border-color, #e0e0e0); - border-radius: 6px; -} - -.rollback:hover:not(:disabled) { - background: rgba(201, 100, 66, 0.12); - border-color: var(--accent, #c96442); -} - -.empty { - margin: 0; - padding: 0.75rem; - color: var(--text-muted, #666); - font-size: 0.85rem; -} - -.actions { - flex-shrink: 0; - display: flex; - gap: 0.5rem; - justify-content: flex-end; - padding: 0.75rem 1.25rem; - border-top: 1px solid var(--border-color, #e0e0e0); -} diff --git a/web/src/components/forms/CareerDiffModal.tsx b/web/src/components/forms/CareerDiffModal.tsx deleted file mode 100644 index 5849db41..00000000 --- a/web/src/components/forms/CareerDiffModal.tsx +++ /dev/null @@ -1,255 +0,0 @@ -import { useMemo, useRef } from "react"; - -import { DIFF_DIALOG_MESSAGES as D } from "../../constants/messages"; -import { buildReviewEntries } from "../../utils/careerReview"; -import type { CareerChange, ChangeKind } from "../../utils/careerDiff"; -import { - annotateHtml, - buildPathKindMap, - foldUnchanged, - injectRemovedPlaceholders, -} from "../../utils/diffHighlight"; -import styles from "./CareerDiffModal.module.css"; - -/** 種別ごとのバッジ文言。 */ -const KIND_LABEL: Record = { - modified: D.MODIFIED_LABEL, - added: D.ADDED_LABEL, - removed: D.REMOVED_LABEL, -}; - -/** - * iframe 内に注入する diff 着色 CSS。backend の resume.css に追記する形で srcDoc に埋め込む。 - * 緑=追加 / 赤=削除 / 黄=修正。VSCode 系 diff の配色に寄せる。 - */ -const DIFF_CSS = ` - body { margin: 0; padding: 12px 16px; background: #fff; } - .diff-mark { border-radius: 2px; padding: 0 1px; } - .diff-added { background: rgba(22,163,74,0.18); box-shadow: 0 0 0 1px rgba(22,163,74,0.45); } - .diff-removed { background: rgba(220,38,38,0.16); box-shadow: 0 0 0 1px rgba(220,38,38,0.40); } - .diff-modified { background: rgba(234,179,8,0.25); box-shadow: 0 0 0 1px rgba(234,179,8,0.50); } - details.fold { margin: 3px 0; } - summary.fold-summary { - cursor: pointer; list-style: none; font-size: 8pt; color: #6b7280; - background: #f3f4f6; border: 1px dashed #cbd5e1; border-radius: 4px; padding: 2px 8px; - } - summary.fold-summary::-webkit-details-marker { display: none; } - summary.fold-summary::before { content: "▸ "; } - details[open] > summary.fold-summary::before { content: "▾ "; } - .diff-removed-stub { - font-size: 8pt; color: #b91c1c; background: rgba(220,38,38,0.08); - border: 1px dashed rgba(220,38,38,0.5); border-radius: 4px; padding: 2px 6px; margin: 2px 0; - } - .diff-removed-stub td { border: none; color: #b91c1c; } -`; - -function buildSrcDoc(css: string, bodyHtml: string): string { - return ( - "" + - `${bodyHtml}` - ); -} - -/** - * 経歴書 保存時の左右 diff モーダル(VSCode フォーク風)。 - * - * 左=保存済み(baseline) / 右=編集中(form) を PDF と同じ整形レイアウト(iframe srcdoc)で並べ、 - * 変更箇所を緑/赤/黄でハイライト。右サイドバーに変更点リストを出し、各行で項目別ロールバック。 - */ -export function CareerDiffModal({ - changes, - baselineHtml, - editedHtml, - css, - loading, - error, - saving, - onConfirm, - onCancel, - onRollback, -}: { - changes: CareerChange[]; - baselineHtml: string | null; - editedHtml: string | null; - css: string; - loading: boolean; - error: string | null; - saving: boolean; - onConfirm: () => void; - onCancel: () => void; - onRollback: (change: CareerChange) => void; -}) { - const editedFrameRef = useRef(null); - const hasChanges = changes.length > 0; - - const pathKindMap = useMemo(() => buildPathKindMap(changes), [changes]); - - /** - * 変更点をフィールド単位にまとめ、PDF レイアウト順に並べる。 - * 左右ペイン(PDF)とサイドバーの縦順が一致し、上から順に突合できる。 - */ - const reviewEntries = useMemo(() => buildReviewEntries(changes), [changes]); - - // 着色(annotateHtml)→ 変更なし領域を畳む(foldUnchanged)の順で整形する。 - const baselineDoc = useMemo(() => { - if (baselineHtml === null) return null; - return buildSrcDoc(css, foldUnchanged(annotateHtml(baselineHtml, pathKindMap), pathKindMap)); - }, [baselineHtml, css, pathKindMap]); - - const editedDoc = useMemo(() => { - if (editedHtml === null) return null; - // 着色(差分)→ 削除跡のプレースホルダ挿入 → 変更なし領域の折りたたみ。 - const annotated = annotateHtml(editedHtml, pathKindMap); - const withStubs = injectRemovedPlaceholders(annotated, changes); - return buildSrcDoc(css, foldUnchanged(withStubs, pathKindMap)); - }, [editedHtml, css, pathKindMap, changes]); - - /** レビュー項目クリックで、右ペイン(編集中)の該当ノードへスクロールする。 */ - const scrollToPath = (fp: string) => { - const doc = editedFrameRef.current?.contentDocument; - if (!doc) return; - const escaped = CSS.escape(fp); - // 削除項目は編集中ペインから消えており data-fp が無い(次要素が繰り上がって同じ - // パスを持つ)ため、まず injectRemovedPlaceholders が挿す削除スタブ(data-removed)を狙う。 - const target = - doc.querySelector(`[data-removed="${escaped}"]`) ?? - doc.querySelector(`[data-fp="${escaped}"]`) ?? - doc.querySelector(`[data-fp^="${escaped}."]`); - target?.scrollIntoView({ block: "center", behavior: "smooth" }); - }; - - return ( -
{ - // 保存処理中は背景クリックでの閉じ操作を無効化する(処理中状態を隠さない)。 - if (!saving) onCancel(); - }} - > -
e.stopPropagation()} - role="dialog" - aria-modal="true" - aria-label={D.TITLE} - > -
-

{D.TITLE}

-

{D.DESCRIPTION}

-
- -
- {/* 左: 保存済み */} -
-
{D.PANE_BASELINE}
- {baselineDoc ? ( -