From b3aa3049751aceadde1005583ede63bc358971d6 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Sun, 23 Aug 2026 17:38:49 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20=EA=B3=B5=EA=B0=9C=ED=95=A0=20=EC=88=98=20=EC=9E=88?= =?UTF-8?q?=EA=B2=8C=20=ED=95=98=EA=B3=A0,=20=ED=99=88=EC=9D=B4=20?= =?UTF-8?q?=EB=AC=B4=EC=97=87=EC=9D=84=20=EC=95=9E=EC=97=90=20=EB=91=98?= =?UTF-8?q?=EC=A7=80=20=EA=B3=A0=EB=A5=BC=20=EC=88=98=20=EC=9E=88=EA=B2=8C?= =?UTF-8?q?=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공개 화면 다섯 곳이 조용히 비어 있었다. 원인은 하나씩 달랐지만 모두 "값을 채울 방법이 없었다"는 같은 모양이었다. 홈의 "지금 집중하는 것" — `home_focus_config` 는 마이그레이션이 빈 행 하나만 넣어 두었고, 계약에 선언된 `getHomeFocus`/`updateHomeFocus` 는 구현이 없었다. 세 슬롯이 모두 비면 홈은 그 영역을 아예 그리지 않으므로, 운영에서는 한 번도 나타난 적이 없다. Studio 대시보드에 고르는 화면을 둔다. 홈의 "최근 기록" — 화면이 공개된 프로젝트를 하나씩 돌며 타임라인을 조립했다. 그래서 게시한 문서라도 그 프로젝트가 공개되어 있지 않으면 목록에서 통째로 빠졌고, 실제로 릴리스 한 줄만 남았다. 무엇이 최근인지는 공개 투영이 이미 알고 있으므로 그것을 그대로 읽는다. 프로젝트마다 요청을 보내던 N+1 도 사라진다. 프로젝트 공개 — 프로젝트는 `RecordKind` 에 없어 문서 게시 파이프라인을 타지 못하는데, 공개 화면들(프로젝트 목록·프로필의 "현재 프로젝트"·홈 focus)은 전부 `public_resource_projection` 의 PROJECT 행을 가시성 관문으로 쓴다. 그 행을 세우는 경로가 없었으므로 프로젝트는 영원히 비공개였다. 계약에 이미 있던 `publishProject`/`unpublishProject` 를 구현하고 주제·프로젝트 화면에 버튼을 둔다. 문서 사이 관계 연결 — `JdbcCatalogQueryAdapter` 의 RELATION/EVIDENCE 가 `List.of()` 스텁이라 어떤 기록도 연결 대상 목록을 채울 수 없었다. RELATION 은 작성 중에 고르는 것이므로 작업본까지 포함하고, EVIDENCE 는 읽는 사람이 따라갈 수 있어야 하므로 공개된 것만 포함한다. 본문 너비 — 문서 한 편이 세 폭으로 갈라져 있었다. 머리말 920px, 유형·프로젝트 줄은 shell 전체 1180px, 본문은 672px 를 가운데 정렬. 셋을 같은 폭·같은 왼쪽 끝에 세우고 읽는 단을 56rem 으로 넓힌다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu --- .gitignore | 1 + .../console-2026-08-21T10-06-16-202Z.log | 1 - .../console-2026-08-21T10-06-40-806Z.log | 1 - .../console-2026-08-21T10-10-58-031Z.log | 1 - .../page-2026-08-21T10-06-17-092Z.yml | 0 .../page-2026-08-21T10-06-25-904Z.yml | 16 -- .../page-2026-08-21T10-06-35-904Z.yml | 74 ------- .../page-2026-08-21T10-06-40-962Z.yml | 0 .../page-2026-08-21T10-07-21-058Z.yml | 100 --------- .../page-2026-08-21T10-08-20-113Z.yml | 0 .../page-2026-08-21T10-08-24-554Z.yml | 55 ----- .../page-2026-08-21T10-08-30-303Z.yml | 143 ------------- .../page-2026-08-21T10-08-35-304Z.yml | 0 .../page-2026-08-21T10-08-44-012Z.yml | 99 --------- .../page-2026-08-21T10-09-11-287Z.yml | 0 .../page-2026-08-21T10-09-16-578Z.yml | 55 ----- .../page-2026-08-21T10-09-26-317Z.yml | 122 ----------- .../page-2026-08-21T10-09-30-337Z.yml | 0 .../page-2026-08-21T10-09-36-210Z.yml | 99 --------- .../page-2026-08-21T10-09-52-861Z.yml | 0 .../page-2026-08-21T10-10-12-379Z.yml | 106 ---------- .../page-2026-08-21T10-10-26-016Z.yml | 96 --------- .../page-2026-08-21T10-10-58-198Z.yml | 0 .../page-2026-08-21T10-11-03-556Z.yml | 100 --------- .../page-2026-08-21T10-18-11-148Z.yml | 0 .../page-2026-08-21T10-18-21-158Z.yml | 50 ----- .../page-2026-08-21T10-18-37-556Z.yml | 0 .../page-2026-08-21T10-19-02-154Z.yml | 0 deploy/keycloak/tech-log-realm.json | 4 +- public/release-manifest.json | 8 +- .../adapters/http/http-management-gateway.ts | 12 ++ .../http/http-public-content-gateway.ts | 29 +++ .../tech-log/adapters/static/public-query.ts | 41 ++++ .../application/ports/management-gateway.ts | 17 ++ .../ports/public-content-queries.ts | 19 ++ .../management/canonical-source.json | 2 +- .../tech-log/contracts/management/contract.ts | 2 + .../contracts/management/generated.ts | 66 +++--- .../management/management-api.openapi.yaml | 127 +++++++----- .../contracts/studio/canonical-source.json | 4 +- .../tech-log/contracts/studio/generated.ts | 27 ++- .../contracts/studio/studio-api.openapi.yaml | 34 ++- ...ch-log-management-contract-contribution.ts | 41 ++++ .../content-format/parse-case-content.ts | 116 +++++++++-- .../content-format/serialize-case-content.ts | 8 + .../presentation/public/pages/home-page.tsx | 102 +++++---- .../public-render/case-body-renderer.tsx | 13 ++ .../studio/components/home-focus-editor.tsx | 194 ++++++++++++++++++ .../studio/components/release-manager.tsx | 62 ++++-- .../studio/components/studio-dashboard.tsx | 2 + .../studio/components/taxonomy-manager.tsx | 61 +++++- .../tech-log/presentation/styles/globals.css | 58 +++++- .../tech-log/presentation/styles/studio.css | 11 + .../features/tech-log/content-format.test.ts | 33 ++- .../tech-log/contract-generation.test.ts | 2 +- tests/unit/ci-artifact-contract.test.ts | 43 +++- 56 files changed, 935 insertions(+), 1322 deletions(-) delete mode 100644 .playwright-mcp/console-2026-08-21T10-06-16-202Z.log delete mode 100644 .playwright-mcp/console-2026-08-21T10-06-40-806Z.log delete mode 100644 .playwright-mcp/console-2026-08-21T10-10-58-031Z.log delete mode 100644 .playwright-mcp/page-2026-08-21T10-06-17-092Z.yml delete mode 100644 .playwright-mcp/page-2026-08-21T10-06-25-904Z.yml delete mode 100644 .playwright-mcp/page-2026-08-21T10-06-35-904Z.yml delete mode 100644 .playwright-mcp/page-2026-08-21T10-06-40-962Z.yml delete mode 100644 .playwright-mcp/page-2026-08-21T10-07-21-058Z.yml delete mode 100644 .playwright-mcp/page-2026-08-21T10-08-20-113Z.yml delete mode 100644 .playwright-mcp/page-2026-08-21T10-08-24-554Z.yml delete mode 100644 .playwright-mcp/page-2026-08-21T10-08-30-303Z.yml delete mode 100644 .playwright-mcp/page-2026-08-21T10-08-35-304Z.yml delete mode 100644 .playwright-mcp/page-2026-08-21T10-08-44-012Z.yml delete mode 100644 .playwright-mcp/page-2026-08-21T10-09-11-287Z.yml delete mode 100644 .playwright-mcp/page-2026-08-21T10-09-16-578Z.yml delete mode 100644 .playwright-mcp/page-2026-08-21T10-09-26-317Z.yml delete mode 100644 .playwright-mcp/page-2026-08-21T10-09-30-337Z.yml delete mode 100644 .playwright-mcp/page-2026-08-21T10-09-36-210Z.yml delete mode 100644 .playwright-mcp/page-2026-08-21T10-09-52-861Z.yml delete mode 100644 .playwright-mcp/page-2026-08-21T10-10-12-379Z.yml delete mode 100644 .playwright-mcp/page-2026-08-21T10-10-26-016Z.yml delete mode 100644 .playwright-mcp/page-2026-08-21T10-10-58-198Z.yml delete mode 100644 .playwright-mcp/page-2026-08-21T10-11-03-556Z.yml delete mode 100644 .playwright-mcp/page-2026-08-21T10-18-11-148Z.yml delete mode 100644 .playwright-mcp/page-2026-08-21T10-18-21-158Z.yml delete mode 100644 .playwright-mcp/page-2026-08-21T10-18-37-556Z.yml delete mode 100644 .playwright-mcp/page-2026-08-21T10-19-02-154Z.yml create mode 100644 src/features/tech-log/presentation/studio/components/home-focus-editor.tsx diff --git a/.gitignore b/.gitignore index db5b8db..538c7cf 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ artifacts/tests/visual/ # Git worktrees created inside the repository. A worktree is a checkout, not # source: committing one would nest a second working copy inside this one. .worktrees/ +.playwright-mcp/ diff --git a/.playwright-mcp/console-2026-08-21T10-06-16-202Z.log b/.playwright-mcp/console-2026-08-21T10-06-16-202Z.log deleted file mode 100644 index 68b3eeb..0000000 --- a/.playwright-mcp/console-2026-08-21T10-06-16-202Z.log +++ /dev/null @@ -1 +0,0 @@ -[ 1019ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0 diff --git a/.playwright-mcp/console-2026-08-21T10-06-40-806Z.log b/.playwright-mcp/console-2026-08-21T10-06-40-806Z.log deleted file mode 100644 index 720ea14..0000000 --- a/.playwright-mcp/console-2026-08-21T10-06-40-806Z.log +++ /dev/null @@ -1 +0,0 @@ -[ 39361ms] [ERROR] Failed to load resource: the server responded with a status of 409 (Conflict) @ https://hyeonworks.com/api/v1/studio/cases/b09f168b-b205-478f-b60f-83872d1b4d01:0 diff --git a/.playwright-mcp/console-2026-08-21T10-10-58-031Z.log b/.playwright-mcp/console-2026-08-21T10-10-58-031Z.log deleted file mode 100644 index 7168a49..0000000 --- a/.playwright-mcp/console-2026-08-21T10-10-58-031Z.log +++ /dev/null @@ -1 +0,0 @@ -[ 4651ms] [ERROR] Failed to load resource: the server responded with a status of 409 (Conflict) @ https://hyeonworks.com/api/v1/studio/cases/b09f168b-b205-478f-b60f-83872d1b4d01:0 diff --git a/.playwright-mcp/page-2026-08-21T10-06-17-092Z.yml b/.playwright-mcp/page-2026-08-21T10-06-17-092Z.yml deleted file mode 100644 index e69de29..0000000 diff --git a/.playwright-mcp/page-2026-08-21T10-06-25-904Z.yml b/.playwright-mcp/page-2026-08-21T10-06-25-904Z.yml deleted file mode 100644 index 26923fc..0000000 --- a/.playwright-mcp/page-2026-08-21T10-06-25-904Z.yml +++ /dev/null @@ -1,16 +0,0 @@ -- generic [ref=f1e3]: - - banner [ref=f1e4]: - - generic [ref=f1e5]: prod - - main [ref=f1e6]: - - heading "Sign in to your account" [level=1] [ref=f1e8] - - generic [ref=f1e12]: - - generic [ref=f1e13]: - - generic [ref=f1e14]: Username or email - - textbox "Username or email" [active] [ref=f1e17] - - generic [ref=f1e18]: - - generic [ref=f1e19]: Password - - generic [ref=f1e21]: - - textbox "Password" [ref=f1e24] - - button "Show password" [ref=f1e26] [cursor=pointer]: - - generic [ref=f1e27]:  - - button "Sign In" [ref=f1e30] [cursor=pointer] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-08-21T10-06-35-904Z.yml b/.playwright-mcp/page-2026-08-21T10-06-35-904Z.yml deleted file mode 100644 index 5e6f206..0000000 --- a/.playwright-mcp/page-2026-08-21T10-06-35-904Z.yml +++ /dev/null @@ -1,74 +0,0 @@ -- generic [ref=f2e3]: - - link "본문으로 건너뛰기" [ref=f2e4] [cursor=pointer]: - - /url: "#main-content" - - banner [ref=f2e5]: - - generic [ref=f2e6]: - - link "TechLog 홈" [ref=f2e8] [cursor=pointer]: - - /url: / - - text: TechLog - - generic [ref=f2e9]: - - button "TechLog 검색 열기" [ref=f2e11] [cursor=pointer]: 검색 - - group [ref=f2e12]: - - generic "메뉴" [ref=f2e13] [cursor=pointer] - - main [ref=f2e14]: - - region [ref=f2e15]: - - heading "TechLog" [level=1] [ref=f2e16] - - paragraph [ref=f2e17]: 문제를 재현하고 검증해 운영 가능한 설계로 연결합니다. - - region [ref=f2e18]: - - generic [ref=f2e19]: - - generic [ref=f2e20]: - - paragraph [ref=f2e21]: Index - - heading "최근 기록" [level=2] [ref=f2e22] - - link "모든 기록 탐색" [ref=f2e23] [cursor=pointer]: - - /url: /explore - - list [ref=f2e24]: - - listitem [ref=f2e25]: - - link "RELEASE 2026.08.21 첫 공개 공개 사이트와 Studio 작성 흐름을 처음으로 실제 서버에 올렸습니다. TechLog · TechLog" [ref=f2e26] [cursor=pointer]: - - /url: /releases/0.1.0 - - generic [ref=f2e27]: - - generic [ref=f2e28]: RELEASE - - time [ref=f2e29]: 2026.08.21 - - generic [ref=f2e30]: - - heading "첫 공개" [level=3] [ref=f2e31] - - paragraph [ref=f2e32]: 공개 사이트와 Studio 작성 흐름을 처음으로 실제 서버에 올렸습니다. - - paragraph [ref=f2e33]: TechLog · TechLog - - generic [ref=f2e34]: ↗ - - region [ref=f2e35]: - - generic [ref=f2e37]: - - paragraph [ref=f2e38]: Explore - - heading "어떤 맥락으로 읽을까요?" [level=2] [ref=f2e39] - - list [ref=f2e40]: - - listitem [ref=f2e41]: - - link "문제를 따라가며 검증 과정을 읽습니다 Case" [ref=f2e42] [cursor=pointer]: - - /url: /explore/cases - - generic [ref=f2e43]: 문제를 따라가며 검증 과정을 읽습니다 - - strong [ref=f2e44]: Case - - generic [ref=f2e45]: ↗ - - listitem [ref=f2e46]: - - link "다시 찾을 수 있는 기술 기준을 확인합니다 Reference" [ref=f2e47] [cursor=pointer]: - - /url: /explore/references - - generic [ref=f2e48]: 다시 찾을 수 있는 기술 기준을 확인합니다 - - strong [ref=f2e49]: Reference - - generic [ref=f2e50]: ↗ - - listitem [ref=f2e51]: - - link "아직 끝나지 않은 판단과 다음 검증을 봅니다 OpenQuestion" [ref=f2e52] [cursor=pointer]: - - /url: /explore/questions - - generic [ref=f2e53]: 아직 끝나지 않은 판단과 다음 검증을 봅니다 - - strong [ref=f2e54]: OpenQuestion - - generic [ref=f2e55]: ↗ - - listitem [ref=f2e56]: - - link "여러 기록을 하나의 시스템 맥락에서 연결합니다 Project" [ref=f2e57] [cursor=pointer]: - - /url: /projects - - generic [ref=f2e58]: 여러 기록을 하나의 시스템 맥락에서 연결합니다 - - strong [ref=f2e59]: Project - - generic [ref=f2e60]: ↗ - - contentinfo [ref=f2e61]: - - generic [ref=f2e62]: - - generic [ref=f2e63]: - - paragraph [ref=f2e64]: 동현 - - paragraph [ref=f2e65]: 문제를 재현하고 검증해 운영 가능한 설계로 연결합니다. - - generic [ref=f2e66]: - - link "프로필" [ref=f2e67] [cursor=pointer]: - - /url: /profile - - link "변경 기록" [ref=f2e68] [cursor=pointer]: - - /url: /releases \ No newline at end of file diff --git a/.playwright-mcp/page-2026-08-21T10-06-40-962Z.yml b/.playwright-mcp/page-2026-08-21T10-06-40-962Z.yml deleted file mode 100644 index e69de29..0000000 diff --git a/.playwright-mcp/page-2026-08-21T10-07-21-058Z.yml b/.playwright-mcp/page-2026-08-21T10-07-21-058Z.yml deleted file mode 100644 index 287cc78..0000000 --- a/.playwright-mcp/page-2026-08-21T10-07-21-058Z.yml +++ /dev/null @@ -1,100 +0,0 @@ -- generic [ref=f3e22]: - - link "본문으로 건너뛰기" [ref=f3e23] [cursor=pointer]: - - /url: "#main-content" - - banner [ref=f3e24]: - - generic [ref=f3e25]: - - link "TechLog Studio" [ref=f3e26] [cursor=pointer]: - - /url: /studio - - text: TechLog - - generic [ref=f3e27]: Studio - - navigation "Studio 주 탐색" [ref=f3e29]: - - link "작업본" [ref=f3e30] [cursor=pointer]: - - /url: /studio/documents - - link "게시 기록" [ref=f3e31] [cursor=pointer]: - - /url: /studio/publications - - link "새 문서" [ref=f3e32] [cursor=pointer]: - - /url: /studio/documents/new - - link "주제·프로젝트" [ref=f3e33] [cursor=pointer]: - - /url: /studio/taxonomy - - link "릴리즈" [ref=f3e34] [cursor=pointer]: - - /url: /studio/releases - - link "공개 사이트 보기" [ref=f3e35] [cursor=pointer]: - - /url: / - - button "로그아웃" [ref=f3e36] - - main [ref=f3e37]: - - generic [ref=f3e38]: - - generic [ref=f3e39]: - - generic [ref=f3e40]: - - paragraph [ref=f3e41]: WORKING COPIES - - heading "작업본" [level=1] [ref=f3e42] - - paragraph [ref=f3e43]: 세션에 있는 Case, Reference, Question, Decision을 찾고 다음 작업으로 이동합니다. - - link "새 문서" [ref=f3e44] [cursor=pointer]: - - /url: /studio/documents/new - - region "작업본 검색과 필터" [ref=f3e45]: - - search [ref=f3e46]: - - generic [ref=f3e47]: 검색 - - generic [ref=f3e48]: - - searchbox "검색" [ref=f3e49] - - button "검색" [ref=f3e50] - - generic [ref=f3e51]: - - text: 종류 - - combobox "종류" [ref=f3e52]: - - option "전체" [selected] - - option "Case" - - option "Reference" - - option "Question" - - option "Decision" - - generic [ref=f3e53]: - - text: 상태 - - combobox "상태" [ref=f3e54]: - - option "전체" [selected] - - option "게시 전" - - option "게시 중" - - option "게시 취소" - - paragraph [ref=f3e55]: - - generic [ref=f3e56]: 2개 표시 중 - - alert [ref=f3e57]: 삭제하지 못했습니다. 게시 중이거나, 이 기록을 참조하는 곳이 있거나, 다른 곳에서 먼저 수정되었을 수 있습니다. - - generic [ref=f3e58]: - - article [ref=f3e1]: - - paragraph [ref=f3e2]: Case - - generic [ref=f3e3]: - - heading [level=2] [ref=f3e4]: - - link "게시 흐름 확인" [ref=f3e5] [cursor=pointer]: - - /url: /studio/documents/b09f168b-b205-478f-b60f-83872d1b4d01/edit - - paragraph [ref=f3e6]: 프로젝트 미지정 - - generic [ref=f3e7]: - - generic [ref=f3e8]: - - term [ref=f3e9]: 상태 - - definition [ref=f3e10]: 게시 취소 - - generic [ref=f3e11]: - - term [ref=f3e12]: 다음 - - definition [ref=f3e13]: - - link "검증하기" [ref=f3e14] [cursor=pointer]: - - /url: /studio/documents/b09f168b-b205-478f-b60f-83872d1b4d01/validation - - generic [ref=f3e15]: - - term [ref=f3e16]: 수정 - - definition [ref=f3e17]: - - time [ref=f3e18]: 2026. 8. 21. - - button "삭제" [ref=f3e19] - - article [ref=f3e59]: - - paragraph [ref=f3e60]: Case - - generic [ref=f3e61]: - - heading [level=2] [ref=f3e62]: - - link "브라우저에서 토큰을 어디까지 다뤄야 하는가..?" [ref=f3e63] [cursor=pointer]: - - /url: /studio/documents/f363edc8-2c13-4995-acda-934237034a85/edit - - paragraph [ref=f3e64]: 프로젝트 미지정 - - generic [ref=f3e65]: - - generic [ref=f3e66]: - - term [ref=f3e67]: 상태 - - definition [ref=f3e68]: 게시 전 - - generic [ref=f3e69]: - - term [ref=f3e70]: 다음 - - definition [ref=f3e71]: - - link "검증하기" [ref=f3e72] [cursor=pointer]: - - /url: /studio/documents/f363edc8-2c13-4995-acda-934237034a85/validation - - generic [ref=f3e73]: - - term [ref=f3e74]: 수정 - - definition [ref=f3e75]: - - time [ref=f3e76]: 2026. 8. 21. - - button "삭제" [ref=f3e77] - - paragraph [ref=f3e78] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-08-21T10-08-20-113Z.yml b/.playwright-mcp/page-2026-08-21T10-08-20-113Z.yml deleted file mode 100644 index e69de29..0000000 diff --git a/.playwright-mcp/page-2026-08-21T10-08-24-554Z.yml b/.playwright-mcp/page-2026-08-21T10-08-24-554Z.yml deleted file mode 100644 index 513732f..0000000 --- a/.playwright-mcp/page-2026-08-21T10-08-24-554Z.yml +++ /dev/null @@ -1,55 +0,0 @@ -- generic [ref=f4e3]: - - link "본문으로 건너뛰기" [ref=f4e4] [cursor=pointer]: - - /url: "#main-content" - - banner [ref=f4e5]: - - generic [ref=f4e6]: - - link "TechLog Studio" [ref=f4e7] [cursor=pointer]: - - /url: /studio - - text: TechLog - - generic [ref=f4e8]: Studio - - navigation "Studio 주 탐색" [ref=f4e10]: - - link "작업본" [ref=f4e11] [cursor=pointer]: - - /url: /studio/documents - - link "게시 기록" [ref=f4e12] [cursor=pointer]: - - /url: /studio/publications - - link "새 문서" [ref=f4e13] [cursor=pointer]: - - /url: /studio/documents/new - - link "주제·프로젝트" [ref=f4e14] [cursor=pointer]: - - /url: /studio/taxonomy - - link "릴리즈" [ref=f4e15] [cursor=pointer]: - - /url: /studio/releases - - link "공개 사이트 보기" [ref=f4e16] [cursor=pointer]: - - /url: / - - button "로그아웃" [ref=f4e17] - - main [ref=f4e18]: - - generic [ref=f4e19]: - - generic [ref=f4e20]: - - paragraph [ref=f4e21]: NEW WORKING COPY - - heading "새 문서" [level=1] [ref=f4e22] - - paragraph [ref=f4e23]: 목적에 맞는 기록 종류를 선택하면 빈 작업본을 만들고 바로 편집을 시작합니다. - - generic [ref=f4e24]: - - group "문서 종류" [ref=f4e25]: - - generic [ref=f4e27] [cursor=pointer]: - - radio "Case 문제를 재현하고 검증한 결론을 기록합니다. 문제 · 결론 · 환경 · 재현 · 본문" [checked] [active] [ref=f4e28] - - strong [ref=f4e29]: Case - - generic [ref=f4e30]: 문제를 재현하고 검증한 결론을 기록합니다. - - generic [ref=f4e31]: 문제 · 결론 · 환경 · 재현 · 본문 - - generic [ref=f4e32] [cursor=pointer]: - - radio "Reference 반복해서 적용할 기술 기준을 정리합니다. 목적 · 규칙 · 적용 조건 · 예외 · 예시" [ref=f4e33] - - strong [ref=f4e34]: Reference - - generic [ref=f4e35]: 반복해서 적용할 기술 기준을 정리합니다. - - generic [ref=f4e36]: 목적 · 규칙 · 적용 조건 · 예외 · 예시 - - generic [ref=f4e37] [cursor=pointer]: - - radio "Question 아직 닫히지 않은 판단과 다음 검증을 관리합니다. 상태 · 사실 · 가정 · 미지수 · 선택지" [ref=f4e38] - - strong [ref=f4e39]: Question - - generic [ref=f4e40]: 아직 닫히지 않은 판단과 다음 검증을 관리합니다. - - generic [ref=f4e41]: 상태 · 사실 · 가정 · 미지수 · 선택지 - - generic [ref=f4e42] [cursor=pointer]: - - radio "Decision 프로젝트가 선택한 방향과 그 근거·영향을 기록합니다. 상태 · 결정일 · 결정문 · 판단 이유 · 영향 · 근거" [ref=f4e43] - - strong [ref=f4e44]: Decision - - generic [ref=f4e45]: 프로젝트가 선택한 방향과 그 근거·영향을 기록합니다. - - generic [ref=f4e46]: 상태 · 결정일 · 결정문 · 판단 이유 · 영향 · 근거 - - generic [ref=f4e47]: - - button "작업본 만들기" [ref=f4e48] - - paragraph [ref=f4e49]: 이 화면의 작업본은 현재 Studio 세션에서만 유지됩니다. - - paragraph [ref=f4e50] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-08-21T10-08-30-303Z.yml b/.playwright-mcp/page-2026-08-21T10-08-30-303Z.yml deleted file mode 100644 index 676f186..0000000 --- a/.playwright-mcp/page-2026-08-21T10-08-30-303Z.yml +++ /dev/null @@ -1,143 +0,0 @@ -- generic [ref=f4e3]: - - link "본문으로 건너뛰기" [ref=f4e4] [cursor=pointer]: - - /url: "#main-content" - - banner [ref=f4e5]: - - generic [ref=f4e6]: - - link "TechLog Studio" [ref=f4e7] [cursor=pointer]: - - /url: /studio - - text: TechLog - - generic [ref=f4e8]: Studio - - navigation "Studio 주 탐색" [ref=f4e10]: - - link "작업본" [ref=f4e11] [cursor=pointer]: - - /url: /studio/documents - - link "게시 기록" [ref=f4e12] [cursor=pointer]: - - /url: /studio/publications - - link "새 문서" [ref=f4e13] [cursor=pointer]: - - /url: /studio/documents/new - - link "주제·프로젝트" [ref=f4e14] [cursor=pointer]: - - /url: /studio/taxonomy - - link "릴리즈" [ref=f4e15] [cursor=pointer]: - - /url: /studio/releases - - link "공개 사이트 보기" [ref=f4e16] [cursor=pointer]: - - /url: / - - button "로그아웃" [ref=f4e17] - - main [ref=f4e18]: - - generic [ref=f4e51]: - - tablist "문서 편집 화면" [ref=f4e52]: - - tab "편집" [selected] [ref=f4e53] - - tab "즉시 미리보기" [ref=f4e54] - - generic [ref=f4e55]: - - tabpanel "편집" [ref=f4e57]: - - generic [ref=f4e58]: - - paragraph [ref=f4e59]: CASE · VERSION 1 - - heading "문서 편집" [level=1] [ref=f4e60] - - paragraph [ref=f4e61]: 제목 없는 작업본 - - region [ref=f4e62]: - - generic [ref=f4e63]: - - paragraph [ref=f4e64]: DOCUMENT - - heading "기본 정보" [level=2] [ref=f4e65] - - generic [ref=f4e66]: - - generic [ref=f4e67]: - - generic [ref=f4e68]: 제목 - - textbox "제목" [ref=f4e69] - - generic [ref=f4e70]: - - generic [ref=f4e71]: slug - - textbox "slug" [ref=f4e72]: - - /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈) - - generic [ref=f4e73]: - - generic [ref=f4e74]: 요약 - - textbox "요약" [ref=f4e75] - - generic [ref=f4e76]: - - generic [ref=f4e77]: Topic - - combobox "Topic" [ref=f4e78]: - - option "선택하지 않음" [selected] - - option "OAuth/OIDC 인증 경계" - - generic [ref=f4e79]: - - generic [ref=f4e80]: Project - - combobox "Project" [ref=f4e81]: - - option "미지정" [selected] - - option "Backend Clean Architecture" - - option "KeyCloak Patterns" - - option "Liner N + 1문제" - - group "관계" [ref=f4e82]: - - paragraph [ref=f4e84]: 연결한 공개 기록이 없습니다. - - button "관계 추가" [ref=f4e85] - - region [ref=f4e86]: - - generic [ref=f4e87]: - - paragraph [ref=f4e88]: CASE - - heading "문제와 검증" [level=2] [ref=f4e89] - - generic [ref=f4e90]: - - generic [ref=f4e91]: - - generic [ref=f4e92]: 문제 - - textbox "문제" [ref=f4e93] - - generic [ref=f4e94]: - - generic [ref=f4e95]: 결론 - - textbox "결론" [ref=f4e96] - - generic [ref=f4e97]: - - generic [ref=f4e98]: 검증 환경 - - textbox "검증 환경" [ref=f4e99] - - generic [ref=f4e100]: - - generic [ref=f4e101]: 재현 조건 - - textbox "재현 조건" [ref=f4e102] - - generic [ref=f4e103]: - - generic [ref=f4e104]: 마지막 검증일 - - textbox "마지막 검증일" [ref=f4e105] - - generic [ref=f4e106]: - - generic [ref=f4e107]: 본문 Markdown - - textbox "본문 Markdown" [ref=f4e108] - - generic [ref=f4e109]: - - paragraph [ref=f4e110]: EVIDENCE - - heading "본문에 Asset 삽입" [level=3] [ref=f4e111] - - paragraph [ref=f4e112]: 목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다. - - generic [ref=f4e113]: - - generic [ref=f4e114]: - - generic [ref=f4e115]: 업로드 종류 - - combobox "업로드 종류" [ref=f4e116]: - - option "이미지" [selected] - - option "다이어그램" - - option "첨부파일" - - button "Asset 업로드" [ref=f4e117] - - generic [ref=f4e118]: - - search [ref=f4e119]: - - generic [ref=f4e120]: Asset 검색 - - generic [ref=f4e121]: - - searchbox "Asset 검색" [ref=f4e122] - - button "검색" [ref=f4e123] - - generic [ref=f4e124]: - - checkbox "삽입할 때 크게 보기 허용" [checked] [ref=f4e125] - - generic [ref=f4e126]: 삽입할 때 크게 보기 허용 - - status [ref=f4e127]: 삽입할 수 있는 Asset 1개 - - list [ref=f4e128]: - - listitem [ref=f4e129]: - - button "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [ref=f4e130] - - button "삭제" [ref=f4e131] - - complementary [ref=f4e132]: - - paragraph [ref=f4e133]: WORKING COPY - - heading "작업 상태" [level=2] [ref=f4e134] - - status "편집 상태" [ref=f4e135]: 저장됨 - - generic [ref=f4e136]: - - generic [ref=f4e137]: - - term [ref=f4e138]: 저장 버전 - - definition [ref=f4e139]: "1" - - generic [ref=f4e140]: - - term [ref=f4e141]: 종류 - - definition [ref=f4e142]: CASE - - button "저장" [disabled] [ref=f4e143] - - navigation "게시까지의 단계" [ref=f4e144]: - - list [ref=f4e145]: - - listitem [ref=f4e146]: - - generic [ref=f4e147]: "1" - - link "검증" [ref=f4e148] [cursor=pointer]: - - /url: /studio/documents/969cc2c7-be6e-42ca-924a-652c63db9784/validation - - listitem [ref=f4e149]: - - text: → - - generic [ref=f4e150]: "2" - - link "미리보기" [ref=f4e151] [cursor=pointer]: - - /url: /studio/documents/969cc2c7-be6e-42ca-924a-652c63db9784/preview - - listitem [ref=f4e152]: - - text: → - - generic [ref=f4e153]: "3" - - link "게시" [ref=f4e154] [cursor=pointer]: - - /url: /studio/documents/969cc2c7-be6e-42ca-924a-652c63db9784/publish - - paragraph [ref=f4e155]: 불완전한 초안도 저장할 수 있습니다. 게시 가능 여부는 이후 검증 단계에서 확인합니다. - - paragraph [ref=f4e50]: Case 작업본을 만들었습니다. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-08-21T10-08-35-304Z.yml b/.playwright-mcp/page-2026-08-21T10-08-35-304Z.yml deleted file mode 100644 index e69de29..0000000 diff --git a/.playwright-mcp/page-2026-08-21T10-08-44-012Z.yml b/.playwright-mcp/page-2026-08-21T10-08-44-012Z.yml deleted file mode 100644 index 198c4e2..0000000 --- a/.playwright-mcp/page-2026-08-21T10-08-44-012Z.yml +++ /dev/null @@ -1,99 +0,0 @@ -- generic [ref=f5e22]: - - link "본문으로 건너뛰기" [ref=f5e23] [cursor=pointer]: - - /url: "#main-content" - - banner [ref=f5e24]: - - generic [ref=f5e25]: - - link "TechLog Studio" [ref=f5e26] [cursor=pointer]: - - /url: /studio - - text: TechLog - - generic [ref=f5e27]: Studio - - navigation "Studio 주 탐색" [ref=f5e29]: - - link "작업본" [ref=f5e30] [cursor=pointer]: - - /url: /studio/documents - - link "게시 기록" [ref=f5e31] [cursor=pointer]: - - /url: /studio/publications - - link "새 문서" [ref=f5e32] [cursor=pointer]: - - /url: /studio/documents/new - - link "주제·프로젝트" [ref=f5e33] [cursor=pointer]: - - /url: /studio/taxonomy - - link "릴리즈" [ref=f5e34] [cursor=pointer]: - - /url: /studio/releases - - link "공개 사이트 보기" [ref=f5e35] [cursor=pointer]: - - /url: / - - button "로그아웃" [ref=f5e36] - - main [ref=f5e37]: - - generic [ref=f5e38]: - - generic [ref=f5e39]: - - generic [ref=f5e40]: - - paragraph [ref=f5e41]: WORKING COPIES - - heading "작업본" [level=1] [ref=f5e42] - - paragraph [ref=f5e43]: 세션에 있는 Case, Reference, Question, Decision을 찾고 다음 작업으로 이동합니다. - - link "새 문서" [ref=f5e44] [cursor=pointer]: - - /url: /studio/documents/new - - region "작업본 검색과 필터" [ref=f5e45]: - - search [ref=f5e46]: - - generic [ref=f5e47]: 검색 - - generic [ref=f5e48]: - - searchbox "검색" [ref=f5e49] - - button "검색" [ref=f5e50] - - generic [ref=f5e51]: - - text: 종류 - - combobox "종류" [ref=f5e52]: - - option "전체" [selected] - - option "Case" - - option "Reference" - - option "Question" - - option "Decision" - - generic [ref=f5e53]: - - text: 상태 - - combobox "상태" [ref=f5e54]: - - option "전체" [selected] - - option "게시 전" - - option "게시 중" - - option "게시 취소" - - paragraph [ref=f5e55]: - - generic [ref=f5e56]: 2개 표시 중 - - generic [ref=f5e57]: - - article [ref=f5e58]: - - paragraph [ref=f5e59]: Case - - generic [ref=f5e60]: - - heading [level=2] [ref=f5e61]: - - link "게시 흐름 확인" [ref=f5e62] [cursor=pointer]: - - /url: /studio/documents/b09f168b-b205-478f-b60f-83872d1b4d01/edit - - paragraph [ref=f5e63]: 프로젝트 미지정 - - generic [ref=f5e64]: - - generic [ref=f5e65]: - - term [ref=f5e66]: 상태 - - definition [ref=f5e67]: 게시 취소 - - generic [ref=f5e68]: - - term [ref=f5e69]: 다음 - - definition [ref=f5e70]: - - link "검증하기" [ref=f5e71] [cursor=pointer]: - - /url: /studio/documents/b09f168b-b205-478f-b60f-83872d1b4d01/validation - - generic [ref=f5e72]: - - term [ref=f5e73]: 수정 - - definition [ref=f5e74]: - - time [ref=f5e75]: 2026. 8. 21. - - button "삭제" [ref=f5e76] - - article [ref=f5e77]: - - paragraph [ref=f5e78]: Case - - generic [ref=f5e79]: - - heading [level=2] [ref=f5e80]: - - link "브라우저에서 토큰을 어디까지 다뤄야 하는가..?" [ref=f5e81] [cursor=pointer]: - - /url: /studio/documents/f363edc8-2c13-4995-acda-934237034a85/edit - - paragraph [ref=f5e82]: 프로젝트 미지정 - - generic [ref=f5e83]: - - generic [ref=f5e84]: - - term [ref=f5e85]: 상태 - - definition [ref=f5e86]: 게시 전 - - generic [ref=f5e87]: - - term [ref=f5e88]: 다음 - - definition [ref=f5e89]: - - link "검증하기" [ref=f5e90] [cursor=pointer]: - - /url: /studio/documents/f363edc8-2c13-4995-acda-934237034a85/validation - - generic [ref=f5e91]: - - term [ref=f5e92]: 수정 - - definition [ref=f5e93]: - - time [ref=f5e94]: 2026. 8. 21. - - button "삭제" [ref=f5e95] - - paragraph [ref=f5e96]: 작업본 제목 없음 을(를) 삭제했습니다. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-08-21T10-09-11-287Z.yml b/.playwright-mcp/page-2026-08-21T10-09-11-287Z.yml deleted file mode 100644 index e69de29..0000000 diff --git a/.playwright-mcp/page-2026-08-21T10-09-16-578Z.yml b/.playwright-mcp/page-2026-08-21T10-09-16-578Z.yml deleted file mode 100644 index d4f7965..0000000 --- a/.playwright-mcp/page-2026-08-21T10-09-16-578Z.yml +++ /dev/null @@ -1,55 +0,0 @@ -- generic [ref=f6e3]: - - link "본문으로 건너뛰기" [ref=f6e4] [cursor=pointer]: - - /url: "#main-content" - - banner [ref=f6e5]: - - generic [ref=f6e6]: - - link "TechLog Studio" [ref=f6e7] [cursor=pointer]: - - /url: /studio - - text: TechLog - - generic [ref=f6e8]: Studio - - navigation "Studio 주 탐색" [ref=f6e10]: - - link "작업본" [ref=f6e11] [cursor=pointer]: - - /url: /studio/documents - - link "게시 기록" [ref=f6e12] [cursor=pointer]: - - /url: /studio/publications - - link "새 문서" [ref=f6e13] [cursor=pointer]: - - /url: /studio/documents/new - - link "주제·프로젝트" [ref=f6e14] [cursor=pointer]: - - /url: /studio/taxonomy - - link "릴리즈" [ref=f6e15] [cursor=pointer]: - - /url: /studio/releases - - link "공개 사이트 보기" [ref=f6e16] [cursor=pointer]: - - /url: / - - button "로그아웃" [ref=f6e17] - - main [ref=f6e18]: - - generic [ref=f6e19]: - - generic [ref=f6e20]: - - paragraph [ref=f6e21]: NEW WORKING COPY - - heading "새 문서" [level=1] [ref=f6e22] - - paragraph [ref=f6e23]: 목적에 맞는 기록 종류를 선택하면 빈 작업본을 만들고 바로 편집을 시작합니다. - - generic [ref=f6e24]: - - group "문서 종류" [ref=f6e25]: - - generic [ref=f6e27] [cursor=pointer]: - - radio "Case 문제를 재현하고 검증한 결론을 기록합니다. 문제 · 결론 · 환경 · 재현 · 본문" [ref=f6e28] - - strong [ref=f6e29]: Case - - generic [ref=f6e30]: 문제를 재현하고 검증한 결론을 기록합니다. - - generic [ref=f6e31]: 문제 · 결론 · 환경 · 재현 · 본문 - - generic [ref=f6e32] [cursor=pointer]: - - radio "Reference 반복해서 적용할 기술 기준을 정리합니다. 목적 · 규칙 · 적용 조건 · 예외 · 예시" [ref=f6e33] - - strong [ref=f6e34]: Reference - - generic [ref=f6e35]: 반복해서 적용할 기술 기준을 정리합니다. - - generic [ref=f6e36]: 목적 · 규칙 · 적용 조건 · 예외 · 예시 - - generic [ref=f6e37] [cursor=pointer]: - - radio "Question 아직 닫히지 않은 판단과 다음 검증을 관리합니다. 상태 · 사실 · 가정 · 미지수 · 선택지" [checked] [active] [ref=f6e38] - - strong [ref=f6e39]: Question - - generic [ref=f6e40]: 아직 닫히지 않은 판단과 다음 검증을 관리합니다. - - generic [ref=f6e41]: 상태 · 사실 · 가정 · 미지수 · 선택지 - - generic [ref=f6e42] [cursor=pointer]: - - radio "Decision 프로젝트가 선택한 방향과 그 근거·영향을 기록합니다. 상태 · 결정일 · 결정문 · 판단 이유 · 영향 · 근거" [ref=f6e43] - - strong [ref=f6e44]: Decision - - generic [ref=f6e45]: 프로젝트가 선택한 방향과 그 근거·영향을 기록합니다. - - generic [ref=f6e46]: 상태 · 결정일 · 결정문 · 판단 이유 · 영향 · 근거 - - generic [ref=f6e47]: - - button "작업본 만들기" [ref=f6e48] - - paragraph [ref=f6e49]: 이 화면의 작업본은 현재 Studio 세션에서만 유지됩니다. - - paragraph [ref=f6e50] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-08-21T10-09-26-317Z.yml b/.playwright-mcp/page-2026-08-21T10-09-26-317Z.yml deleted file mode 100644 index 6f0043c..0000000 --- a/.playwright-mcp/page-2026-08-21T10-09-26-317Z.yml +++ /dev/null @@ -1,122 +0,0 @@ -- generic [ref=f6e3]: - - link "본문으로 건너뛰기" [ref=f6e4] [cursor=pointer]: - - /url: "#main-content" - - banner [ref=f6e5]: - - generic [ref=f6e6]: - - link "TechLog Studio" [ref=f6e7] [cursor=pointer]: - - /url: /studio - - text: TechLog - - generic [ref=f6e8]: Studio - - navigation "Studio 주 탐색" [ref=f6e10]: - - link "작업본" [ref=f6e11] [cursor=pointer]: - - /url: /studio/documents - - link "게시 기록" [ref=f6e12] [cursor=pointer]: - - /url: /studio/publications - - link "새 문서" [ref=f6e13] [cursor=pointer]: - - /url: /studio/documents/new - - link "주제·프로젝트" [ref=f6e14] [cursor=pointer]: - - /url: /studio/taxonomy - - link "릴리즈" [ref=f6e15] [cursor=pointer]: - - /url: /studio/releases - - link "공개 사이트 보기" [ref=f6e16] [cursor=pointer]: - - /url: / - - button "로그아웃" [ref=f6e17] - - main [ref=f6e18]: - - generic [ref=f6e51]: - - tablist "문서 편집 화면" [ref=f6e52]: - - tab "편집" [selected] [ref=f6e53] - - tab "즉시 미리보기" [ref=f6e54] - - generic [ref=f6e55]: - - tabpanel "편집" [ref=f6e57]: - - generic [ref=f6e58]: - - paragraph [ref=f6e59]: QUESTION · VERSION 1 - - heading "문서 편집" [level=1] [ref=f6e60] - - paragraph [ref=f6e61]: 제목 없는 작업본 - - region [ref=f6e62]: - - generic [ref=f6e63]: - - paragraph [ref=f6e64]: DOCUMENT - - heading "기본 정보" [level=2] [ref=f6e65] - - generic [ref=f6e66]: - - generic [ref=f6e67]: - - generic [ref=f6e68]: 제목 - - textbox "제목" [ref=f6e69] - - generic [ref=f6e70]: - - generic [ref=f6e71]: slug - - textbox "slug" [ref=f6e72]: - - /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈) - - generic [ref=f6e73]: - - generic [ref=f6e74]: 요약 - - textbox "요약" [ref=f6e75] - - generic [ref=f6e76]: - - generic [ref=f6e77]: Topic - - combobox "Topic" [ref=f6e78]: - - option "선택하지 않음" [selected] - - option "OAuth/OIDC 인증 경계" - - generic [ref=f6e79]: - - generic [ref=f6e80]: Project - - combobox "Project" [ref=f6e81]: - - option "미지정" [selected] - - option "Backend Clean Architecture" - - option "KeyCloak Patterns" - - option "Liner N + 1문제" - - group "관계" [ref=f6e82]: - - paragraph [ref=f6e84]: 연결한 공개 기록이 없습니다. - - button "관계 추가" [ref=f6e85] - - region [ref=f6e86]: - - generic [ref=f6e87]: - - paragraph [ref=f6e88]: QUESTION - - heading "판단과 다음 검증" [level=2] [ref=f6e89] - - generic [ref=f6e90]: - - generic [ref=f6e91]: 질문 상태 - - combobox "질문 상태" [ref=f6e92]: - - option "아직 정하지 않음" - - option "OPEN" [selected] - - option "RESOLVED" - - group "사실" [ref=f6e93]: - - paragraph [ref=f6e95]: 아직 입력한 항목이 없습니다. - - button "사실 추가" [ref=f6e96] - - group "가정" [ref=f6e97]: - - paragraph [ref=f6e99]: 아직 입력한 항목이 없습니다. - - button "가정 추가" [ref=f6e100] - - group "미지수" [ref=f6e101]: - - paragraph [ref=f6e103]: 아직 입력한 항목이 없습니다. - - button "미지수 추가" [ref=f6e104] - - group "제약" [ref=f6e105]: - - paragraph [ref=f6e107]: 아직 입력한 항목이 없습니다. - - button "제약 추가" [ref=f6e108] - - group "선택지" [ref=f6e109]: - - paragraph [ref=f6e111]: 아직 입력한 선택지가 없습니다. - - button "선택지 추가" [ref=f6e112] - - generic [ref=f6e113]: - - generic [ref=f6e114]: 다음 검증 - - textbox "다음 검증" [ref=f6e115] - - complementary [ref=f6e116]: - - paragraph [ref=f6e117]: WORKING COPY - - heading "작업 상태" [level=2] [ref=f6e118] - - status "편집 상태" [ref=f6e119]: 저장됨 - - generic [ref=f6e120]: - - generic [ref=f6e121]: - - term [ref=f6e122]: 저장 버전 - - definition [ref=f6e123]: "1" - - generic [ref=f6e124]: - - term [ref=f6e125]: 종류 - - definition [ref=f6e126]: QUESTION - - button "저장" [disabled] [ref=f6e127] - - navigation "게시까지의 단계" [ref=f6e128]: - - list [ref=f6e129]: - - listitem [ref=f6e130]: - - generic [ref=f6e131]: "1" - - link "검증" [ref=f6e132] [cursor=pointer]: - - /url: /studio/documents/f54170bb-e1a2-466d-9d1c-39563e03266c/validation - - listitem [ref=f6e133]: - - text: → - - generic [ref=f6e134]: "2" - - link "미리보기" [ref=f6e135] [cursor=pointer]: - - /url: /studio/documents/f54170bb-e1a2-466d-9d1c-39563e03266c/preview - - listitem [ref=f6e136]: - - text: → - - generic [ref=f6e137]: "3" - - link "게시" [ref=f6e138] [cursor=pointer]: - - /url: /studio/documents/f54170bb-e1a2-466d-9d1c-39563e03266c/publish - - paragraph [ref=f6e139]: 불완전한 초안도 저장할 수 있습니다. 게시 가능 여부는 이후 검증 단계에서 확인합니다. - - paragraph [ref=f6e50]: Question 작업본을 만들었습니다. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-08-21T10-09-30-337Z.yml b/.playwright-mcp/page-2026-08-21T10-09-30-337Z.yml deleted file mode 100644 index e69de29..0000000 diff --git a/.playwright-mcp/page-2026-08-21T10-09-36-210Z.yml b/.playwright-mcp/page-2026-08-21T10-09-36-210Z.yml deleted file mode 100644 index 7ed9a21..0000000 --- a/.playwright-mcp/page-2026-08-21T10-09-36-210Z.yml +++ /dev/null @@ -1,99 +0,0 @@ -- generic [ref=f7e3]: - - link "본문으로 건너뛰기" [ref=f7e4] [cursor=pointer]: - - /url: "#main-content" - - banner [ref=f7e5]: - - generic [ref=f7e6]: - - link "TechLog Studio" [ref=f7e7] [cursor=pointer]: - - /url: /studio - - text: TechLog - - generic [ref=f7e8]: Studio - - navigation "Studio 주 탐색" [ref=f7e10]: - - link "작업본" [ref=f7e11] [cursor=pointer]: - - /url: /studio/documents - - link "게시 기록" [ref=f7e12] [cursor=pointer]: - - /url: /studio/publications - - link "새 문서" [ref=f7e13] [cursor=pointer]: - - /url: /studio/documents/new - - link "주제·프로젝트" [ref=f7e14] [cursor=pointer]: - - /url: /studio/taxonomy - - link "릴리즈" [ref=f7e15] [cursor=pointer]: - - /url: /studio/releases - - link "공개 사이트 보기" [ref=f7e16] [cursor=pointer]: - - /url: / - - button "로그아웃" [ref=f7e17] - - main [ref=f7e18]: - - generic [ref=f7e19]: - - generic [ref=f7e20]: - - generic [ref=f7e21]: - - paragraph [ref=f7e22]: WORKING COPIES - - heading "작업본" [level=1] [ref=f7e23] - - paragraph [ref=f7e24]: 세션에 있는 Case, Reference, Question, Decision을 찾고 다음 작업으로 이동합니다. - - link "새 문서" [ref=f7e25] [cursor=pointer]: - - /url: /studio/documents/new - - region "작업본 검색과 필터" [ref=f7e26]: - - search [ref=f7e27]: - - generic [ref=f7e28]: 검색 - - generic [ref=f7e29]: - - searchbox "검색" [ref=f7e30] - - button "검색" [ref=f7e31] - - generic [ref=f7e32]: - - text: 종류 - - combobox "종류" [ref=f7e33]: - - option "전체" [selected] - - option "Case" - - option "Reference" - - option "Question" - - option "Decision" - - generic [ref=f7e34]: - - text: 상태 - - combobox "상태" [ref=f7e35]: - - option "전체" [selected] - - option "게시 전" - - option "게시 중" - - option "게시 취소" - - paragraph [ref=f7e36]: - - generic [ref=f7e37]: 2개 표시 중 - - generic [ref=f7e38]: - - article [ref=f7e39]: - - paragraph [ref=f7e40]: Case - - generic [ref=f7e41]: - - heading [level=2] [ref=f7e42]: - - link "게시 흐름 확인" [ref=f7e43] [cursor=pointer]: - - /url: /studio/documents/b09f168b-b205-478f-b60f-83872d1b4d01/edit - - paragraph [ref=f7e44]: 프로젝트 미지정 - - generic [ref=f7e45]: - - generic [ref=f7e46]: - - term [ref=f7e47]: 상태 - - definition [ref=f7e48]: 게시 취소 - - generic [ref=f7e49]: - - term [ref=f7e50]: 다음 - - definition [ref=f7e51]: - - link "검증하기" [ref=f7e52] [cursor=pointer]: - - /url: /studio/documents/b09f168b-b205-478f-b60f-83872d1b4d01/validation - - generic [ref=f7e53]: - - term [ref=f7e54]: 수정 - - definition [ref=f7e55]: - - time [ref=f7e56]: 2026. 8. 21. - - button "삭제" [ref=f7e57] - - article [ref=f7e58]: - - paragraph [ref=f7e59]: Case - - generic [ref=f7e60]: - - heading [level=2] [ref=f7e61]: - - link "브라우저에서 토큰을 어디까지 다뤄야 하는가..?" [ref=f7e62] [cursor=pointer]: - - /url: /studio/documents/f363edc8-2c13-4995-acda-934237034a85/edit - - paragraph [ref=f7e63]: 프로젝트 미지정 - - generic [ref=f7e64]: - - generic [ref=f7e65]: - - term [ref=f7e66]: 상태 - - definition [ref=f7e67]: 게시 전 - - generic [ref=f7e68]: - - term [ref=f7e69]: 다음 - - definition [ref=f7e70]: - - link "검증하기" [ref=f7e71] [cursor=pointer]: - - /url: /studio/documents/f363edc8-2c13-4995-acda-934237034a85/validation - - generic [ref=f7e72]: - - term [ref=f7e73]: 수정 - - definition [ref=f7e74]: - - time [ref=f7e75]: 2026. 8. 21. - - button "삭제" [ref=f7e76] - - paragraph [ref=f7e77]: 작업본 제목 없음 을(를) 삭제했습니다. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-08-21T10-09-52-861Z.yml b/.playwright-mcp/page-2026-08-21T10-09-52-861Z.yml deleted file mode 100644 index e69de29..0000000 diff --git a/.playwright-mcp/page-2026-08-21T10-10-12-379Z.yml b/.playwright-mcp/page-2026-08-21T10-10-12-379Z.yml deleted file mode 100644 index fa71e68..0000000 --- a/.playwright-mcp/page-2026-08-21T10-10-12-379Z.yml +++ /dev/null @@ -1,106 +0,0 @@ -- generic [ref=f8e74]: - - link "본문으로 건너뛰기" [ref=f8e75] [cursor=pointer]: - - /url: "#main-content" - - banner [ref=f8e76]: - - generic [ref=f8e77]: - - link "TechLog Studio" [ref=f8e78] [cursor=pointer]: - - /url: /studio - - text: TechLog - - generic [ref=f8e79]: Studio - - navigation "Studio 주 탐색" [ref=f8e81]: - - link "작업본" [ref=f8e82] [cursor=pointer]: - - /url: /studio/documents - - link "게시 기록" [ref=f8e83] [cursor=pointer]: - - /url: /studio/publications - - link "새 문서" [ref=f8e84] [cursor=pointer]: - - /url: /studio/documents/new - - link "주제·프로젝트" [ref=f8e85] [cursor=pointer]: - - /url: /studio/taxonomy - - link "릴리즈" [ref=f8e86] [cursor=pointer]: - - /url: /studio/releases - - link "공개 사이트 보기" [ref=f8e87] [cursor=pointer]: - - /url: / - - button "로그아웃" [ref=f8e88] - - main [ref=f8e89]: - - generic [ref=f8e1]: - - generic [ref=f8e3]: - - paragraph [ref=f8e4]: TAXONOMY - - heading "주제와 프로젝트" [level=1] [ref=f8e5] - - paragraph [ref=f8e6]: 문서를 게시하려면 주제가 필요합니다. 여기서 만들고 정리합니다. - - region "주제 만들기" [ref=f8e7]: - - generic [ref=f8e8]: - - generic [ref=f8e9]: 새 주제 - - generic [ref=f8e10]: - - textbox "새 주제" [ref=f8e11]: - - /placeholder: 주제 이름 - - textbox "주제 slug" [ref=f8e12]: - - /placeholder: slug (비우면 이름에서 생성) - - button "추가" [ref=f8e13] - - generic [ref=f8e14]: - - generic [ref=f8e15]: 새 프로젝트 - - generic [ref=f8e16]: - - textbox "새 프로젝트" [ref=f8e17]: - - /placeholder: 프로젝트 이름 - - button "추가" [ref=f8e18] - - paragraph [ref=f8e90]: 2개의 주제 - - generic [ref=f8e91]: - - article [ref=f8e92]: - - paragraph [ref=f8e93]: TOPIC - - generic [ref=f8e94]: - - heading "삭제 시험 주제" [level=2] [ref=f8e95] - - paragraph [ref=f8e96]: sakje-siheom-juje - - generic [ref=f8e98]: - - term [ref=f8e99]: 상태 - - definition [ref=f8e100]: 사용 중 - - button "삭제" [ref=f8e101] - - article [ref=f8e102]: - - paragraph [ref=f8e103]: TOPIC - - generic [ref=f8e104]: - - heading "OAuth/OIDC 인증 경계" [level=2] [ref=f8e105] - - paragraph [ref=f8e106]: oauth-oidc-auth-boundary - - generic [ref=f8e108]: - - term [ref=f8e109]: 상태 - - definition [ref=f8e110]: 사용 중 - - button "삭제" [ref=f8e111] - - paragraph [ref=f8e112]: 3개의 프로젝트 - - generic [ref=f8e113]: - - article [ref=f8e114]: - - paragraph [ref=f8e115]: PROJECT - - generic [ref=f8e116]: - - heading "Liner N + 1문제" [level=2] [ref=f8e117] - - paragraph [ref=f8e118]: 목표 미지정 - - generic [ref=f8e119]: - - generic [ref=f8e120]: - - term [ref=f8e121]: 단계 - - definition [ref=f8e122]: RESEARCH - - generic [ref=f8e123]: - - term [ref=f8e124]: 공개 - - definition [ref=f8e125]: PRIVATE - - button "삭제" [ref=f8e126] - - article [ref=f8e127]: - - paragraph [ref=f8e128]: PROJECT - - generic [ref=f8e129]: - - heading "KeyCloak Patterns" [level=2] [ref=f8e130] - - paragraph [ref=f8e131]: 목표 미지정 - - generic [ref=f8e132]: - - generic [ref=f8e133]: - - term [ref=f8e134]: 단계 - - definition [ref=f8e135]: RESEARCH - - generic [ref=f8e136]: - - term [ref=f8e137]: 공개 - - definition [ref=f8e138]: PRIVATE - - button "삭제" [ref=f8e139] - - article [ref=f8e140]: - - paragraph [ref=f8e141]: PROJECT - - generic [ref=f8e142]: - - heading "Backend Clean Architecture" [level=2] [ref=f8e143] - - paragraph [ref=f8e144]: 목표 미지정 - - generic [ref=f8e145]: - - generic [ref=f8e146]: - - term [ref=f8e147]: 단계 - - definition [ref=f8e148]: RESEARCH - - generic [ref=f8e149]: - - term [ref=f8e150]: 공개 - - definition [ref=f8e151]: PRIVATE - - button "삭제" [ref=f8e152] - - paragraph [ref=f8e153]: 주제 삭제 시험 주제 을(를) 만들었습니다. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-08-21T10-10-26-016Z.yml b/.playwright-mcp/page-2026-08-21T10-10-26-016Z.yml deleted file mode 100644 index 1db5b84..0000000 --- a/.playwright-mcp/page-2026-08-21T10-10-26-016Z.yml +++ /dev/null @@ -1,96 +0,0 @@ -- generic [ref=f8e74]: - - link "본문으로 건너뛰기" [ref=f8e75] [cursor=pointer]: - - /url: "#main-content" - - banner [ref=f8e76]: - - generic [ref=f8e77]: - - link "TechLog Studio" [ref=f8e78] [cursor=pointer]: - - /url: /studio - - text: TechLog - - generic [ref=f8e79]: Studio - - navigation "Studio 주 탐색" [ref=f8e81]: - - link "작업본" [ref=f8e82] [cursor=pointer]: - - /url: /studio/documents - - link "게시 기록" [ref=f8e83] [cursor=pointer]: - - /url: /studio/publications - - link "새 문서" [ref=f8e84] [cursor=pointer]: - - /url: /studio/documents/new - - link "주제·프로젝트" [ref=f8e85] [cursor=pointer]: - - /url: /studio/taxonomy - - link "릴리즈" [ref=f8e86] [cursor=pointer]: - - /url: /studio/releases - - link "공개 사이트 보기" [ref=f8e87] [cursor=pointer]: - - /url: / - - button "로그아웃" [ref=f8e88] - - main [ref=f8e89]: - - generic [ref=f8e1]: - - generic [ref=f8e3]: - - paragraph [ref=f8e4]: TAXONOMY - - heading "주제와 프로젝트" [level=1] [ref=f8e5] - - paragraph [ref=f8e6]: 문서를 게시하려면 주제가 필요합니다. 여기서 만들고 정리합니다. - - region "주제 만들기" [ref=f8e7]: - - generic [ref=f8e8]: - - generic [ref=f8e9]: 새 주제 - - generic [ref=f8e10]: - - textbox "새 주제" [ref=f8e11]: - - /placeholder: 주제 이름 - - textbox "주제 slug" [ref=f8e12]: - - /placeholder: slug (비우면 이름에서 생성) - - button "추가" [ref=f8e13] - - generic [ref=f8e14]: - - generic [ref=f8e15]: 새 프로젝트 - - generic [ref=f8e16]: - - textbox "새 프로젝트" [ref=f8e17]: - - /placeholder: 프로젝트 이름 - - button "추가" [ref=f8e18] - - paragraph [ref=f8e154]: 1개의 주제 - - article [ref=f8e156]: - - paragraph [ref=f8e157]: TOPIC - - generic [ref=f8e158]: - - heading "OAuth/OIDC 인증 경계" [level=2] [ref=f8e159] - - paragraph [ref=f8e160]: oauth-oidc-auth-boundary - - generic [ref=f8e162]: - - term [ref=f8e163]: 상태 - - definition [ref=f8e164]: 사용 중 - - button "삭제" [ref=f8e165] - - paragraph [ref=f8e166]: 3개의 프로젝트 - - generic [ref=f8e167]: - - article [ref=f8e168]: - - paragraph [ref=f8e169]: PROJECT - - generic [ref=f8e170]: - - heading "Liner N + 1문제" [level=2] [ref=f8e171] - - paragraph [ref=f8e172]: 목표 미지정 - - generic [ref=f8e173]: - - generic [ref=f8e174]: - - term [ref=f8e175]: 단계 - - definition [ref=f8e176]: RESEARCH - - generic [ref=f8e177]: - - term [ref=f8e178]: 공개 - - definition [ref=f8e179]: PRIVATE - - button "삭제" [ref=f8e180] - - article [ref=f8e181]: - - paragraph [ref=f8e182]: PROJECT - - generic [ref=f8e183]: - - heading "KeyCloak Patterns" [level=2] [ref=f8e184] - - paragraph [ref=f8e185]: 목표 미지정 - - generic [ref=f8e186]: - - generic [ref=f8e187]: - - term [ref=f8e188]: 단계 - - definition [ref=f8e189]: RESEARCH - - generic [ref=f8e190]: - - term [ref=f8e191]: 공개 - - definition [ref=f8e192]: PRIVATE - - button "삭제" [ref=f8e193] - - article [ref=f8e194]: - - paragraph [ref=f8e195]: PROJECT - - generic [ref=f8e196]: - - heading "Backend Clean Architecture" [level=2] [ref=f8e197] - - paragraph [ref=f8e198]: 목표 미지정 - - generic [ref=f8e199]: - - generic [ref=f8e200]: - - term [ref=f8e201]: 단계 - - definition [ref=f8e202]: RESEARCH - - generic [ref=f8e203]: - - term [ref=f8e204]: 공개 - - definition [ref=f8e205]: PRIVATE - - button "삭제" [ref=f8e206] - - paragraph [ref=f8e153]: 주제 삭제 시험 주제 을(를) 삭제했습니다. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-08-21T10-10-58-198Z.yml b/.playwright-mcp/page-2026-08-21T10-10-58-198Z.yml deleted file mode 100644 index e69de29..0000000 diff --git a/.playwright-mcp/page-2026-08-21T10-11-03-556Z.yml b/.playwright-mcp/page-2026-08-21T10-11-03-556Z.yml deleted file mode 100644 index 4e7a9ad..0000000 --- a/.playwright-mcp/page-2026-08-21T10-11-03-556Z.yml +++ /dev/null @@ -1,100 +0,0 @@ -- generic [ref=f9e3]: - - link "본문으로 건너뛰기" [ref=f9e4] [cursor=pointer]: - - /url: "#main-content" - - banner [ref=f9e5]: - - generic [ref=f9e6]: - - link "TechLog Studio" [ref=f9e7] [cursor=pointer]: - - /url: /studio - - text: TechLog - - generic [ref=f9e8]: Studio - - navigation "Studio 주 탐색" [ref=f9e10]: - - link "작업본" [ref=f9e11] [cursor=pointer]: - - /url: /studio/documents - - link "게시 기록" [ref=f9e12] [cursor=pointer]: - - /url: /studio/publications - - link "새 문서" [ref=f9e13] [cursor=pointer]: - - /url: /studio/documents/new - - link "주제·프로젝트" [ref=f9e14] [cursor=pointer]: - - /url: /studio/taxonomy - - link "릴리즈" [ref=f9e15] [cursor=pointer]: - - /url: /studio/releases - - link "공개 사이트 보기" [ref=f9e16] [cursor=pointer]: - - /url: / - - button "로그아웃" [ref=f9e17] - - main [ref=f9e18]: - - generic [ref=f9e19]: - - generic [ref=f9e20]: - - generic [ref=f9e21]: - - paragraph [ref=f9e22]: WORKING COPIES - - heading "작업본" [level=1] [ref=f9e23] - - paragraph [ref=f9e24]: 세션에 있는 Case, Reference, Question, Decision을 찾고 다음 작업으로 이동합니다. - - link "새 문서" [ref=f9e25] [cursor=pointer]: - - /url: /studio/documents/new - - region "작업본 검색과 필터" [ref=f9e26]: - - search [ref=f9e27]: - - generic [ref=f9e28]: 검색 - - generic [ref=f9e29]: - - searchbox "검색" [ref=f9e30] - - button "검색" [ref=f9e31] - - generic [ref=f9e32]: - - text: 종류 - - combobox "종류" [ref=f9e33]: - - option "전체" [selected] - - option "Case" - - option "Reference" - - option "Question" - - option "Decision" - - generic [ref=f9e34]: - - text: 상태 - - combobox "상태" [ref=f9e35]: - - option "전체" [selected] - - option "게시 전" - - option "게시 중" - - option "게시 취소" - - paragraph [ref=f9e36]: - - generic [ref=f9e37]: 2개 표시 중 - - alert [ref=f9e38]: 삭제하지 못했습니다. 게시 중이거나, 이 기록을 참조하는 곳이 있거나, 다른 곳에서 먼저 수정되었을 수 있습니다. - - generic [ref=f9e39]: - - article [ref=f9e40]: - - paragraph [ref=f9e41]: Case - - generic [ref=f9e42]: - - heading [level=2] [ref=f9e43]: - - link "게시 흐름 확인" [ref=f9e44] [cursor=pointer]: - - /url: /studio/documents/b09f168b-b205-478f-b60f-83872d1b4d01/edit - - paragraph [ref=f9e45]: 프로젝트 미지정 - - generic [ref=f9e46]: - - generic [ref=f9e47]: - - term [ref=f9e48]: 상태 - - definition [ref=f9e49]: 게시 취소 - - generic [ref=f9e50]: - - term [ref=f9e51]: 다음 - - definition [ref=f9e52]: - - link "검증하기" [ref=f9e53] [cursor=pointer]: - - /url: /studio/documents/b09f168b-b205-478f-b60f-83872d1b4d01/validation - - generic [ref=f9e54]: - - term [ref=f9e55]: 수정 - - definition [ref=f9e56]: - - time [ref=f9e57]: 2026. 8. 21. - - button "삭제" [ref=f9e58] - - article [ref=f9e59]: - - paragraph [ref=f9e60]: Case - - generic [ref=f9e61]: - - heading [level=2] [ref=f9e62]: - - link "브라우저에서 토큰을 어디까지 다뤄야 하는가..?" [ref=f9e63] [cursor=pointer]: - - /url: /studio/documents/f363edc8-2c13-4995-acda-934237034a85/edit - - paragraph [ref=f9e64]: 프로젝트 미지정 - - generic [ref=f9e65]: - - generic [ref=f9e66]: - - term [ref=f9e67]: 상태 - - definition [ref=f9e68]: 게시 전 - - generic [ref=f9e69]: - - term [ref=f9e70]: 다음 - - definition [ref=f9e71]: - - link "검증하기" [ref=f9e72] [cursor=pointer]: - - /url: /studio/documents/f363edc8-2c13-4995-acda-934237034a85/validation - - generic [ref=f9e73]: - - term [ref=f9e74]: 수정 - - definition [ref=f9e75]: - - time [ref=f9e76]: 2026. 8. 21. - - button "삭제" [ref=f9e77] - - paragraph [ref=f9e78] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-08-21T10-18-11-148Z.yml b/.playwright-mcp/page-2026-08-21T10-18-11-148Z.yml deleted file mode 100644 index e69de29..0000000 diff --git a/.playwright-mcp/page-2026-08-21T10-18-21-158Z.yml b/.playwright-mcp/page-2026-08-21T10-18-21-158Z.yml deleted file mode 100644 index e932986..0000000 --- a/.playwright-mcp/page-2026-08-21T10-18-21-158Z.yml +++ /dev/null @@ -1,50 +0,0 @@ -- generic [ref=f10e19]: - - link "본문으로 건너뛰기" [ref=f10e20] [cursor=pointer]: - - /url: "#main-content" - - banner [ref=f10e21]: - - generic [ref=f10e22]: - - link "TechLog Studio" [ref=f10e23] [cursor=pointer]: - - /url: /studio - - text: TechLog - - generic [ref=f10e24]: Studio - - navigation "Studio 주 탐색" [ref=f10e26]: - - link "작업본" [ref=f10e27] [cursor=pointer]: - - /url: /studio/documents - - link "게시 기록" [ref=f10e28] [cursor=pointer]: - - /url: /studio/publications - - link "새 문서" [ref=f10e29] [cursor=pointer]: - - /url: /studio/documents/new - - link "주제·프로젝트" [ref=f10e30] [cursor=pointer]: - - /url: /studio/taxonomy - - link "릴리즈" [ref=f10e31] [cursor=pointer]: - - /url: /studio/releases - - link "공개 사이트 보기" [ref=f10e32] [cursor=pointer]: - - /url: / - - button "로그아웃" [ref=f10e33] - - main [ref=f10e34]: - - generic [ref=f10e1]: - - generic [ref=f10e2]: - - paragraph [ref=f10e3]: ASSET LIBRARY - - heading "Asset" [level=1] [ref=f10e4] - - paragraph [ref=f10e5]: 업로드한 Asset을 검색하고 사용처를 확인하며, 사용하지 않는 Asset을 정리합니다. - - region "Asset 검색 도구" [ref=f10e6]: - - search [ref=f10e7]: - - generic [ref=f10e8]: Asset 검색 - - generic [ref=f10e9]: - - searchbox "Asset 검색" [ref=f10e10] - - button "검색" [ref=f10e11] - - status - - status [ref=f10e12]: 1개의 Asset - - list [ref=f10e13]: - - listitem [ref=f10e14]: - - button "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [ref=f10e15] - - text: READY - - generic [ref=f10e16]: 사용 0건 - - generic "screenshot-from-2026-08-21-18-04-49-72f1f9c6 상세" [ref=f10e35]: - - heading "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [active] [level=2] [ref=f10e36] - - paragraph [ref=f10e37]: READY - - paragraph [ref=f10e38]: 사용 중인 문서가 없습니다. - - generic [ref=f10e39]: - - button "삭제" [ref=f10e40] - - button "닫기" [ref=f10e41] - - paragraph [ref=f10e42] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-08-21T10-18-37-556Z.yml b/.playwright-mcp/page-2026-08-21T10-18-37-556Z.yml deleted file mode 100644 index e69de29..0000000 diff --git a/.playwright-mcp/page-2026-08-21T10-19-02-154Z.yml b/.playwright-mcp/page-2026-08-21T10-19-02-154Z.yml deleted file mode 100644 index e69de29..0000000 diff --git a/deploy/keycloak/tech-log-realm.json b/deploy/keycloak/tech-log-realm.json index 8dfd229..5e86156 100644 --- a/deploy/keycloak/tech-log-realm.json +++ b/deploy/keycloak/tech-log-realm.json @@ -5,8 +5,8 @@ "registrationAllowed": false, "loginTheme": "keycloak", "accessTokenLifespan": 300, - "ssoSessionIdleTimeout": 1800, - "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeout": 28800, + "ssoSessionMaxLifespan": 86400, "roles": { "realm": [ { "name": "studio-author", "description": "Tech Log Studio 편집 권한 (studio:read + studio:write)" } diff --git a/public/release-manifest.json b/public/release-manifest.json index 9ca56a2..de78aec 100644 --- a/public/release-manifest.json +++ b/public/release-manifest.json @@ -38,12 +38,12 @@ }, "contractSet": { "setAlgorithm": "CA_CONTRACT_SET_V1", - "setDigest": "sha256:7ee35548d2a84b744f8c17c7b785a79b328dcf756f5c9df8d77d37ff1535b087", + "setDigest": "sha256:7a4e5ace1def727943ee39ab40904b8932db7d224f9a3fc0cc8e4c350819262b", "packages": [ { "packageId": "@tech-log/management-contract", "version": "1.0.0", - "digest": "sha256:d19ae7c4fbcac924a356bbb0cc1a46a4046ecec701158ca0a9b7cc089bbaf878", + "digest": "sha256:446b14486291ab89b5b42d86aea1c1c7007ac40583aa98fe50d6e741d61aa1b3", "runtimeProtocolVersion": 1, "sourceRevision": "b195b29" }, @@ -56,8 +56,8 @@ }, { "packageId": "@tech-log/studio-contract", - "version": "3.0.0", - "digest": "sha256:674327a82951fd4a1bc2594c858072dfcd0b9abe7c63198283da5d8c92a04326", + "version": "3.1.0", + "digest": "sha256:6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f", "runtimeProtocolVersion": 1, "sourceRevision": "b195b29" } diff --git a/src/features/tech-log/adapters/http/http-management-gateway.ts b/src/features/tech-log/adapters/http/http-management-gateway.ts index ab52c47..3581b4d 100644 --- a/src/features/tech-log/adapters/http/http-management-gateway.ts +++ b/src/features/tech-log/adapters/http/http-management-gateway.ts @@ -1,5 +1,7 @@ import type { CreateDraftResponse, + HomeFocusRequest, + HomeFocusResponse, ProjectEditResponse, ProjectIndexPage, ProjectUpdateRequest, @@ -85,6 +87,16 @@ export function createHttpManagementGateway( deleteRelease: async (id: string, expectedVersion: number) => { await run("deleteRelease", { id, expectedVersion }); }, + publishProject: ( + id: string, + expectedVersion: number, + visibility: "PUBLIC" | "UNLISTED" = "PUBLIC", + ) => run("publishProject", { id, expectedVersion, visibility }), + unpublishProject: (id: string, expectedVersion: number) => + run("unpublishProject", { id, expectedVersion }), + getHomeFocus: () => run("getHomeFocus", {}), + updateHomeFocus: (body: HomeFocusRequest) => + run("updateHomeFocus", body), publishRelease: (id: string, expectedVersion: number) => run("publishRelease", { id, expectedVersion }), archiveRelease: (id: string, expectedVersion: number) => diff --git a/src/features/tech-log/adapters/http/http-public-content-gateway.ts b/src/features/tech-log/adapters/http/http-public-content-gateway.ts index f0c1116..f8bf206 100644 --- a/src/features/tech-log/adapters/http/http-public-content-gateway.ts +++ b/src/features/tech-log/adapters/http/http-public-content-gateway.ts @@ -1,5 +1,6 @@ import type { HomeFocusItem, + LatestRecordEntry, ProjectActivity, ProjectDecision, Project, @@ -320,6 +321,33 @@ export function createHttpPublicContentGateway( ); } + /** + * 공개 투영이 고른 최근 기록. `entryType` 은 계약이 네 값만 허용하므로 그대로 믿고 쓴다 — + * 서버가 이미 걸러 보낸다. + */ + async function getLatestEntries(): Promise { + const home = await read>[] }>>( + "getPublicHome", + {}, + ); + if (home === NOT_FOUND) return []; + return (home.latestEntries ?? []).map((entry) => { + const topic = entry.primaryTopic as Readonly> | null | undefined; + const project = entry.primaryProject as Readonly> | null | undefined; + const path = String(entry.path ?? ""); + return Object.freeze({ + id: `${String(entry.entryType ?? "")}:${path}`, + entryType: String(entry.entryType ?? "CASE") as LatestRecordEntry["entryType"], + title: String(entry.title ?? ""), + summary: String(entry.summary ?? ""), + path, + publishedAt: String(entry.publishedAt ?? ""), + topic: topic ? String(topic.name ?? "") : "", + project: project ? String(project.name ?? "") : "", + }); + }); + } + async function getHomeFocusItems(): Promise { const home = await read> }>>( "getPublicHome", @@ -463,6 +491,7 @@ export function createHttpPublicContentGateway( getProjectDecisions, getProjectActivity, getHomeFocusItems, + getLatestEntries, searchPublicContent, }); } diff --git a/src/features/tech-log/adapters/static/public-query.ts b/src/features/tech-log/adapters/static/public-query.ts index 7893b82..6907c14 100644 --- a/src/features/tech-log/adapters/static/public-query.ts +++ b/src/features/tech-log/adapters/static/public-query.ts @@ -11,6 +11,7 @@ import { type HomeFocusItem, } from "./public-content.ts"; import type { + LatestRecordEntry, PublicContentQueries, PublicTopic, } from "../../application/ports/public-content-queries.ts"; @@ -119,6 +120,43 @@ export function listTopics(): PublicTopic[] { .map((entry) => Object.freeze(entry)); } +/** + * 픽스처판 "최근 기록". HTTP 어댑터가 서버에서 읽어 오는 것과 같은 의미를 픽스처에서 만든다 — + * 프로젝트 활동과 릴리스가 원천이다. + */ +export function getLatestEntries(): LatestRecordEntry[] { + const recordByPath = new Map(publicRecords.map((record) => [record.path, record])); + const activities = projects.flatMap((project) => + project.activity.map((activity) => { + const record = recordByPath.get(activity.recordPath ?? activity.path); + const published = activity.type === "PUBLICATION" && record; + return { + id: activity.id, + entryType: (published ? record.kind : "PROJECT_ACTIVITY") as LatestRecordEntry["entryType"], + title: published ? record.title : activity.title, + summary: activity.summary, + path: activity.path, + publishedAt: activity.dateTime, + topic: record?.topic ?? project.topics[0] ?? "", + project: project.title, + }; + }), + ); + const releaseEntries = releases.map((release) => ({ + id: `release-${release.version}`, + entryType: "RELEASE" as const, + title: release.title, + summary: release.summary, + path: release.path, + publishedAt: release.publishedAt, + topic: "TechLog", + project: "TechLog", + })); + return [...activities, ...releaseEntries].sort((left, right) => + right.publishedAt.localeCompare(left.publishedAt), + ); +} + export function getHomeFocusItems(): HomeFocusItem[] { const project = getProject("backend-skeleton"); const question = getRecord("QUESTION", "validate-edge-token-again"); @@ -264,6 +302,9 @@ export const publicContentQueries = Object.freeze({ async listTopics() { return listTopics(); }, + async getLatestEntries() { + return getLatestEntries(); + }, async getHomeFocusItems() { return getHomeFocusItems(); }, diff --git a/src/features/tech-log/application/ports/management-gateway.ts b/src/features/tech-log/application/ports/management-gateway.ts index b7dc883..a332b1a 100644 --- a/src/features/tech-log/application/ports/management-gateway.ts +++ b/src/features/tech-log/application/ports/management-gateway.ts @@ -1,5 +1,7 @@ import type { CreateDraftResponse, + HomeFocusRequest, + HomeFocusResponse, ProjectEditResponse, ProjectIndexPage, ProjectUpdateRequest, @@ -27,6 +29,21 @@ export type ManagementGateway = Readonly<{ createProject(title: string): Promise; updateProject(id: string, body: ProjectUpdateRequest): Promise; deleteProject(id: string, expectedVersion: number): Promise; + /** + * 프로젝트 게시. 프로젝트는 Studio 문서가 아니라 게시 파이프라인 밖에 있고, 그래서 문서를 + * 게시해도 그 문서가 속한 프로젝트는 비공개로 남는다 — 공개 화면(프로젝트 목록·프로필의 + * "현재 프로젝트"·홈의 focus)은 모두 게시된 프로젝트만 읽으므로, 이 호출 없이는 어디에도 + * 나타나지 않는다. + */ + publishProject( + id: string, + expectedVersion: number, + visibility?: "PUBLIC" | "UNLISTED", + ): Promise; + unpublishProject(id: string, expectedVersion: number): Promise; + /** 공개 홈이 무엇을 앞에 둘지. 세 슬롯이 모두 비면 홈은 그 영역을 아예 그리지 않는다. */ + getHomeFocus(): Promise; + updateHomeFocus(body: HomeFocusRequest): Promise; listReleases(page?: number, size?: number): Promise; getRelease(id: string): Promise; createRelease(title: string): Promise; diff --git a/src/features/tech-log/application/ports/public-content-queries.ts b/src/features/tech-log/application/ports/public-content-queries.ts index a78f04c..375f986 100644 --- a/src/features/tech-log/application/ports/public-content-queries.ts +++ b/src/features/tech-log/application/ports/public-content-queries.ts @@ -164,6 +164,24 @@ export type PublicTopic = { recordCount: number; }; +/** + * 홈의 "최근 기록" 한 줄. 서버가 공개 투영에서 직접 고른다. + * + * 화면이 프로젝트를 하나씩 돌며 조립하던 때에는, 게시된 문서라도 그 문서가 매달린 프로젝트가 + * 공개되어 있지 않으면 목록에서 통째로 빠졌다 — 실제로 게시한 Case 는 안 보이고 릴리스만 + * 남았다. 무엇이 최근인지는 공개 투영 하나가 알고 있으므로 거기서 그대로 읽는다. + */ +export type LatestRecordEntry = { + id: string; + entryType: "CASE" | "REFERENCE" | "PROJECT_ACTIVITY" | "RELEASE"; + title: string; + summary: string; + path: string; + publishedAt: string; + topic: string; + project: string; +}; + export type FocusKey = "current" | "question" | "decision"; export type HomeFocusItem = { @@ -227,6 +245,7 @@ export type PublicContentQueries = Readonly<{ * 있었고, Studio 에서 주제를 만들어도 바뀌지 않았다. */ listTopics(): Promise; + getLatestEntries(): Promise; getHomeFocusItems(): Promise; searchPublicContent(query: string): Promise; }>; diff --git a/src/features/tech-log/contracts/management/canonical-source.json b/src/features/tech-log/contracts/management/canonical-source.json index 3dbbdab..7af1e46 100644 --- a/src/features/tech-log/contracts/management/canonical-source.json +++ b/src/features/tech-log/contracts/management/canonical-source.json @@ -1,7 +1,7 @@ { "packageId": "@tech-log/management-contract", "version": "1.0.0", - "digest": "sha256:d19ae7c4fbcac924a356bbb0cc1a46a4046ecec701158ca0a9b7cc089bbaf878", + "digest": "sha256:446b14486291ab89b5b42d86aea1c1c7007ac40583aa98fe50d6e741d61aa1b3", "sourceRevision": "b195b29", "operationIds": [ "createCaseDraft", diff --git a/src/features/tech-log/contracts/management/contract.ts b/src/features/tech-log/contracts/management/contract.ts index afc604c..6b6041e 100644 --- a/src/features/tech-log/contracts/management/contract.ts +++ b/src/features/tech-log/contracts/management/contract.ts @@ -15,3 +15,5 @@ export type ReleaseIndexItem = Schemas["ReleaseIndexItem"]; export type ReleaseIndexPage = Schemas["ReleaseIndexPage"]; export type ReleaseUpdateRequest = Schemas["ReleaseUpdateRequest"]; export type PublishResponse = Schemas["PublishResponse"]; +export type HomeFocusRequest = Schemas["HomeFocusRequest"]; +export type HomeFocusResponse = Schemas["HomeFocusResponse"]; diff --git a/src/features/tech-log/contracts/management/generated.ts b/src/features/tech-log/contracts/management/generated.ts index f1e1acd..a0a1fc6 100644 --- a/src/features/tech-log/contracts/management/generated.ts +++ b/src/features/tech-log/contracts/management/generated.ts @@ -963,6 +963,12 @@ export interface components { data: components["schemas"]["ReleaseEditResponse"]; meta: components["schemas"]["ResponseMeta"]; }; + HomeFocusResponseEnvelope: { + /** @constant */ + success: true; + data: components["schemas"]["HomeFocusResponse"]; + meta: components["schemas"]["ResponseMeta"]; + }; PublishResponseEnvelope: { /** @constant */ success: true; @@ -5519,7 +5525,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PublishResponse"]; + "application/json": components["schemas"]["PublishResponseEnvelope"]; }; }; /** @description Bad Request */ @@ -5528,7 +5534,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unauthorized */ @@ -5537,7 +5543,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Forbidden */ @@ -5546,7 +5552,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Not Found */ @@ -5555,7 +5561,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Conflict */ @@ -5564,7 +5570,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unprocessable Content */ @@ -5573,7 +5579,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Internal Server Error */ @@ -5582,7 +5588,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; }; @@ -5610,7 +5616,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProjectEditResponse"]; + "application/json": components["schemas"]["ProjectEditResponseEnvelope"]; }; }; /** @description Bad Request */ @@ -5619,7 +5625,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unauthorized */ @@ -5628,7 +5634,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Forbidden */ @@ -5637,7 +5643,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Not Found */ @@ -5646,7 +5652,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Conflict */ @@ -5655,7 +5661,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unprocessable Content */ @@ -5664,7 +5670,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Internal Server Error */ @@ -5673,7 +5679,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; }; @@ -8117,7 +8123,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["HomeFocusResponse"]; + "application/json": components["schemas"]["HomeFocusResponseEnvelope"]; }; }; /** @description Bad Request */ @@ -8126,7 +8132,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unauthorized */ @@ -8135,7 +8141,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Forbidden */ @@ -8144,7 +8150,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Not Found */ @@ -8153,7 +8159,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Internal Server Error */ @@ -8162,7 +8168,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; }; @@ -8188,7 +8194,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["HomeFocusResponse"]; + "application/json": components["schemas"]["HomeFocusResponseEnvelope"]; }; }; /** @description Bad Request */ @@ -8197,7 +8203,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unauthorized */ @@ -8206,7 +8212,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Forbidden */ @@ -8215,7 +8221,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Not Found */ @@ -8224,7 +8230,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Conflict */ @@ -8233,7 +8239,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unprocessable Content */ @@ -8242,7 +8248,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Internal Server Error */ @@ -8251,7 +8257,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; }; diff --git a/src/features/tech-log/contracts/management/management-api.openapi.yaml b/src/features/tech-log/contracts/management/management-api.openapi.yaml index 0103e4e..637beac 100644 --- a/src/features/tech-log/contracts/management/management-api.openapi.yaml +++ b/src/features/tech-log/contracts/management/management-api.openapi.yaml @@ -3015,49 +3015,49 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/PublishResponse' + $ref: '#/components/schemas/PublishResponseEnvelope' '400': description: Bad Request content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: Unauthorized content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: Forbidden content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: Not Found content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '409': description: Conflict content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '422': description: Unprocessable Content content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: Internal Server Error content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' requestBody: required: true content: @@ -3085,49 +3085,49 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ProjectEditResponse' + $ref: '#/components/schemas/ProjectEditResponseEnvelope' '400': description: Bad Request content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: Unauthorized content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: Forbidden content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: Not Found content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '409': description: Conflict content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '422': description: Unprocessable Content content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: Internal Server Error content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' requestBody: required: true content: @@ -4985,37 +4985,37 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/HomeFocusResponse' + $ref: '#/components/schemas/HomeFocusResponseEnvelope' '400': description: Bad Request content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: Unauthorized content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: Forbidden content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: Not Found content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: Internal Server Error content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' security: - sessionCookie: [] put: @@ -5030,49 +5030,49 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/HomeFocusResponse' + $ref: '#/components/schemas/HomeFocusResponseEnvelope' '400': description: Bad Request content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: Unauthorized content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: Forbidden content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: Not Found content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '409': description: Conflict content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '422': description: Unprocessable Content content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: Internal Server Error content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' requestBody: required: true content: @@ -5510,6 +5510,21 @@ components: $ref: '#/components/schemas/ReleaseEditResponse' meta: $ref: '#/components/schemas/ResponseMeta' + HomeFocusResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/HomeFocusResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' PublishResponseEnvelope: type: object additionalProperties: false diff --git a/src/features/tech-log/contracts/studio/canonical-source.json b/src/features/tech-log/contracts/studio/canonical-source.json index ec09ffa..7ffbff1 100644 --- a/src/features/tech-log/contracts/studio/canonical-source.json +++ b/src/features/tech-log/contracts/studio/canonical-source.json @@ -1,7 +1,7 @@ { "packageId": "@tech-log/studio-contract", - "version": "3.0.0", - "digest": "sha256:674327a82951fd4a1bc2594c858072dfcd0b9abe7c63198283da5d8c92a04326", + "version": "3.1.0", + "digest": "sha256:6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f", "sourceRevision": "b195b29", "operationIds": [ "getStudioSession", diff --git a/src/features/tech-log/contracts/studio/generated.ts b/src/features/tech-log/contracts/studio/generated.ts index a1afac9..7f73fb6 100644 --- a/src/features/tech-log/contracts/studio/generated.ts +++ b/src/features/tech-log/contracts/studio/generated.ts @@ -1074,7 +1074,32 @@ export interface components { height: number | null; decorative: boolean; }; - CaseRenderBlock: components["schemas"]["HeadingBlock"] | components["schemas"]["ParagraphBlock"] | components["schemas"]["BlockquoteBlock"] | components["schemas"]["UnorderedListBlock"] | components["schemas"]["OrderedListBlock"] | components["schemas"]["CodeBlock"] | components["schemas"]["DataTableBlock"] | components["schemas"]["CalloutBlock"] | components["schemas"]["EvidenceFigureBlock"]; + /** @description `---` 로 쓴 구분선이다. 담을 내용이 없으므로 `type` 뿐이다. + * */ + ThematicBreakBlock: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "THEMATIC_BREAK"; + }; + /** @description `![alt](/media/...)` 로 쓴 그림이다. + * + * `EvidenceFigureBlock` 과 나누는 기준은 출처다. evidence 는 assetKey 로 가리켜 게시 + * 시점에 고정되고 확대 보기를 갖지만, 이쪽은 작성자가 적은 경로를 그대로 쓴다. 경로 규칙은 + * 링크와 같다 — 외부 스킴과 `javascript:` 는 거절한다. + * */ + ImageBlock: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "IMAGE"; + src: string; + alt: string; + title: string | null; + }; + CaseRenderBlock: components["schemas"]["HeadingBlock"] | components["schemas"]["ParagraphBlock"] | components["schemas"]["BlockquoteBlock"] | components["schemas"]["UnorderedListBlock"] | components["schemas"]["OrderedListBlock"] | components["schemas"]["CodeBlock"] | components["schemas"]["DataTableBlock"] | components["schemas"]["CalloutBlock"] | components["schemas"]["EvidenceFigureBlock"] | components["schemas"]["ThematicBreakBlock"] | components["schemas"]["ImageBlock"]; CasePublicRenderModel: components["schemas"]["PublicRenderModelBase"] & { /** @enum {string} */ kind: "CASE"; diff --git a/src/features/tech-log/contracts/studio/studio-api.openapi.yaml b/src/features/tech-log/contracts/studio/studio-api.openapi.yaml index 8a2edbd..ac68d97 100644 --- a/src/features/tech-log/contracts/studio/studio-api.openapi.yaml +++ b/src/features/tech-log/contracts/studio/studio-api.openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Tech Log Studio API - version: 3.0.0 + version: 3.1.0 description: | Tech Log Studio orchestration 계약이다. @@ -1321,7 +1321,10 @@ components: properties: type: { type: string, enum: [HEADING] } id: { type: string, minLength: 1, maxLength: 200 } - level: { type: integer, minimum: 2, maximum: 4 } + # 작성자가 쓴 그대로 담는다. 서버 렌더러는 이 값을 2..4 로 좁혀 문서 안 제목 위계를 + # 지키므로(BlockRenderer), 계약이 1..6 을 거절할 이유가 없다 — 거절하면 `#` 로 시작한 + # 평범한 Markdown 이 통째로 렌더링되지 않는다. + level: { type: integer, minimum: 1, maximum: 6 } content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } } ParagraphBlock: type: object @@ -1452,6 +1455,29 @@ components: width: { type: [integer, "null"], minimum: 1 } height: { type: [integer, "null"], minimum: 1 } decorative: { type: boolean } + ThematicBreakBlock: + type: object + additionalProperties: false + description: | + `---` 로 쓴 구분선이다. 담을 내용이 없으므로 `type` 뿐이다. + required: [type] + properties: + type: { type: string, enum: [THEMATIC_BREAK] } + ImageBlock: + type: object + additionalProperties: false + description: | + `![alt](/media/...)` 로 쓴 그림이다. + + `EvidenceFigureBlock` 과 나누는 기준은 출처다. evidence 는 assetKey 로 가리켜 게시 + 시점에 고정되고 확대 보기를 갖지만, 이쪽은 작성자가 적은 경로를 그대로 쓴다. 경로 규칙은 + 링크와 같다 — 외부 스킴과 `javascript:` 는 거절한다. + required: [type, src, alt, title] + properties: + type: { type: string, enum: [IMAGE] } + src: { type: string, minLength: 1, maxLength: 500 } + alt: { type: string, maxLength: 300 } + title: { type: [string, "null"], maxLength: 300 } CaseRenderBlock: oneOf: - { $ref: "#/components/schemas/HeadingBlock" } @@ -1463,6 +1489,8 @@ components: - { $ref: "#/components/schemas/DataTableBlock" } - { $ref: "#/components/schemas/CalloutBlock" } - { $ref: "#/components/schemas/EvidenceFigureBlock" } + - { $ref: "#/components/schemas/ThematicBreakBlock" } + - { $ref: "#/components/schemas/ImageBlock" } discriminator: propertyName: type mapping: @@ -1475,6 +1503,8 @@ components: DATA_TABLE: "#/components/schemas/DataTableBlock" CALLOUT: "#/components/schemas/CalloutBlock" EVIDENCE_FIGURE: "#/components/schemas/EvidenceFigureBlock" + THEMATIC_BREAK: "#/components/schemas/ThematicBreakBlock" + IMAGE: "#/components/schemas/ImageBlock" CasePublicRenderModel: unevaluatedProperties: false allOf: diff --git a/src/features/tech-log/contracts/tech-log-management-contract-contribution.ts b/src/features/tech-log/contracts/tech-log-management-contract-contribution.ts index 6b10ba7..37765d7 100644 --- a/src/features/tech-log/contracts/tech-log-management-contract-contribution.ts +++ b/src/features/tech-log/contracts/tech-log-management-contract-contribution.ts @@ -310,6 +310,47 @@ const HTTP_CONTRACTS = Object.freeze([ }); }, ), + writeOperation( + "publishProject", + "POST", + `${P}/{id}/publish`, + { acceptedStatuses: [200], requestByteLimit: 1_024, responseByteLimit: 8_192 }, + (input: never) => { + const value = input as unknown as Readonly<{ + id: string; + expectedVersion: number; + visibility: "PUBLIC" | "UNLISTED"; + }>; + return Object.freeze({ + pathValues: Object.freeze({ id: value.id }), + queryEntries: NO_QUERY, + body: { expectedVersion: value.expectedVersion, visibility: value.visibility }, + }); + }, + ), + writeOperation( + "unpublishProject", + "POST", + `${P}/{id}/unpublish`, + { acceptedStatuses: [200], requestByteLimit: 1_024, responseByteLimit: 262_144 }, + (input: never) => { + const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>; + return Object.freeze({ + pathValues: Object.freeze({ id: value.id }), + queryEntries: NO_QUERY, + body: { expectedVersion: value.expectedVersion }, + }); + }, + ), + readOperation("getHomeFocus", `${D}/home-focus`, 8_192), + writeOperation( + "updateHomeFocus", + "PUT", + `${D}/home-focus`, + { acceptedStatuses: [200], requestByteLimit: 4_096, responseByteLimit: 8_192 }, + (input: never) => + Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY, body: input }), + ), writeOperation( "publishRelease", "POST", diff --git a/src/features/tech-log/domain/content-format/parse-case-content.ts b/src/features/tech-log/domain/content-format/parse-case-content.ts index 1c8b8b6..e331ec6 100644 --- a/src/features/tech-log/domain/content-format/parse-case-content.ts +++ b/src/features/tech-log/domain/content-format/parse-case-content.ts @@ -180,14 +180,27 @@ function normalizeDirectives(source: string): string { return line; } + /* + `:::name key="value"` 를 remark-directive 가 읽는 `:::name{key="value"}` 로 바꾼다. + + 이름과 나머지 사이의 경계를 `\s` 로 못 박는 것이 중요하다. 예전에는 이름을 + `[a-z0-9-]*` 로 두고 나머지가 `{` 로 시작하지 않기만 요구했는데, 정규식이 되돌아가며 + 이름의 마지막 글자를 나머지 쪽으로 넘겨 그 조건을 피해 갔다 — `:::note` 는 이름 `not` + 에 본문 `e` 가 되어 `:::not{e}` 로, 이미 중괄호를 쓴 `:::table{id="t"}` 는 + `:::tabl{e{id="t"}}` 로 망가졌다. 그래서 속성 없는 디렉티브는 이름이 통째로 바뀌고, + 중괄호 형태는 아예 해석되지 않았다. + */ return line.replace( - /^(:::[a-z][a-z0-9-]*)([^\n{][^\n]*)(\n?)$/i, + /^(:::[a-z][a-z0-9-]*)(\s+[^\n]*?)(\n?)$/i, ( _match, marker: string, attributes: string, newline: string, - ) => `${marker}{${attributes.trim()}}${newline}`, + ) => { + const trimmed = attributes.trim(); + return trimmed ? `${marker}{${trimmed}}${newline}` : `${marker}${newline}`; + }, ); }) .join(""); @@ -199,6 +212,14 @@ function assertNever(value: never): never { const trustedRelativeLinkOrigin = "https://techlog.invalid"; +/** 서버가 아는 callout 이름과 화면에 붙일 말. 이름이 tone 을 겸하므로 속성을 받지 않는다. */ +const CALLOUT_LABELS: Readonly> = { + note: "참고", + tip: "도움말", + warning: "주의", + danger: "위험", +}; + function hasAsciiControlCharacter(value: string): boolean { return Array.from(value).some((character) => { const codePoint = character.codePointAt(0) ?? 0; @@ -326,8 +347,8 @@ function headingBlock( node: Heading, usedIds: Set, ): components["schemas"]["HeadingBlock"] { - if (node.depth < 2 || node.depth > 4) { - invalid(node, "only heading levels 2 through 4 are supported"); + if (node.depth < 1 || node.depth > 6) { + invalid(node, "only heading levels 1 through 6 are supported"); } const children = [...node.children]; @@ -417,15 +438,34 @@ function tableCellContent(cell: TableCell): Inline[] { return inlineFromNodes(cell.children); } +/** + * `:::table` 없이 쓴 GFM 표에 붙일 값. + * + *

서버 렌더러는 파이프 표를 그대로 읽는다 — `:::table` 이라는 directive 자체를 모른다. + * 여기서만 감싸기를 요구하면 같은 본문이 Studio 와 공개 화면에서 다르게 읽히므로, 감싸지 않은 + * 표도 받는다. `id` 는 자리 순서로 만들고 `caption` 은 비운다. 설명이 필요하면 `:::table` 로 + * 감싸 `caption` 을 주면 된다. + */ +function bareTableAttributes(index: number): Record { + return { id: `table-${index}`, caption: "", rowHeaderColumn: "none" }; +} + function tableBlock( - node: ContainerDirective, + node: ContainerDirective | Table, usedIds: Set, + bareIndex?: number, ): components["schemas"]["DataTableBlock"] { - const attributes = attributesOf(node, ["id", "caption", "rowHeaderColumn"]); - if (node.children.length !== 1 || node.children[0].type !== "table") { - invalid(node, "table directive must contain exactly one GFM table"); + const bare = node.type === "table"; + const attributes = bare + ? bareTableAttributes(bareIndex!) + : attributesOf(node as ContainerDirective, ["id", "caption", "rowHeaderColumn"]); + if (!bare) { + const container = node as ContainerDirective; + if (container.children.length !== 1 || container.children[0].type !== "table") { + invalid(node, "table directive must contain exactly one GFM table"); + } } - const table = node.children[0] as Table; + const table = (bare ? node : (node as ContainerDirective).children[0]) as Table; if (table.children.length === 0) invalid(table, "table header is required"); const id = attributes.id; @@ -506,6 +546,25 @@ function directiveBlock( content: inlineFromNodes((node.children[0] as Paragraph).children), }; } + /* + 서버 렌더러가 아는 callout 이름이다(`note`/`tip` 은 정보, `warning`/`danger` 는 경고). + `:::callout tone="..."` 만 받으면 서버가 정상으로 읽는 본문을 여기서 거절하게 되므로 둘 다 + 받는다. 이름이 곧 tone 이라 속성이 없고, 라벨은 이름에서 만든다. + */ + case "note": + case "tip": + case "warning": + case "danger": { + if (node.children.length !== 1 || node.children[0].type !== "paragraph") { + invalid(node, `${node.name} directive must contain exactly one paragraph`); + } + return { + type: "CALLOUT", + tone: node.name === "note" || node.name === "tip" ? "info" : "warning", + label: CALLOUT_LABELS[node.name], + content: inlineFromNodes((node.children[0] as Paragraph).children), + }; + } case "evidence": { const attributes = attributesOf(node, ["key", "alt", "caption", "zoom"]); if (node.children.length !== 0) { @@ -533,10 +592,34 @@ function directiveBlock( } } +/** + * 문단 하나에 그림만 있으면 그림 블록으로 읽는다. + * + *

Markdown 에서 `![alt](/media/...)` 는 문단 안의 inline 이다. 인라인 유니온에는 그림이 없고 + * 앞으로도 둘 이유가 없다 — 글 가운데 끼워 넣는 그림은 이 문서 형식이 다루는 대상이 아니다. + * 그래서 "문단이 그림 하나로만 이루어진 경우"만 블록으로 올린다. + */ +function imageBlockOf(node: Paragraph): components["schemas"]["ImageBlock"] | null { + const visible = node.children.filter( + (child) => !(child.type === "text" && child.value.trim() === ""), + ); + if (visible.length !== 1) return null; + const only = visible[0]!; + if (only.type !== "image") return null; + const image = only as unknown as { url: string; alt?: string | null; title?: string | null }; + if (!isSafeLink(image.url)) invalid(node, `unsafe image URL: ${image.url}`); + return { + type: "IMAGE", + src: image.url, + alt: image.alt ?? "", + title: image.title ?? null, + }; +} + function paragraphBlock( node: Paragraph, -): components["schemas"]["ParagraphBlock"] { - return { type: "PARAGRAPH", content: inlineFromNodes(node.children) }; +): components["schemas"]["ParagraphBlock"] | components["schemas"]["ImageBlock"] { + return imageBlockOf(node) ?? { type: "PARAGRAPH", content: inlineFromNodes(node.children) }; } export function parseCaseContent(source: string): CaseAuthoringBlock[] { @@ -564,6 +647,7 @@ export function parseCaseContent(source: string): CaseAuthoringBlock[] { listItemCount += 1; return `list-item-${listItemCount}`; }; + let bareTableCount = 0; return tree.children.map((node: Content): CaseAuthoringBlock => { switch (node.type) { @@ -585,11 +669,17 @@ export function parseCaseContent(source: string): CaseAuthoringBlock[] { return codeBlock(node); case "containerDirective": return directiveBlock(node, usedIds); - case "html": + case "table": { + // 서버 렌더러는 파이프 표를 그대로 읽는다. 여기서 거절하면 같은 본문이 Studio 와 + // 공개 화면에서 다르게 읽힌다. + bareTableCount += 1; + return tableBlock(node, usedIds, bareTableCount); + } case "thematicBreak": + return { type: "THEMATIC_BREAK" }; + case "html": case "definition": case "yaml": - case "table": case "footnoteDefinition": case "leafDirective": return invalid(node, `unsupported block syntax: ${node.type}`); diff --git a/src/features/tech-log/domain/content-format/serialize-case-content.ts b/src/features/tech-log/domain/content-format/serialize-case-content.ts index f072ecf..444f292 100644 --- a/src/features/tech-log/domain/content-format/serialize-case-content.ts +++ b/src/features/tech-log/domain/content-format/serialize-case-content.ts @@ -188,6 +188,14 @@ function serializeBlock(block: CaseRenderBlock): string { `:::evidence key=${quoteAttribute(block.key)} alt=${quoteAttribute(block.alt)} caption=${quoteAttribute(block.caption)} zoom=${quoteAttribute(String(block.zoom))}`, ":::", ].join("\n"); + case "THEMATIC_BREAK": + return "---"; + case "IMAGE": + // 제목은 Markdown 이 따옴표로 감싼다. 없으면 붙이지 않아야 다시 읽었을 때 빈 제목이 되지 + // 않는다. + return block.title === null + ? `![${escapeText(block.alt)}](${block.src})` + : `![${escapeText(block.alt)}](${block.src} "${block.title.replaceAll('"', '\\"')}")`; default: return assertNever(block); } diff --git a/src/features/tech-log/presentation/public/pages/home-page.tsx b/src/features/tech-log/presentation/public/pages/home-page.tsx index 1c7194b..1569dd7 100644 --- a/src/features/tech-log/presentation/public/pages/home-page.tsx +++ b/src/features/tech-log/presentation/public/pages/home-page.tsx @@ -39,56 +39,37 @@ function optionalString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } +/** + * 서버가 고른 최근 기록에 릴리스를 얹는다. + * + * 예전에는 이 함수가 공개된 프로젝트를 하나씩 돌며 활동을 모아 타임라인을 만들었다. 그래서 게시된 + * 문서라도 그 문서가 매달린 프로젝트가 공개되어 있지 않으면 홈에서 통째로 사라졌다 — 실제로 Case 를 + * 게시했는데 홈에는 릴리스 한 줄만 남았다. 무엇이 최근인지는 공개 투영이 이미 알고 있으므로 그것을 + * 그대로 읽는다. 프로젝트마다 요청을 하나씩 보내던 N+1 도 같이 사라진다. + * + * 릴리스는 Publication 파이프라인을 거치지 않아 그 투영에 행이 없다. 그래서 릴리스만 따로 읽어 + * 시간순으로 합친다. + */ async function getLatestEntries( publicContent: PublicContentQueries, ): Promise { - const publicRecords = await publicContent.listRecords(); - const publicRecordByPath = new Map( - publicRecords.map((record) => [record.path, record]), - ); - const searchableEntities = await publicContent.searchPublicContent(""); - const projectPrefix = "/projects/"; - const projectSlugs = searchableEntities - .filter((entity) => entity.contentType === "PROJECT") - .flatMap((entity) => - entity.path.startsWith(projectPrefix) - ? [decodeURIComponent(entity.path.slice(projectPrefix.length))] - : [], - ); - // One project at a time would serialise a request per project; issuing them - // together keeps the timeline's cost at its slowest project rather than their - // sum. The flatten below restores the original single-list shape. - const projectTimeline = ( - await Promise.all( - projectSlugs.map(async (projectSlug) => { - const project = await publicContent.getProject(projectSlug); - if (!project) return []; - const activities = await publicContent.getProjectActivity(projectSlug); - return activities.map((activity) => { - const record = publicRecordByPath.get( - activity.recordPath ?? activity.path, - ); - return { - id: activity.id, - typeLabel: - activity.type === "PUBLICATION" && record - ? record.kind - : "PROJECT ACTIVITY", - title: - activity.type === "PUBLICATION" && record - ? record.title - : activity.title, - summary: activity.summary, - date: activity.date, - dateTime: activity.dateTime, - topic: record?.topic ?? project.topics[0] ?? "", - project: project.title, - path: activity.path, - }; - }); - }), - ) - ).flat(); + const [records, searchableEntities] = await Promise.all([ + publicContent.getLatestEntries(), + publicContent.searchPublicContent(""), + ]); + + const recordTimeline: LatestEntry[] = records.map((entry) => ({ + id: entry.id, + typeLabel: entry.entryType === "PROJECT_ACTIVITY" ? "PROJECT ACTIVITY" : entry.entryType, + title: entry.title, + summary: entry.summary, + date: dateLabel(entry.publishedAt), + dateTime: entry.publishedAt, + topic: entry.topic, + project: entry.project, + path: entry.path, + })); + const releaseTimeline = ( await Promise.all( searchableEntities @@ -102,14 +83,14 @@ async function getLatestEntries( if (!release) return []; return [ { - id: `release-${release.version}`, - typeLabel: "RELEASE", - title: release.title, - summary: release.summary, - date: release.publishedLabel, - dateTime: release.publishedAt, - topic: "TechLog", - project: "TechLog", + id: `release-${release.version}`, + typeLabel: "RELEASE", + title: release.title, + summary: release.summary, + date: release.publishedLabel, + dateTime: release.publishedAt, + topic: "TechLog", + project: "TechLog", path: release.path, }, ]; @@ -117,11 +98,20 @@ async function getLatestEntries( ) ).flat(); - return [...projectTimeline, ...releaseTimeline].sort((left, right) => + return [...recordTimeline, ...releaseTimeline].sort((left, right) => right.dateTime.localeCompare(left.dateTime), ); } +/** 목록의 날짜 칸은 공개 화면 어디서나 같은 형식이다. */ +function dateLabel(isoTimestamp: string): string { + const parsed = new Date(isoTimestamp); + if (Number.isNaN(parsed.getTime())) return ""; + return `${parsed.getFullYear()}.${String(parsed.getMonth() + 1).padStart(2, "0")}.${String( + parsed.getDate(), + ).padStart(2, "0")}`; +} + export function HomePage() { const { search } = useRouteInput<"TECH_LOG_HOME">(); const requestedKey = optionalString(search.focus); diff --git a/src/features/tech-log/presentation/shared/public-render/case-body-renderer.tsx b/src/features/tech-log/presentation/shared/public-render/case-body-renderer.tsx index 59dc451..8e85d90 100644 --- a/src/features/tech-log/presentation/shared/public-render/case-body-renderer.tsx +++ b/src/features/tech-log/presentation/shared/public-render/case-body-renderer.tsx @@ -99,6 +99,19 @@ function renderBlock( resolveEvidenceAsset={resolveEvidenceAsset} /> ); + case "THEMATIC_BREAK": + return


; + case "IMAGE": + /* + 작성자가 적은 경로를 그대로 쓴다. 경로 검증은 파싱할 때 끝났다(`isSafeLink`). + `title` 이 있으면 그림 설명으로 보여 준다 — Markdown 이 제목을 그런 뜻으로 쓴다. + */ + return ( +
+ {block.alt} + {block.title ?
{block.title}
: null} +
+ ); default: return assertNever(block); } diff --git a/src/features/tech-log/presentation/studio/components/home-focus-editor.tsx b/src/features/tech-log/presentation/studio/components/home-focus-editor.tsx new file mode 100644 index 0000000..89ad5f8 --- /dev/null +++ b/src/features/tech-log/presentation/studio/components/home-focus-editor.tsx @@ -0,0 +1,194 @@ +import { useCallback, useEffect, useState, type FormEvent } from "react"; + +import type { CatalogEntry } from "../../../contracts/studio/contract.ts"; +import type { HomeFocusResponse } from "../../../contracts/management/contract.ts"; +import type { ProjectIndexItem } from "../../../contracts/management/contract.ts"; +import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts"; +import { useStudio } from "../use-studio.ts"; + +/** + * 공개 홈의 "지금 집중하는 것" 을 정하는 화면. + * + *

그 영역은 세 칸(현재 작업·열린 질문·최근 결정)을 가지며, 셋이 모두 비면 홈은 영역 자체를 + * 그리지 않는다. `home_focus_config` 는 마이그레이션이 빈 행 하나만 넣어 두었고 그 값을 채울 + * 화면이 없었으므로, 홈에서는 그 영역이 한 번도 나타난 적이 없었다. + * + *

고를 수 있는 것은 실제로 존재하는 기록뿐이다. 질문과 결정은 Studio catalog 의 + * `RELATION` 목록에서 가져온다 — 그 목록이 곧 "연결 가능한 대상" 의 정의이고, 여기서 다른 + * 기준을 쓰면 두 화면이 서로 다른 것을 보여 준다. + * + *

비공개 프로젝트도 고를 수 있게 둔다. 미리 지목해 두고 게시와 동시에 홈에 뜨게 하는 것이 + * 정상적인 순서이기 때문이다. 다만 게시되지 않은 동안에는 홈이 그 칸을 그리지 않으므로, 목록에 + * 그 사실을 적어 둔다. + */ +export function HomeFocusEditor() { + const { gateway, managementGateway, setRequestAnnouncement } = useStudio(); + const [focus, setFocus] = useState(null); + const [projects, setProjects] = useState([]); + const [relations, setRelations] = useState([]); + const [projectId, setProjectId] = useState(""); + const [questionId, setQuestionId] = useState(""); + const [decisionId, setDecisionId] = useState(""); + const [pending, setPending] = useState(false); + const [error, setError] = useState(""); + const [generation, setGeneration] = useState(0); + + const reload = useCallback(() => setGeneration((value) => value + 1), []); + + useEffect(() => { + let cancelled = false; + void Promise.all([ + managementGateway.getHomeFocus(), + managementGateway.listProjects(0, 50), + gateway.getCatalog({ type: "RELATION", limit: 100 }), + ]).then( + ([current, projectPage, catalog]) => { + if (cancelled) return; + setFocus(current); + setProjects(projectPage.items ?? []); + setRelations(catalog.items ?? []); + setProjectId(current.currentProjectId ?? ""); + setQuestionId(current.openQuestionId ?? ""); + setDecisionId(current.recentDecisionId ?? ""); + setError(""); + }, + () => { + if (!cancelled) setError("홈 설정을 불러오지 못했습니다."); + }, + ); + return () => { + cancelled = true; + }; + }, [gateway, managementGateway, generation]); + + const questions = relations.filter((entry) => entry.kind === "QUESTION"); + const decisions = relations.filter((entry) => entry.kind === "PROJECT_DECISION"); + + const save = async (event: FormEvent) => { + event.preventDefault(); + if (pending || !focus) return; + setPending(true); + setError(""); + try { + const saved = await managementGateway.updateHomeFocus({ + expectedVersion: focus.version, + // 빈 문자열은 "고르지 않음" 이다. 계약은 uuid 만 받으므로 보내지 않는다. + currentProjectId: projectId || undefined, + openQuestionId: questionId || undefined, + recentDecisionId: decisionId || undefined, + }); + setFocus(saved); + setRequestAnnouncement("홈에 표시할 항목을 저장했습니다."); + reload(); + } catch (failure) { + setError(managementFailureMessage(failure, "홈 설정을 저장하지 못했습니다.")); + } finally { + setPending(false); + } + }; + + const chosenProject = projects.find((project) => project.id === projectId); + const projectHidden = chosenProject && chosenProject.targetVisibility === "PRIVATE"; + const nothingChosen = !projectId && !questionId && !decisionId; + + return ( +

+
+

홈에 무엇을 띄울까

+
+ {error ? ( +

+ {error} +

+ ) : null} + {!focus && !error ? ( +

+ 홈 설정을 불러오는 중입니다. +

+ ) : null} + {focus ? ( +
+

+ 공개 홈 맨 위 “지금 집중하는 것” 영역입니다. 셋 다 비워 두면 그 영역은 + 나타나지 않습니다. +

+ +
+ + + {projectHidden ? ( +

+ 이 프로젝트는 아직 비공개입니다. 주제·프로젝트 화면에서 게시해야 홈에 나타납니다. +

+ ) : null} +
+ +
+ + + {questions.length === 0 ? ( +

아직 Question 문서가 없습니다.

+ ) : null} +
+ +
+ + + {decisions.length === 0 ? ( +

아직 Decision 문서가 없습니다.

+ ) : null} +
+ + {nothingChosen ? ( +

+ 지금은 아무것도 고르지 않아 홈에 이 영역이 나타나지 않습니다. +

+ ) : null} + +
+ +
+
+ ) : null} +
+ ); +} diff --git a/src/features/tech-log/presentation/studio/components/release-manager.tsx b/src/features/tech-log/presentation/studio/components/release-manager.tsx index cd3e5ae..b2bf278 100644 --- a/src/features/tech-log/presentation/studio/components/release-manager.tsx +++ b/src/features/tech-log/presentation/studio/components/release-manager.tsx @@ -158,26 +158,28 @@ export function ReleaseManager() { } }; + const requestOf = (current: NonNullable): ReleaseUpdateRequest => + ({ + expectedVersion: current.expectedVersion, + versionLabel: current.versionLabel.trim(), + title: current.title.trim(), + summary: current.summary, + changeTypes: [...current.changeTypes], + changesMarkdown: current.changesMarkdown, + verificationMarkdown: current.verificationMarkdown, + ...(current.releasedOn ? { releasedOn: current.releasedOn } : {}), + reasonMarkdown: current.reasonMarkdown, + userImpactMarkdown: current.userImpactMarkdown, + implementationImpactMarkdown: current.implementationImpactMarkdown, + knownLimitationsMarkdown: current.knownLimitationsMarkdown, + }) as ReleaseUpdateRequest; + const save = async () => { if (pending || draft === null || selectedId === null) return; setPending(true); setError(""); try { - const body: ReleaseUpdateRequest = { - expectedVersion: draft.expectedVersion, - versionLabel: draft.versionLabel.trim(), - title: draft.title.trim(), - summary: draft.summary, - changeTypes: [...draft.changeTypes], - changesMarkdown: draft.changesMarkdown, - verificationMarkdown: draft.verificationMarkdown, - ...(draft.releasedOn ? { releasedOn: draft.releasedOn } : {}), - reasonMarkdown: draft.reasonMarkdown, - userImpactMarkdown: draft.userImpactMarkdown, - implementationImpactMarkdown: draft.implementationImpactMarkdown, - knownLimitationsMarkdown: draft.knownLimitationsMarkdown, - } as ReleaseUpdateRequest; - const saved = await managementGateway.updateRelease(selectedId, body); + const saved = await managementGateway.updateRelease(selectedId, requestOf(draft)); setDraft(toDraft(saved)); setRequestAnnouncement(`릴리즈 ${saved.versionLabel} 을(를) 저장했습니다.`); reload(); @@ -193,14 +195,24 @@ export function ReleaseManager() { setPending(true); setError(""); try { - const published = await managementGateway.publishRelease(selectedId, draft.expectedVersion); + /* + 먼저 저장한다. 발행은 서버에 저장된 릴리즈를 검사하는데(`PublishReleaseUseCase`), 예전에는 + 저장하지 않고 발행만 불렀다 — 화면의 칸을 다 채우고 공개를 눌러도 서버 쪽은 여전히 빈 + 초안이라 "모두 채워져야 합니다" 가 떴다. 채웠는데 안 된다는 말이 나온 이유가 이것이다. + */ + const saved = await managementGateway.updateRelease(selectedId, requestOf(draft)); + setDraft(toDraft(saved)); + const published = await managementGateway.publishRelease(selectedId, saved.version); setRequestAnnouncement(`릴리즈를 공개했습니다: ${published.canonicalPath}`); reload(); - } catch { - // 발행은 저장보다 요구가 많다. 무엇이 비었는지는 서버가 알고 있지만, 그 목록을 그대로 - // 옮기려면 오류 details 를 읽는 화면이 필요하다 — 여기서는 필수 항목을 그대로 안내한다. + } catch (error) { + // 무엇이 모자란지는 서버가 안다. 예전에는 그 답을 버리고 필수 항목을 전부 나열했는데, + // 그러면 이미 채운 칸까지 비었다고 말하게 된다. setError( - "공개하지 못했습니다. 버전, 제목, 한 줄 요약, 변경 유형, 변경 내용, 검증, 공개일이 모두 채워져야 합니다.", + managementFailureMessage( + error, + "공개하지 못했습니다. 버전, 제목, 한 줄 요약, 변경 유형, 변경 내용, 검증, 공개일이 모두 채워져야 합니다.", + ), ); } finally { setPending(false); @@ -341,6 +353,16 @@ export function ReleaseManager() { placeholder="0.1.0" onChange={(event) => update({ versionLabel: event.currentTarget.value })} /> + {/* + 새 릴리즈는 `draft-…` 라는 자리표시자 버전으로 만들어지고, 서버는 그것이 남아 + 있으면 공개를 거절한다(`ReleaseDrafts.isPlaceholder`). 화면에는 값이 채워져 + 보이므로 왜 거절당하는지 알 길이 없었다. + */} + {draft.versionLabel.startsWith("draft-") ? ( + + 자리표시자 버전입니다. 공개하려면 실제 버전으로 바꿔 주세요 (예: 0.2.0). + + ) : null}