Compare commits
22
Commits
c03b0c77b8
..
main
@@ -29,3 +29,4 @@ artifacts/tests/visual/
|
|||||||
# Git worktrees created inside the repository. A worktree is a checkout, not
|
# Git worktrees created inside the repository. A worktree is a checkout, not
|
||||||
# source: committing one would nest a second working copy inside this one.
|
# source: committing one would nest a second working copy inside this one.
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
.playwright-mcp/
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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]
|
|
||||||
@@ -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
|
|
||||||
@@ -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]
|
|
||||||
@@ -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]
|
|
||||||
@@ -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 작업본을 만들었습니다.
|
|
||||||
@@ -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]: 작업본 제목 없음 을(를) 삭제했습니다.
|
|
||||||
@@ -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]
|
|
||||||
@@ -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 작업본을 만들었습니다.
|
|
||||||
@@ -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]: 작업본 제목 없음 을(를) 삭제했습니다.
|
|
||||||
@@ -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]: 주제 삭제 시험 주제 을(를) 만들었습니다.
|
|
||||||
@@ -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]: 주제 삭제 시험 주제 을(를) 삭제했습니다.
|
|
||||||
@@ -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]
|
|
||||||
@@ -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]
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# TECH_LOG_STUDIO_PROJECT_EDIT accessibility review
|
||||||
|
|
||||||
|
Status: pending-manual-review
|
||||||
|
Route ID: TECH_LOG_STUDIO_PROJECT_EDIT
|
||||||
|
Release ID:
|
||||||
|
Reviewer:
|
||||||
|
Reviewed at:
|
||||||
|
Signature:
|
||||||
|
Attestation: pending
|
||||||
|
M1 Keyboard: pending
|
||||||
|
M2 Visible focus: pending
|
||||||
|
M3 Route focus: pending
|
||||||
|
M4 Modal focus: pending
|
||||||
|
M5 Error association: pending
|
||||||
|
M6 Color signal: pending
|
||||||
|
M7 Reduced motion: pending
|
||||||
|
Screen reader: pending
|
||||||
|
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# TECH_LOG_STUDIO_RELEASE_EDIT accessibility review
|
||||||
|
|
||||||
|
Status: pending-manual-review
|
||||||
|
Route ID: TECH_LOG_STUDIO_RELEASE_EDIT
|
||||||
|
Release ID:
|
||||||
|
Reviewer:
|
||||||
|
Reviewed at:
|
||||||
|
Signature:
|
||||||
|
Attestation: pending
|
||||||
|
M1 Keyboard: pending
|
||||||
|
M2 Visible focus: pending
|
||||||
|
M3 Route focus: pending
|
||||||
|
M4 Modal focus: pending
|
||||||
|
M5 Error association: pending
|
||||||
|
M6 Color signal: pending
|
||||||
|
M7 Reduced motion: pending
|
||||||
|
Screen reader: pending
|
||||||
|
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
@@ -1202,12 +1202,24 @@
|
|||||||
"schemaId": "markdown",
|
"schemaId": "markdown",
|
||||||
"production": "source-controlled"
|
"production": "source-controlled"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PROJECT-EDIT-md",
|
||||||
|
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_PROJECT_EDIT.md",
|
||||||
|
"schemaId": "markdown",
|
||||||
|
"production": "source-controlled"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASES-md",
|
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASES-md",
|
||||||
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASES.md",
|
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASES.md",
|
||||||
"schemaId": "markdown",
|
"schemaId": "markdown",
|
||||||
"production": "source-controlled"
|
"production": "source-controlled"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASE-EDIT-md",
|
||||||
|
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASE_EDIT.md",
|
||||||
|
"schemaId": "markdown",
|
||||||
|
"production": "source-controlled"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md",
|
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md",
|
||||||
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_NOT_FOUND.md",
|
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_NOT_FOUND.md",
|
||||||
@@ -1962,7 +1974,9 @@
|
|||||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PUBLICATION-PREVIEW-md",
|
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PUBLICATION-PREVIEW-md",
|
||||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-ASSETS-md",
|
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-ASSETS-md",
|
||||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-TAXONOMY-md",
|
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-TAXONOMY-md",
|
||||||
|
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PROJECT-EDIT-md",
|
||||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASES-md",
|
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASES-md",
|
||||||
|
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASE-EDIT-md",
|
||||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md",
|
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md",
|
||||||
"artifact-artifacts-tests-a11y-manual-NOT-FOUND-md",
|
"artifact-artifacts-tests-a11y-manual-NOT-FOUND-md",
|
||||||
"artifact-artifacts-tests-a11y-manual-report-json"
|
"artifact-artifacts-tests-a11y-manual-report-json"
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
"registrationAllowed": false,
|
"registrationAllowed": false,
|
||||||
"loginTheme": "keycloak",
|
"loginTheme": "keycloak",
|
||||||
"accessTokenLifespan": 300,
|
"accessTokenLifespan": 300,
|
||||||
"ssoSessionIdleTimeout": 1800,
|
"ssoSessionIdleTimeout": 28800,
|
||||||
"ssoSessionMaxLifespan": 36000,
|
"ssoSessionMaxLifespan": 86400,
|
||||||
"roles": {
|
"roles": {
|
||||||
"realm": [
|
"realm": [
|
||||||
{ "name": "studio-author", "description": "Tech Log Studio 편집 권한 (studio:read + studio:write)" }
|
{ "name": "studio-author", "description": "Tech Log Studio 편집 권한 (studio:read + studio:write)" }
|
||||||
|
|||||||
@@ -594,6 +594,20 @@ const commonSecurityRules = {
|
|||||||
"CallExpression[callee.object.name='document'][callee.property.name='createElement'][arguments.0.value='script']",
|
"CallExpression[callee.object.name='document'][callee.property.name='createElement'][arguments.0.value='script']",
|
||||||
message: "Runtime script construction is prohibited by FE-OC-019.",
|
message: "Runtime script construction is prohibited by FE-OC-019.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
/*
|
||||||
|
setState 업데이터는 핸들러가 끝난 뒤, 다음 렌더에 실행된다. 그때 React 는 이미
|
||||||
|
`event.currentTarget` 을 null 로 되돌려 놓았으므로 업데이터 안에서 그것을 읽으면
|
||||||
|
"Cannot read properties of null" 로 화면이 통째로 죽는다.
|
||||||
|
|
||||||
|
타입 검사도 lint 도 잡지 못했고, 첫 입력에서야 드러났다 — 값은 핸들러가 도는 동안
|
||||||
|
지역 변수로 꺼내 두고 업데이터에는 그 값을 넘긴다.
|
||||||
|
*/
|
||||||
|
selector:
|
||||||
|
"CallExpression[callee.name=/^set[A-Z]/] > ArrowFunctionExpression MemberExpression[property.name='currentTarget']",
|
||||||
|
message:
|
||||||
|
"Read event.currentTarget before the setState updater runs — it is null by the time the updater is called.",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -38,28 +38,28 @@
|
|||||||
},
|
},
|
||||||
"contractSet": {
|
"contractSet": {
|
||||||
"setAlgorithm": "CA_CONTRACT_SET_V1",
|
"setAlgorithm": "CA_CONTRACT_SET_V1",
|
||||||
"setDigest": "sha256:7ee35548d2a84b744f8c17c7b785a79b328dcf756f5c9df8d77d37ff1535b087",
|
"setDigest": "sha256:cdcfb628a502d71596f1162726eb395aad0f5f92cf05fd77d304f8e51c81b2fc",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"packageId": "@tech-log/management-contract",
|
"packageId": "@tech-log/management-contract",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"digest": "sha256:d19ae7c4fbcac924a356bbb0cc1a46a4046ecec701158ca0a9b7cc089bbaf878",
|
"digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
|
||||||
"runtimeProtocolVersion": 1,
|
"runtimeProtocolVersion": 1,
|
||||||
"sourceRevision": "b195b29"
|
"sourceRevision": "ef49d3a"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"packageId": "@tech-log/public-contract",
|
"packageId": "@tech-log/public-contract",
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"digest": "sha256:37e6f804165de3e492e975075bea563ee41ae74076222a3562d3452631bfdb2b",
|
"digest": "sha256:7eb668e39e279e49767306dd36e1dd51302071c39d78495d21307bbd9676220e",
|
||||||
"runtimeProtocolVersion": 1,
|
"runtimeProtocolVersion": 1,
|
||||||
"sourceRevision": "b195b29"
|
"sourceRevision": "ef49d3a"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"packageId": "@tech-log/studio-contract",
|
"packageId": "@tech-log/studio-contract",
|
||||||
"version": "3.0.0",
|
"version": "3.1.0",
|
||||||
"digest": "sha256:674327a82951fd4a1bc2594c858072dfcd0b9abe7c63198283da5d8c92a04326",
|
"digest": "sha256:18dd46898be64b07f7e826409d19347512613ee2e22420028a4a0644f50f37dd",
|
||||||
"runtimeProtocolVersion": 1,
|
"runtimeProtocolVersion": 1,
|
||||||
"sourceRevision": "b195b29"
|
"sourceRevision": "ef49d3a"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -468,7 +468,12 @@ const CANONICAL_GATE_SHAPE_SHA256 =
|
|||||||
// TECH_LOG_STUDIO_RELEASES evidence artifact, by the same method — 8c73d447…
|
// TECH_LOG_STUDIO_RELEASES evidence artifact, by the same method — 8c73d447…
|
||||||
// was first reproduced from the previous gates.json, so the computation that
|
// was first reproduced from the previous gates.json, so the computation that
|
||||||
// produced this value is known to be the one the constant was pinned under.
|
// produced this value is known to be the one the constant was pinned under.
|
||||||
"187dbd9676b7b3444409b5af4143d49927b1eacc67f57a099f4a3765bb64d865";
|
// 프로젝트 편집 화면: FE-GATE-009 가 TECH_LOG_STUDIO_PROJECT_EDIT 증거 아티팩트를
|
||||||
|
// 얻어 다시 계산했다. 같은 방법이다 — 187dbd96… 을 이전 gates.json 에서 먼저 재현해,
|
||||||
|
// 이 값을 만든 계산이 상수가 고정될 때 쓰인 그 계산임을 확인했다.
|
||||||
|
// 릴리즈 편집 화면: 같은 방법으로 다시 계산했다. f9e7e521… 을 이전 gates.json 에서 먼저
|
||||||
|
// 재현했다.
|
||||||
|
"fb138e7c51fdf969f755cd8ff32cf627f1750b966d212c33ee996c8c578db0e3";
|
||||||
|
|
||||||
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
|
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
|
||||||
const normalized = gates.map(
|
const normalized = gates.map(
|
||||||
@@ -523,8 +528,13 @@ function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[]
|
|||||||
// Alignment follow-up, item 2 added the TechLog junit report.
|
// Alignment follow-up, item 2 added the TechLog junit report.
|
||||||
// The taxonomy route added its own manual a11y evidence file — every installed
|
// The taxonomy route added its own manual a11y evidence file — every installed
|
||||||
// route carries one, and the gate checks that the two sets match exactly.
|
// route carries one, and the gate checks that the two sets match exactly.
|
||||||
if (contract.artifacts.length !== 132) {
|
// The project edit route did the same: it is what finally lets a project carry
|
||||||
failures.push(`artifact authority baseline must contain exactly 132 artifacts; received ${contract.artifacts.length}`);
|
// a purpose, a current objective, and a next step, so the public screens that
|
||||||
|
// read those fields stop rendering blanks.
|
||||||
|
// The release edit route followed: the editor used to open below the release
|
||||||
|
// list, so editing meant scrolling past every release to reach it.
|
||||||
|
if (contract.artifacts.length !== 134) {
|
||||||
|
failures.push(`artifact authority baseline must contain exactly 134 artifacts; received ${contract.artifacts.length}`);
|
||||||
}
|
}
|
||||||
if (contract.stages.length !== 5) {
|
if (contract.stages.length !== 5) {
|
||||||
failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`);
|
failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`);
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import type {
|
import type {
|
||||||
CreateDraftResponse,
|
CreateDraftResponse,
|
||||||
|
HomeFocusRequest,
|
||||||
|
HomeFocusResponse,
|
||||||
|
ProjectActivityRequest,
|
||||||
|
ProjectActivityResponse,
|
||||||
|
UpdateProjectActivityRequest,
|
||||||
ProjectEditResponse,
|
ProjectEditResponse,
|
||||||
ProjectIndexPage,
|
ProjectIndexPage,
|
||||||
ProjectUpdateRequest,
|
ProjectUpdateRequest,
|
||||||
@@ -85,6 +90,28 @@ export function createHttpManagementGateway(
|
|||||||
deleteRelease: async (id: string, expectedVersion: number) => {
|
deleteRelease: async (id: string, expectedVersion: number) => {
|
||||||
await run<void>("deleteRelease", { id, expectedVersion });
|
await run<void>("deleteRelease", { id, expectedVersion });
|
||||||
},
|
},
|
||||||
|
listProjectActivities: (id: string) =>
|
||||||
|
run<ProjectActivityResponse[]>("listStudioProjectActivities", { id }),
|
||||||
|
createProjectActivity: (id: string, body: ProjectActivityRequest) =>
|
||||||
|
run<ProjectActivityResponse>("createProjectActivity", { id, body }),
|
||||||
|
updateProjectActivity: (
|
||||||
|
id: string,
|
||||||
|
activityId: string,
|
||||||
|
body: UpdateProjectActivityRequest,
|
||||||
|
) => run<ProjectActivityResponse>("updateProjectActivity", { id, activityId, body }),
|
||||||
|
deleteProjectActivity: async (id: string, activityId: string, expectedVersion: number) => {
|
||||||
|
await run<void>("deleteProjectActivity", { id, activityId, expectedVersion });
|
||||||
|
},
|
||||||
|
publishProject: (
|
||||||
|
id: string,
|
||||||
|
expectedVersion: number,
|
||||||
|
visibility: "PUBLIC" | "UNLISTED" = "PUBLIC",
|
||||||
|
) => run<PublishResponse>("publishProject", { id, expectedVersion, visibility }),
|
||||||
|
unpublishProject: (id: string, expectedVersion: number) =>
|
||||||
|
run<ProjectEditResponse>("unpublishProject", { id, expectedVersion }),
|
||||||
|
getHomeFocus: () => run<HomeFocusResponse>("getHomeFocus", {}),
|
||||||
|
updateHomeFocus: (body: HomeFocusRequest) =>
|
||||||
|
run<HomeFocusResponse>("updateHomeFocus", body),
|
||||||
publishRelease: (id: string, expectedVersion: number) =>
|
publishRelease: (id: string, expectedVersion: number) =>
|
||||||
run<PublishResponse>("publishRelease", { id, expectedVersion }),
|
run<PublishResponse>("publishRelease", { id, expectedVersion }),
|
||||||
archiveRelease: (id: string, expectedVersion: number) =>
|
archiveRelease: (id: string, expectedVersion: number) =>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
HomeFocusItem,
|
HomeFocusItem,
|
||||||
|
LatestRecordEntry,
|
||||||
ProjectActivity,
|
ProjectActivity,
|
||||||
ProjectDecision,
|
ProjectDecision,
|
||||||
Project,
|
Project,
|
||||||
@@ -19,12 +20,12 @@ import {
|
|||||||
decisionItemToDecision,
|
decisionItemToDecision,
|
||||||
flattenRelations,
|
flattenRelations,
|
||||||
knowledgeListItemToRecord,
|
knowledgeListItemToRecord,
|
||||||
markdownLines,
|
|
||||||
markdownSections,
|
markdownSections,
|
||||||
questionListItemToRecord,
|
questionListItemToRecord,
|
||||||
releaseDetailToRelease,
|
releaseDetailToRelease,
|
||||||
searchItemToEntity,
|
searchItemToEntity,
|
||||||
} from "./public-content-mapping.ts";
|
} from "./public-content-mapping.ts";
|
||||||
|
import type { components } from "../../contracts/public/generated.ts";
|
||||||
import type { StudioOperationExecutor } from "./http-studio-gateway.ts";
|
import type { StudioOperationExecutor } from "./http-studio-gateway.ts";
|
||||||
|
|
||||||
const ROUTE_ID = "TECH_LOG_PUBLIC";
|
const ROUTE_ID = "TECH_LOG_PUBLIC";
|
||||||
@@ -141,13 +142,19 @@ export function createHttpPublicContentGateway(
|
|||||||
|
|
||||||
const canonicalPath = String(detail.canonicalPath ?? "");
|
const canonicalPath = String(detail.canonicalPath ?? "");
|
||||||
const groups = (detail.relations as Readonly<Record<string, never>>) ?? {};
|
const groups = (detail.relations as Readonly<Record<string, never>>) ?? {};
|
||||||
|
const projectOf = (entry: Readonly<{ title?: string; path?: string }> | undefined) =>
|
||||||
|
entry?.path
|
||||||
|
? { name: entry.title ?? "", slug: entry.path.split("/").filter(Boolean).pop() ?? "", path: entry.path }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
if (kind === "CASE") {
|
if (kind === "CASE") {
|
||||||
const body = (detail.case as Readonly<Record<string, unknown>>) ?? {};
|
const body = (detail.case as Readonly<Record<string, unknown>>) ?? {};
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
...baseOf("CASE", slug, {
|
...baseOf("CASE", slug, {
|
||||||
title: body.title as string,
|
title: body.title as string,
|
||||||
summary: body.problemSummary as string,
|
// 제목 바로 아래에 오는 것은 문서의 요약이다. 유형별 요약(문제/범위)을 쓰면 바로 아래
|
||||||
|
// 블록과 같은 글을 두 번 말한다.
|
||||||
|
summary: (body.summary as string) ?? "",
|
||||||
path: canonicalPath,
|
path: canonicalPath,
|
||||||
primaryTopic: body.primaryTopic as never,
|
primaryTopic: body.primaryTopic as never,
|
||||||
primaryProject: body.primaryProject as never,
|
primaryProject: body.primaryProject as never,
|
||||||
@@ -192,7 +199,7 @@ export function createHttpPublicContentGateway(
|
|||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
...baseOf("REFERENCE", slug, {
|
...baseOf("REFERENCE", slug, {
|
||||||
title: body.title as string,
|
title: body.title as string,
|
||||||
summary: body.purposeSummary as string,
|
summary: (body.summary as string) ?? "",
|
||||||
path: canonicalPath,
|
path: canonicalPath,
|
||||||
primaryTopic: body.primaryTopic as never,
|
primaryTopic: body.primaryTopic as never,
|
||||||
primaryProject: body.primaryProject as never,
|
primaryProject: body.primaryProject as never,
|
||||||
@@ -204,48 +211,90 @@ export function createHttpPublicContentGateway(
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
kind: "REFERENCE",
|
kind: "REFERENCE",
|
||||||
purpose: (body.purposeSummary as string) ?? "",
|
/*
|
||||||
|
여기서 읽는 이름은 계약이 실제로 주는 이름이어야 한다. 한때 `purposeSummary`,
|
||||||
|
`applyWhenMarkdown`, `exceptionsMarkdown`, `examplesMarkdown` 을 읽었는데 계약에는 그런
|
||||||
|
칸이 없다 — 전부 undefined 로 떨어져 공개 Reference 화면이 통째로 비었다. Studio 에서는
|
||||||
|
같은 글이 다 보이므로 "공개 쪽만 안 나온다" 로 드러났다.
|
||||||
|
|
||||||
|
규칙과 예시는 `content` 마크다운을 잘라 만드는 것이 아니라 계약이 구조로 준다. Studio 의
|
||||||
|
편집기가 제목과 본문을 따로 받기 때문이다.
|
||||||
|
*/
|
||||||
|
purpose: (body.scopeSummary as string) ?? "",
|
||||||
rules: Object.freeze(
|
rules: Object.freeze(
|
||||||
markdownSections(body.content as string).map((section) => ({
|
((body.rules as readonly Readonly<Record<string, unknown>>[] | undefined) ?? []).map(
|
||||||
title: section.title,
|
(rule) => ({
|
||||||
body: section.paragraphs.join("\n"),
|
title: String(rule.title ?? ""),
|
||||||
})),
|
body: String(rule.body ?? ""),
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
applyWhen: Object.freeze(markdownLines(body.applyWhenMarkdown as string)),
|
),
|
||||||
exceptions: Object.freeze(markdownLines(body.exceptionsMarkdown as string)),
|
applyWhen: Object.freeze(((body.appliesTo as readonly string[] | undefined) ?? []).map(String)),
|
||||||
examples: Object.freeze(markdownLines(body.examplesMarkdown as string)),
|
exceptions: Object.freeze(
|
||||||
|
((body.excludedScope as readonly string[] | undefined) ?? []).map(String),
|
||||||
|
),
|
||||||
|
examples: Object.freeze(((body.examples as readonly string[] | undefined) ?? []).map(String)),
|
||||||
verifiedAt: dateLabel(body.lastVerifiedAt as string),
|
verifiedAt: dateLabel(body.lastVerifiedAt as string),
|
||||||
}) as unknown as Extract<PublicRecord, { kind: K }>;
|
}) as unknown as Extract<PublicRecord, { kind: K }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = (detail.question as Readonly<Record<string, unknown>>) ?? {};
|
const body = (detail.question as Readonly<Record<string, unknown>>) ?? {};
|
||||||
const points = (body.points as readonly Readonly<Record<string, unknown>>[] | undefined) ?? [];
|
/*
|
||||||
const pointsOf = (group: string) =>
|
`points` 는 그룹 이름을 키로 갖는 객체다 — 계약의 `QuestionPointGroup`. 여기서는
|
||||||
Object.freeze(
|
`{group, items}` 배열로 읽으면서 `.filter` 를 불렀고, 객체에는 그런 것이 없으니 상세
|
||||||
points
|
화면이 통째로 「요청을 처리하지 못했습니다」가 됐다. 목록은 이 칸을 비워 두고 만들기
|
||||||
.filter((point) => point.group === group)
|
때문에 탐색에서는 멀쩡히 보였고, 그래서 "게시했는데 안 뜬다" 로만 드러났다.
|
||||||
.flatMap((point) => (point.items as readonly string[] | undefined) ?? []),
|
|
||||||
);
|
`as` 캐스트가 그 어긋남을 타입 검사에서 가렸다. 계약의 타입을 그대로 쓰면 다음에 모양이
|
||||||
|
바뀔 때 컴파일이 먼저 막는다.
|
||||||
|
*/
|
||||||
|
type QuestionPoints = components["schemas"]["QuestionPointGroup"];
|
||||||
|
const points = body.points as QuestionPoints | undefined;
|
||||||
|
const pointsOf = (group: keyof QuestionPoints) =>
|
||||||
|
Object.freeze([...(points?.[group] ?? [])].map(String));
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
...baseOf("QUESTION", slug, {
|
...baseOf("QUESTION", slug, {
|
||||||
title: body.question as string,
|
title: body.question as string,
|
||||||
summary: body.summary as string,
|
summary: body.summary as string,
|
||||||
path: canonicalPath,
|
path: canonicalPath,
|
||||||
primaryTopic: body.primaryTopic as never,
|
primaryTopic: body.primaryTopic as never,
|
||||||
primaryProject: body.primaryProject as never,
|
/*
|
||||||
|
질문 상세는 프로젝트를 `question` 이 아니라 `relations.primaryProject` 에 담는다 —
|
||||||
|
Case/Reference 와 다른 자리다. `question` 에서 찾고 있었으므로 머리말의 프로젝트
|
||||||
|
칸이 늘 비어 있었다.
|
||||||
|
|
||||||
|
그 자리의 값은 `RelatedEntry` 라 `title`/`path` 를 쓴다. 머리말이 기다리는 것은
|
||||||
|
`name`/`slug` 이므로 여기서 옮겨 준다 — slug 는 경로의 마지막 마디다.
|
||||||
|
*/
|
||||||
|
primaryProject: projectOf(groups.primaryProject) as never,
|
||||||
publishedAt: body.updatedAt as string,
|
publishedAt: body.updatedAt as string,
|
||||||
relations: flattenRelations(groups, {
|
/*
|
||||||
derivedCases: "이 질문에서 나온 기록",
|
계약이 주는 이름은 `resultCase` / `producedDecision` / `derivedReferences` 다.
|
||||||
projectDecisions: "이 질문이 이끈 결정",
|
여기서는 `derivedCases` / `projectDecisions` / `relatedQuestions` 를 찾고 있었고,
|
||||||
relatedQuestions: "관련 질문",
|
하나도 맞지 않아 이유 자리에 영문 키가 그대로 나왔다.
|
||||||
}),
|
|
||||||
|
`primaryProject` 는 관계가 아니라 이 질문이 속한 프로젝트다 — 머리말이 이미
|
||||||
|
보여 주므로 관계 목록에 넣지 않는다.
|
||||||
|
*/
|
||||||
|
relations: flattenRelations(
|
||||||
|
{
|
||||||
|
resultCase: groups.resultCase,
|
||||||
|
producedDecision: groups.producedDecision,
|
||||||
|
derivedReferences: groups.derivedReferences,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resultCase: "이 질문에서 나온 기록",
|
||||||
|
producedDecision: "이 질문이 이끈 결정",
|
||||||
|
derivedReferences: "이 질문에서 정리된 기준",
|
||||||
|
},
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
kind: "QUESTION",
|
kind: "QUESTION",
|
||||||
questionStatus: (body.status as QuestionRecord["questionStatus"]) ?? "OPEN",
|
questionStatus: (body.status as QuestionRecord["questionStatus"]) ?? "OPEN",
|
||||||
facts: pointsOf("KNOWN_FACT"),
|
facts: pointsOf("facts"),
|
||||||
assumptions: pointsOf("ASSUMPTION"),
|
assumptions: pointsOf("assumptions"),
|
||||||
unknowns: pointsOf("UNRESOLVED"),
|
unknowns: pointsOf("unknowns"),
|
||||||
constraints: pointsOf("CONSTRAINT"),
|
constraints: pointsOf("constraints"),
|
||||||
options: Object.freeze([]),
|
options: Object.freeze([]),
|
||||||
nextValidation: (body.nextVerification as string) ?? "",
|
nextValidation: (body.nextVerification as string) ?? "",
|
||||||
}) as unknown as Extract<PublicRecord, { kind: K }>;
|
}) as unknown as Extract<PublicRecord, { kind: K }>;
|
||||||
@@ -320,6 +369,33 @@ export function createHttpPublicContentGateway(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공개 투영이 고른 최근 기록. `entryType` 은 계약이 네 값만 허용하므로 그대로 믿고 쓴다 —
|
||||||
|
* 서버가 이미 걸러 보낸다.
|
||||||
|
*/
|
||||||
|
async function getLatestEntries(): Promise<LatestRecordEntry[]> {
|
||||||
|
const home = await read<Readonly<{ latestEntries?: readonly Readonly<Record<string, unknown>>[] }>>(
|
||||||
|
"getPublicHome",
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
if (home === NOT_FOUND) return [];
|
||||||
|
return (home.latestEntries ?? []).map((entry) => {
|
||||||
|
const topic = entry.primaryTopic as Readonly<Record<string, unknown>> | null | undefined;
|
||||||
|
const project = entry.primaryProject as Readonly<Record<string, unknown>> | 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<HomeFocusItem[]> {
|
async function getHomeFocusItems(): Promise<HomeFocusItem[]> {
|
||||||
const home = await read<Readonly<{ focus?: Readonly<Record<string, never>> }>>(
|
const home = await read<Readonly<{ focus?: Readonly<Record<string, never>> }>>(
|
||||||
"getPublicHome",
|
"getPublicHome",
|
||||||
@@ -463,6 +539,7 @@ export function createHttpPublicContentGateway(
|
|||||||
getProjectDecisions,
|
getProjectDecisions,
|
||||||
getProjectActivity,
|
getProjectActivity,
|
||||||
getHomeFocusItems,
|
getHomeFocusItems,
|
||||||
|
getLatestEntries,
|
||||||
searchPublicContent,
|
searchPublicContent,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type HomeFocusItem,
|
type HomeFocusItem,
|
||||||
} from "./public-content.ts";
|
} from "./public-content.ts";
|
||||||
import type {
|
import type {
|
||||||
|
LatestRecordEntry,
|
||||||
PublicContentQueries,
|
PublicContentQueries,
|
||||||
PublicTopic,
|
PublicTopic,
|
||||||
} from "../../application/ports/public-content-queries.ts";
|
} from "../../application/ports/public-content-queries.ts";
|
||||||
@@ -53,6 +54,7 @@ export function listRecords(filters: RecordFilters = {}): PublicRecord[] {
|
|||||||
.filter(
|
.filter(
|
||||||
(record) =>
|
(record) =>
|
||||||
!hasTopicFilter ||
|
!hasTopicFilter ||
|
||||||
|
record.topicSlug.toLocaleLowerCase("ko-KR") === requestedTopic ||
|
||||||
record.topic.toLocaleLowerCase("ko-KR") === requestedTopic,
|
record.topic.toLocaleLowerCase("ko-KR") === requestedTopic,
|
||||||
)
|
)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -119,6 +121,38 @@ export function listTopics(): PublicTopic[] {
|
|||||||
.map((entry) => Object.freeze(entry));
|
.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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
/*
|
||||||
|
릴리스는 넣지 않는다. 이 목록은 서버의 `latestEntries` 와 같은 의미여야 하고, 그쪽은 공개
|
||||||
|
투영에서 고르므로 릴리스가 없다 — 릴리스는 Publication 파이프라인을 거치지 않는다. 홈 화면이
|
||||||
|
릴리스를 따로 읽어 합치므로, 여기서도 넣으면 같은 릴리스가 두 번 나온다.
|
||||||
|
*/
|
||||||
|
return [...activities].sort((left, right) =>
|
||||||
|
right.publishedAt.localeCompare(left.publishedAt),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function getHomeFocusItems(): HomeFocusItem[] {
|
export function getHomeFocusItems(): HomeFocusItem[] {
|
||||||
const project = getProject("backend-skeleton");
|
const project = getProject("backend-skeleton");
|
||||||
const question = getRecord("QUESTION", "validate-edge-token-again");
|
const question = getRecord("QUESTION", "validate-edge-token-again");
|
||||||
@@ -264,6 +298,9 @@ export const publicContentQueries = Object.freeze({
|
|||||||
async listTopics() {
|
async listTopics() {
|
||||||
return listTopics();
|
return listTopics();
|
||||||
},
|
},
|
||||||
|
async getLatestEntries() {
|
||||||
|
return getLatestEntries();
|
||||||
|
},
|
||||||
async getHomeFocusItems() {
|
async getHomeFocusItems() {
|
||||||
return getHomeFocusItems();
|
return getHomeFocusItems();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import type {
|
import type {
|
||||||
CreateDraftResponse,
|
CreateDraftResponse,
|
||||||
|
HomeFocusRequest,
|
||||||
|
HomeFocusResponse,
|
||||||
|
ProjectActivityRequest,
|
||||||
|
ProjectActivityResponse,
|
||||||
|
UpdateProjectActivityRequest,
|
||||||
ProjectEditResponse,
|
ProjectEditResponse,
|
||||||
ProjectIndexPage,
|
ProjectIndexPage,
|
||||||
ProjectUpdateRequest,
|
ProjectUpdateRequest,
|
||||||
@@ -27,6 +32,33 @@ export type ManagementGateway = Readonly<{
|
|||||||
createProject(title: string): Promise<CreateDraftResponse>;
|
createProject(title: string): Promise<CreateDraftResponse>;
|
||||||
updateProject(id: string, body: ProjectUpdateRequest): Promise<ProjectEditResponse>;
|
updateProject(id: string, body: ProjectUpdateRequest): Promise<ProjectEditResponse>;
|
||||||
deleteProject(id: string, expectedVersion: number): Promise<void>;
|
deleteProject(id: string, expectedVersion: number): Promise<void>;
|
||||||
|
/**
|
||||||
|
* 프로젝트 게시. 프로젝트는 Studio 문서가 아니라 게시 파이프라인 밖에 있고, 그래서 문서를
|
||||||
|
* 게시해도 그 문서가 속한 프로젝트는 비공개로 남는다 — 공개 화면(프로젝트 목록·프로필의
|
||||||
|
* "현재 프로젝트"·홈의 focus)은 모두 게시된 프로젝트만 읽으므로, 이 호출 없이는 어디에도
|
||||||
|
* 나타나지 않는다.
|
||||||
|
*/
|
||||||
|
publishProject(
|
||||||
|
id: string,
|
||||||
|
expectedVersion: number,
|
||||||
|
visibility?: "PUBLIC" | "UNLISTED",
|
||||||
|
): Promise<PublishResponse>;
|
||||||
|
unpublishProject(id: string, expectedVersion: number): Promise<ProjectEditResponse>;
|
||||||
|
/**
|
||||||
|
* 프로젝트 활동. 공개 프로젝트 화면의 "활동" 은 이 목록을 투영 없이 직접 읽으므로, 여기서
|
||||||
|
* 만든 줄이 곧 그 화면이다.
|
||||||
|
*/
|
||||||
|
listProjectActivities(id: string): Promise<ProjectActivityResponse[]>;
|
||||||
|
createProjectActivity(id: string, body: ProjectActivityRequest): Promise<ProjectActivityResponse>;
|
||||||
|
updateProjectActivity(
|
||||||
|
id: string,
|
||||||
|
activityId: string,
|
||||||
|
body: UpdateProjectActivityRequest,
|
||||||
|
): Promise<ProjectActivityResponse>;
|
||||||
|
deleteProjectActivity(id: string, activityId: string, expectedVersion: number): Promise<void>;
|
||||||
|
/** 공개 홈이 무엇을 앞에 둘지. 세 슬롯이 모두 비면 홈은 그 영역을 아예 그리지 않는다. */
|
||||||
|
getHomeFocus(): Promise<HomeFocusResponse>;
|
||||||
|
updateHomeFocus(body: HomeFocusRequest): Promise<HomeFocusResponse>;
|
||||||
listReleases(page?: number, size?: number): Promise<ReleaseIndexPage>;
|
listReleases(page?: number, size?: number): Promise<ReleaseIndexPage>;
|
||||||
getRelease(id: string): Promise<ReleaseEditResponse>;
|
getRelease(id: string): Promise<ReleaseEditResponse>;
|
||||||
createRelease(title: string): Promise<CreateDraftResponse>;
|
createRelease(title: string): Promise<CreateDraftResponse>;
|
||||||
|
|||||||
@@ -164,6 +164,24 @@ export type PublicTopic = {
|
|||||||
recordCount: number;
|
recordCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 홈의 "최근 기록" 한 줄. 서버가 공개 투영에서 직접 고른다.
|
||||||
|
*
|
||||||
|
* 화면이 프로젝트를 하나씩 돌며 조립하던 때에는, 게시된 문서라도 그 문서가 매달린 프로젝트가
|
||||||
|
* 공개되어 있지 않으면 목록에서 통째로 빠졌다 — 실제로 게시한 Case 는 안 보이고 릴리스만
|
||||||
|
* 남았다. 무엇이 최근인지는 공개 투영 하나가 알고 있으므로 거기서 그대로 읽는다.
|
||||||
|
*/
|
||||||
|
export type LatestRecordEntry = {
|
||||||
|
id: string;
|
||||||
|
entryType: "CASE" | "REFERENCE" | "QUESTION" | "PROJECT_ACTIVITY" | "RELEASE";
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
path: string;
|
||||||
|
publishedAt: string;
|
||||||
|
topic: string;
|
||||||
|
project: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type FocusKey = "current" | "question" | "decision";
|
export type FocusKey = "current" | "question" | "decision";
|
||||||
|
|
||||||
export type HomeFocusItem = {
|
export type HomeFocusItem = {
|
||||||
@@ -227,6 +245,7 @@ export type PublicContentQueries = Readonly<{
|
|||||||
* 있었고, Studio 에서 주제를 만들어도 바뀌지 않았다.
|
* 있었고, Studio 에서 주제를 만들어도 바뀌지 않았다.
|
||||||
*/
|
*/
|
||||||
listTopics(): Promise<PublicTopic[]>;
|
listTopics(): Promise<PublicTopic[]>;
|
||||||
|
getLatestEntries(): Promise<LatestRecordEntry[]>;
|
||||||
getHomeFocusItems(): Promise<HomeFocusItem[]>;
|
getHomeFocusItems(): Promise<HomeFocusItem[]>;
|
||||||
searchPublicContent(query: string): Promise<SearchablePublicEntity[]>;
|
searchPublicContent(query: string): Promise<SearchablePublicEntity[]>;
|
||||||
}>;
|
}>;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"packageId": "@tech-log/management-contract",
|
"packageId": "@tech-log/management-contract",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"digest": "sha256:d19ae7c4fbcac924a356bbb0cc1a46a4046ecec701158ca0a9b7cc089bbaf878",
|
"digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
|
||||||
"sourceRevision": "b195b29",
|
"sourceRevision": "ef49d3a",
|
||||||
"operationIds": [
|
"operationIds": [
|
||||||
"createCaseDraft",
|
"createCaseDraft",
|
||||||
"getCaseForEdit",
|
"getCaseForEdit",
|
||||||
@@ -83,6 +83,7 @@
|
|||||||
"updateHomeFocus",
|
"updateHomeFocus",
|
||||||
"listStudioProjectActivities",
|
"listStudioProjectActivities",
|
||||||
"createProjectActivity",
|
"createProjectActivity",
|
||||||
"updateProjectActivity"
|
"updateProjectActivity",
|
||||||
|
"deleteProjectActivity"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,3 +15,8 @@ export type ReleaseIndexItem = Schemas["ReleaseIndexItem"];
|
|||||||
export type ReleaseIndexPage = Schemas["ReleaseIndexPage"];
|
export type ReleaseIndexPage = Schemas["ReleaseIndexPage"];
|
||||||
export type ReleaseUpdateRequest = Schemas["ReleaseUpdateRequest"];
|
export type ReleaseUpdateRequest = Schemas["ReleaseUpdateRequest"];
|
||||||
export type PublishResponse = Schemas["PublishResponse"];
|
export type PublishResponse = Schemas["PublishResponse"];
|
||||||
|
export type HomeFocusRequest = Schemas["HomeFocusRequest"];
|
||||||
|
export type HomeFocusResponse = Schemas["HomeFocusResponse"];
|
||||||
|
export type ProjectActivityRequest = Schemas["ProjectActivityRequest"];
|
||||||
|
export type UpdateProjectActivityRequest = Schemas["UpdateProjectActivityRequest"];
|
||||||
|
export type ProjectActivityResponse = Schemas["ProjectActivityResponse"];
|
||||||
|
|||||||
@@ -841,7 +841,7 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/api/v1/studio/home-focus": {
|
"/api/v1/studio/home/focus": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
@@ -883,7 +883,12 @@ export interface paths {
|
|||||||
get?: never;
|
get?: never;
|
||||||
put: operations["updateProjectActivity"];
|
put: operations["updateProjectActivity"];
|
||||||
post?: never;
|
post?: never;
|
||||||
delete?: never;
|
/** @description 프로젝트 활동 한 줄을 지운다. 계약에는 만들기와 고치기만 있었고 지우기가 없어서, 잘못
|
||||||
|
* 적은 줄이 공개 타임라인에 영구히 남았다.
|
||||||
|
*
|
||||||
|
* `AUTO` 로 기록된 줄은 게시 같은 실제 사건의 흔적이므로 지우지 않는다 — 지우면 무슨 일이
|
||||||
|
* 있었는지가 사라진다. 손으로 적은 `MANUAL` 줄만 지울 수 있다. */
|
||||||
|
delete: operations["deleteProjectActivity"];
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
patch?: never;
|
patch?: never;
|
||||||
@@ -963,6 +968,24 @@ export interface components {
|
|||||||
data: components["schemas"]["ReleaseEditResponse"];
|
data: components["schemas"]["ReleaseEditResponse"];
|
||||||
meta: components["schemas"]["ResponseMeta"];
|
meta: components["schemas"]["ResponseMeta"];
|
||||||
};
|
};
|
||||||
|
HomeFocusResponseEnvelope: {
|
||||||
|
/** @constant */
|
||||||
|
success: true;
|
||||||
|
data: components["schemas"]["HomeFocusResponse"];
|
||||||
|
meta: components["schemas"]["ResponseMeta"];
|
||||||
|
};
|
||||||
|
ProjectActivityResponseEnvelope: {
|
||||||
|
/** @constant */
|
||||||
|
success: true;
|
||||||
|
data: components["schemas"]["ProjectActivityResponse"];
|
||||||
|
meta: components["schemas"]["ResponseMeta"];
|
||||||
|
};
|
||||||
|
ProjectActivityListEnvelope: {
|
||||||
|
/** @constant */
|
||||||
|
success: true;
|
||||||
|
data: components["schemas"]["ProjectActivityResponse"][];
|
||||||
|
meta: components["schemas"]["ResponseMeta"];
|
||||||
|
};
|
||||||
PublishResponseEnvelope: {
|
PublishResponseEnvelope: {
|
||||||
/** @constant */
|
/** @constant */
|
||||||
success: true;
|
success: true;
|
||||||
@@ -5519,7 +5542,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["PublishResponse"];
|
"application/json": components["schemas"]["PublishResponseEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Bad Request */
|
/** @description Bad Request */
|
||||||
@@ -5528,7 +5551,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Unauthorized */
|
/** @description Unauthorized */
|
||||||
@@ -5537,7 +5560,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Forbidden */
|
/** @description Forbidden */
|
||||||
@@ -5546,7 +5569,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Not Found */
|
/** @description Not Found */
|
||||||
@@ -5555,7 +5578,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Conflict */
|
/** @description Conflict */
|
||||||
@@ -5564,7 +5587,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Unprocessable Content */
|
/** @description Unprocessable Content */
|
||||||
@@ -5573,7 +5596,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Internal Server Error */
|
/** @description Internal Server Error */
|
||||||
@@ -5582,7 +5605,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -5610,7 +5633,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["ProjectEditResponse"];
|
"application/json": components["schemas"]["ProjectEditResponseEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Bad Request */
|
/** @description Bad Request */
|
||||||
@@ -5619,7 +5642,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Unauthorized */
|
/** @description Unauthorized */
|
||||||
@@ -5628,7 +5651,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Forbidden */
|
/** @description Forbidden */
|
||||||
@@ -5637,7 +5660,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Not Found */
|
/** @description Not Found */
|
||||||
@@ -5646,7 +5669,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Conflict */
|
/** @description Conflict */
|
||||||
@@ -5655,7 +5678,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Unprocessable Content */
|
/** @description Unprocessable Content */
|
||||||
@@ -5664,7 +5687,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Internal Server Error */
|
/** @description Internal Server Error */
|
||||||
@@ -5673,7 +5696,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -8117,7 +8140,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["HomeFocusResponse"];
|
"application/json": components["schemas"]["HomeFocusResponseEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Bad Request */
|
/** @description Bad Request */
|
||||||
@@ -8126,7 +8149,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Unauthorized */
|
/** @description Unauthorized */
|
||||||
@@ -8135,7 +8158,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Forbidden */
|
/** @description Forbidden */
|
||||||
@@ -8144,7 +8167,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Not Found */
|
/** @description Not Found */
|
||||||
@@ -8153,7 +8176,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Internal Server Error */
|
/** @description Internal Server Error */
|
||||||
@@ -8162,7 +8185,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -8188,7 +8211,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["HomeFocusResponse"];
|
"application/json": components["schemas"]["HomeFocusResponseEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Bad Request */
|
/** @description Bad Request */
|
||||||
@@ -8197,7 +8220,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Unauthorized */
|
/** @description Unauthorized */
|
||||||
@@ -8206,7 +8229,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Forbidden */
|
/** @description Forbidden */
|
||||||
@@ -8215,7 +8238,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Not Found */
|
/** @description Not Found */
|
||||||
@@ -8224,7 +8247,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Conflict */
|
/** @description Conflict */
|
||||||
@@ -8233,7 +8256,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Unprocessable Content */
|
/** @description Unprocessable Content */
|
||||||
@@ -8242,7 +8265,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Internal Server Error */
|
/** @description Internal Server Error */
|
||||||
@@ -8251,7 +8274,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -8273,7 +8296,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["ProjectActivityResponse"][];
|
"application/json": components["schemas"]["ProjectActivityListEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description 401 */
|
/** @description 401 */
|
||||||
@@ -8282,7 +8305,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description 403 */
|
/** @description 403 */
|
||||||
@@ -8291,7 +8314,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description 404 */
|
/** @description 404 */
|
||||||
@@ -8300,7 +8323,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description 500 */
|
/** @description 500 */
|
||||||
@@ -8309,7 +8332,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -8337,7 +8360,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["ProjectActivityResponse"];
|
"application/json": components["schemas"]["ProjectActivityResponseEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description 400 */
|
/** @description 400 */
|
||||||
@@ -8346,7 +8369,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description 401 */
|
/** @description 401 */
|
||||||
@@ -8355,7 +8378,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description 403 */
|
/** @description 403 */
|
||||||
@@ -8364,7 +8387,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description 404 */
|
/** @description 404 */
|
||||||
@@ -8373,7 +8396,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description 409 */
|
/** @description 409 */
|
||||||
@@ -8382,7 +8405,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description 422 */
|
/** @description 422 */
|
||||||
@@ -8391,7 +8414,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description 500 */
|
/** @description 500 */
|
||||||
@@ -8400,7 +8423,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -8429,7 +8452,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["ProjectActivityResponse"];
|
"application/json": components["schemas"]["ProjectActivityResponseEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description 400 */
|
/** @description 400 */
|
||||||
@@ -8438,7 +8461,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description 401 */
|
/** @description 401 */
|
||||||
@@ -8447,7 +8470,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description 403 */
|
/** @description 403 */
|
||||||
@@ -8456,7 +8479,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description 404 */
|
/** @description 404 */
|
||||||
@@ -8465,7 +8488,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description 409 */
|
/** @description 409 */
|
||||||
@@ -8474,7 +8497,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description 422 */
|
/** @description 422 */
|
||||||
@@ -8483,7 +8506,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description 500 */
|
/** @description 500 */
|
||||||
@@ -8492,7 +8515,97 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
deleteProjectActivity: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header: {
|
||||||
|
"X-CSRF-TOKEN": components["parameters"]["CsrfToken"];
|
||||||
|
};
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
activityId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ExpectedVersionRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description No Content */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Bad Request */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not Found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Conflict */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unprocessable Content */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Internal Server Error */
|
||||||
|
500: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3015,49 +3015,49 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/PublishResponse'
|
$ref: '#/components/schemas/PublishResponseEnvelope'
|
||||||
'400':
|
'400':
|
||||||
description: Bad Request
|
description: Bad Request
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'401':
|
'401':
|
||||||
description: Unauthorized
|
description: Unauthorized
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'403':
|
'403':
|
||||||
description: Forbidden
|
description: Forbidden
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'404':
|
'404':
|
||||||
description: Not Found
|
description: Not Found
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'409':
|
'409':
|
||||||
description: Conflict
|
description: Conflict
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'422':
|
'422':
|
||||||
description: Unprocessable Content
|
description: Unprocessable Content
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'500':
|
'500':
|
||||||
description: Internal Server Error
|
description: Internal Server Error
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -3085,49 +3085,49 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProjectEditResponse'
|
$ref: '#/components/schemas/ProjectEditResponseEnvelope'
|
||||||
'400':
|
'400':
|
||||||
description: Bad Request
|
description: Bad Request
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'401':
|
'401':
|
||||||
description: Unauthorized
|
description: Unauthorized
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'403':
|
'403':
|
||||||
description: Forbidden
|
description: Forbidden
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'404':
|
'404':
|
||||||
description: Not Found
|
description: Not Found
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'409':
|
'409':
|
||||||
description: Conflict
|
description: Conflict
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'422':
|
'422':
|
||||||
description: Unprocessable Content
|
description: Unprocessable Content
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'500':
|
'500':
|
||||||
description: Internal Server Error
|
description: Internal Server Error
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -4973,7 +4973,7 @@ paths:
|
|||||||
$ref: '#/components/schemas/ExpectedVersionRequest'
|
$ref: '#/components/schemas/ExpectedVersionRequest'
|
||||||
security:
|
security:
|
||||||
- sessionCookie: []
|
- sessionCookie: []
|
||||||
/api/v1/studio/home-focus:
|
/api/v1/studio/home/focus:
|
||||||
get:
|
get:
|
||||||
operationId: getHomeFocus
|
operationId: getHomeFocus
|
||||||
tags:
|
tags:
|
||||||
@@ -4985,37 +4985,37 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/HomeFocusResponse'
|
$ref: '#/components/schemas/HomeFocusResponseEnvelope'
|
||||||
'400':
|
'400':
|
||||||
description: Bad Request
|
description: Bad Request
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'401':
|
'401':
|
||||||
description: Unauthorized
|
description: Unauthorized
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'403':
|
'403':
|
||||||
description: Forbidden
|
description: Forbidden
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'404':
|
'404':
|
||||||
description: Not Found
|
description: Not Found
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'500':
|
'500':
|
||||||
description: Internal Server Error
|
description: Internal Server Error
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
security:
|
security:
|
||||||
- sessionCookie: []
|
- sessionCookie: []
|
||||||
put:
|
put:
|
||||||
@@ -5030,49 +5030,49 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/HomeFocusResponse'
|
$ref: '#/components/schemas/HomeFocusResponseEnvelope'
|
||||||
'400':
|
'400':
|
||||||
description: Bad Request
|
description: Bad Request
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'401':
|
'401':
|
||||||
description: Unauthorized
|
description: Unauthorized
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'403':
|
'403':
|
||||||
description: Forbidden
|
description: Forbidden
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'404':
|
'404':
|
||||||
description: Not Found
|
description: Not Found
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'409':
|
'409':
|
||||||
description: Conflict
|
description: Conflict
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'422':
|
'422':
|
||||||
description: Unprocessable Content
|
description: Unprocessable Content
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'500':
|
'500':
|
||||||
description: Internal Server Error
|
description: Internal Server Error
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -5101,33 +5101,31 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: array
|
$ref: '#/components/schemas/ProjectActivityListEnvelope'
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/ProjectActivityResponse'
|
|
||||||
'401':
|
'401':
|
||||||
description: '401'
|
description: '401'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'403':
|
'403':
|
||||||
description: '403'
|
description: '403'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'404':
|
'404':
|
||||||
description: '404'
|
description: '404'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'500':
|
'500':
|
||||||
description: '500'
|
description: '500'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
post:
|
post:
|
||||||
operationId: createProjectActivity
|
operationId: createProjectActivity
|
||||||
tags:
|
tags:
|
||||||
@@ -5154,49 +5152,49 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProjectActivityResponse'
|
$ref: '#/components/schemas/ProjectActivityResponseEnvelope'
|
||||||
'400':
|
'400':
|
||||||
description: '400'
|
description: '400'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'401':
|
'401':
|
||||||
description: '401'
|
description: '401'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'403':
|
'403':
|
||||||
description: '403'
|
description: '403'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'404':
|
'404':
|
||||||
description: '404'
|
description: '404'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'409':
|
'409':
|
||||||
description: '409'
|
description: '409'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'422':
|
'422':
|
||||||
description: '422'
|
description: '422'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'500':
|
'500':
|
||||||
description: '500'
|
description: '500'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
/api/v1/studio/projects/{id}/activities/{activityId}:
|
/api/v1/studio/projects/{id}/activities/{activityId}:
|
||||||
put:
|
put:
|
||||||
operationId: updateProjectActivity
|
operationId: updateProjectActivity
|
||||||
@@ -5230,49 +5228,126 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProjectActivityResponse'
|
$ref: '#/components/schemas/ProjectActivityResponseEnvelope'
|
||||||
'400':
|
'400':
|
||||||
description: '400'
|
description: '400'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'401':
|
'401':
|
||||||
description: '401'
|
description: '401'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'403':
|
'403':
|
||||||
description: '403'
|
description: '403'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'404':
|
'404':
|
||||||
description: '404'
|
description: '404'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'409':
|
'409':
|
||||||
description: '409'
|
description: '409'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'422':
|
'422':
|
||||||
description: '422'
|
description: '422'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'500':
|
'500':
|
||||||
description: '500'
|
description: '500'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
|
delete:
|
||||||
|
operationId: deleteProjectActivity
|
||||||
|
tags:
|
||||||
|
- Projects
|
||||||
|
description: |-
|
||||||
|
프로젝트 활동 한 줄을 지운다. 계약에는 만들기와 고치기만 있었고 지우기가 없어서, 잘못
|
||||||
|
적은 줄이 공개 타임라인에 영구히 남았다.
|
||||||
|
|
||||||
|
`AUTO` 로 기록된 줄은 게시 같은 실제 사건의 흔적이므로 지우지 않는다 — 지우면 무슨 일이
|
||||||
|
있었는지가 사라진다. 손으로 적은 `MANUAL` 줄만 지울 수 있다.
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
- name: activityId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
- $ref: '#/components/parameters/CsrfToken'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ExpectedVersionRequest'
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: No Content
|
||||||
|
'400':
|
||||||
|
description: Bad Request
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
|
'401':
|
||||||
|
description: Unauthorized
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
|
'403':
|
||||||
|
description: Forbidden
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
|
'404':
|
||||||
|
description: Not Found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
|
'409':
|
||||||
|
description: Conflict
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
|
'422':
|
||||||
|
description: Unprocessable Content
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
|
'500':
|
||||||
|
description: Internal Server Error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
|
security:
|
||||||
|
- sessionCookie: []
|
||||||
components:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
sessionCookie:
|
sessionCookie:
|
||||||
@@ -5510,6 +5585,53 @@ components:
|
|||||||
$ref: '#/components/schemas/ReleaseEditResponse'
|
$ref: '#/components/schemas/ReleaseEditResponse'
|
||||||
meta:
|
meta:
|
||||||
$ref: '#/components/schemas/ResponseMeta'
|
$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'
|
||||||
|
ProjectActivityResponseEnvelope:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- success
|
||||||
|
- data
|
||||||
|
- meta
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
const: true
|
||||||
|
data:
|
||||||
|
$ref: '#/components/schemas/ProjectActivityResponse'
|
||||||
|
meta:
|
||||||
|
$ref: '#/components/schemas/ResponseMeta'
|
||||||
|
ProjectActivityListEnvelope:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- success
|
||||||
|
- data
|
||||||
|
- meta
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
const: true
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ProjectActivityResponse'
|
||||||
|
meta:
|
||||||
|
$ref: '#/components/schemas/ResponseMeta'
|
||||||
PublishResponseEnvelope:
|
PublishResponseEnvelope:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"packageId": "@tech-log/public-contract",
|
"packageId": "@tech-log/public-contract",
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"digest": "sha256:37e6f804165de3e492e975075bea563ee41ae74076222a3562d3452631bfdb2b",
|
"digest": "sha256:7eb668e39e279e49767306dd36e1dd51302071c39d78495d21307bbd9676220e",
|
||||||
"sourceRevision": "b195b29",
|
"sourceRevision": "ef49d3a",
|
||||||
"operationIds": [
|
"operationIds": [
|
||||||
"getPublicSite",
|
"getPublicSite",
|
||||||
"getPublicHome",
|
"getPublicHome",
|
||||||
|
|||||||
@@ -454,7 +454,7 @@ export interface components {
|
|||||||
};
|
};
|
||||||
LatestEntry: {
|
LatestEntry: {
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
entryType: "CASE" | "REFERENCE" | "PROJECT_ACTIVITY" | "RELEASE";
|
entryType: "CASE" | "REFERENCE" | "QUESTION" | "PROJECT_ACTIVITY" | "RELEASE";
|
||||||
title: string;
|
title: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -536,6 +536,7 @@ export interface components {
|
|||||||
indexable: boolean;
|
indexable: boolean;
|
||||||
case: {
|
case: {
|
||||||
title: string;
|
title: string;
|
||||||
|
summary?: string;
|
||||||
problemSummary: string;
|
problemSummary: string;
|
||||||
conclusionSummary: string;
|
conclusionSummary: string;
|
||||||
environmentSummary?: string[];
|
environmentSummary?: string[];
|
||||||
@@ -571,9 +572,15 @@ export interface components {
|
|||||||
indexable: boolean;
|
indexable: boolean;
|
||||||
reference: {
|
reference: {
|
||||||
title: string;
|
title: string;
|
||||||
|
summary?: string;
|
||||||
scopeSummary: string;
|
scopeSummary: string;
|
||||||
appliesTo: string[];
|
appliesTo: string[];
|
||||||
excludedScope: string[];
|
excludedScope: string[];
|
||||||
|
rules?: {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
}[];
|
||||||
|
examples?: string[];
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
freshnessStatus: "CURRENT" | "REVIEW_DUE" | "HISTORICAL";
|
freshnessStatus: "CURRENT" | "REVIEW_DUE" | "HISTORICAL";
|
||||||
content: string;
|
content: string;
|
||||||
@@ -670,6 +677,9 @@ export interface components {
|
|||||||
nextStep?: string;
|
nextStep?: string;
|
||||||
systemOverviewMarkdown?: string;
|
systemOverviewMarkdown?: string;
|
||||||
technologies?: string[];
|
technologies?: string[];
|
||||||
|
/** @description 이 프로젝트가 다루는 주제. 프로젝트 화면의 "주요 주제" 가 이 목록을 그린다.
|
||||||
|
* 화면은 처음부터 이 값을 읽고 있었지만 계약에 자리가 없어 늘 비어 있었다. */
|
||||||
|
topics?: components["schemas"]["TopicSummary"][];
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1022,6 +1022,7 @@ components:
|
|||||||
enum:
|
enum:
|
||||||
- CASE
|
- CASE
|
||||||
- REFERENCE
|
- REFERENCE
|
||||||
|
- QUESTION
|
||||||
- PROJECT_ACTIVITY
|
- PROJECT_ACTIVITY
|
||||||
- RELEASE
|
- RELEASE
|
||||||
title:
|
title:
|
||||||
@@ -1239,6 +1240,12 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
title:
|
title:
|
||||||
type: string
|
type: string
|
||||||
|
# 문서가 스스로 밝히는 한 줄 요약이다. 제목 바로 아래에 온다.
|
||||||
|
#
|
||||||
|
# 이 자리가 없어서 화면은 problemSummary / scopeSummary 를 대신 썼고, 그러면 머리말이
|
||||||
|
# 바로 아래의 "문제" 나 "이 기준을 쓰는 이유" 와 같은 글을 두 번 말한다.
|
||||||
|
summary:
|
||||||
|
type: string
|
||||||
problemSummary:
|
problemSummary:
|
||||||
type: string
|
type: string
|
||||||
conclusionSummary:
|
conclusionSummary:
|
||||||
@@ -1325,6 +1332,12 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
title:
|
title:
|
||||||
type: string
|
type: string
|
||||||
|
# 문서가 스스로 밝히는 한 줄 요약이다. 제목 바로 아래에 온다.
|
||||||
|
#
|
||||||
|
# 이 자리가 없어서 화면은 problemSummary / scopeSummary 를 대신 썼고, 그러면 머리말이
|
||||||
|
# 바로 아래의 "문제" 나 "이 기준을 쓰는 이유" 와 같은 글을 두 번 말한다.
|
||||||
|
summary:
|
||||||
|
type: string
|
||||||
scopeSummary:
|
scopeSummary:
|
||||||
type: string
|
type: string
|
||||||
appliesTo:
|
appliesTo:
|
||||||
@@ -1335,6 +1348,23 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
|
# Reference 의 본문은 `content` 마크다운이 아니라 이 두 칸에 있다. Studio 의 Reference
|
||||||
|
# 편집기는 규칙(제목+본문)과 예시를 따로 받고 body_markdown 은 비워 두므로, 이것을
|
||||||
|
# 내보내지 않으면 공개 화면에 판단 기준과 예시가 통째로 빠진다.
|
||||||
|
rules:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required: [title, body]
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
body:
|
||||||
|
type: string
|
||||||
|
examples:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
freshnessStatus:
|
freshnessStatus:
|
||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
@@ -1572,6 +1602,13 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
|
topics:
|
||||||
|
type: array
|
||||||
|
description: |-
|
||||||
|
이 프로젝트가 다루는 주제. 프로젝트 화면의 "주요 주제" 가 이 목록을 그린다.
|
||||||
|
화면은 처음부터 이 값을 읽고 있었지만 계약에 자리가 없어 늘 비어 있었다.
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/TopicSummary'
|
||||||
updatedAt: *id003
|
updatedAt: *id003
|
||||||
featuredDecision:
|
featuredDecision:
|
||||||
$ref: '#/components/schemas/RelatedEntry'
|
$ref: '#/components/schemas/RelatedEntry'
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"packageId": "@tech-log/studio-contract",
|
"packageId": "@tech-log/studio-contract",
|
||||||
"version": "3.0.0",
|
"version": "3.1.0",
|
||||||
"digest": "sha256:674327a82951fd4a1bc2594c858072dfcd0b9abe7c63198283da5d8c92a04326",
|
"digest": "sha256:18dd46898be64b07f7e826409d19347512613ee2e22420028a4a0644f50f37dd",
|
||||||
"sourceRevision": "b195b29",
|
"sourceRevision": "ef49d3a",
|
||||||
"operationIds": [
|
"operationIds": [
|
||||||
"getStudioSession",
|
"getStudioSession",
|
||||||
"getStudioDashboard",
|
"getStudioDashboard",
|
||||||
|
|||||||
@@ -1074,7 +1074,32 @@ export interface components {
|
|||||||
height: number | null;
|
height: number | null;
|
||||||
decorative: boolean;
|
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 `` 로 쓴 그림이다.
|
||||||
|
*
|
||||||
|
* `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"] & {
|
CasePublicRenderModel: components["schemas"]["PublicRenderModelBase"] & {
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
kind: "CASE";
|
kind: "CASE";
|
||||||
@@ -1142,7 +1167,7 @@ export interface components {
|
|||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
status: "PROPOSED" | "ADOPTED";
|
status: "PROPOSED" | "ADOPTED";
|
||||||
/** Format: date */
|
/** Format: date */
|
||||||
decidedOn: string;
|
decidedOn: string | null;
|
||||||
statement: string;
|
statement: string;
|
||||||
rationale: string;
|
rationale: string;
|
||||||
consequences: components["schemas"]["OrderedText"][];
|
consequences: components["schemas"]["OrderedText"][];
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
openapi: 3.1.0
|
openapi: 3.1.0
|
||||||
info:
|
info:
|
||||||
title: Tech Log Studio API
|
title: Tech Log Studio API
|
||||||
version: 3.0.0
|
version: 3.1.0
|
||||||
description: |
|
description: |
|
||||||
Tech Log Studio orchestration 계약이다.
|
Tech Log Studio orchestration 계약이다.
|
||||||
|
|
||||||
@@ -1321,7 +1321,10 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
type: { type: string, enum: [HEADING] }
|
type: { type: string, enum: [HEADING] }
|
||||||
id: { type: string, minLength: 1, maxLength: 200 }
|
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" } }
|
content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
|
||||||
ParagraphBlock:
|
ParagraphBlock:
|
||||||
type: object
|
type: object
|
||||||
@@ -1452,6 +1455,29 @@ components:
|
|||||||
width: { type: [integer, "null"], minimum: 1 }
|
width: { type: [integer, "null"], minimum: 1 }
|
||||||
height: { type: [integer, "null"], minimum: 1 }
|
height: { type: [integer, "null"], minimum: 1 }
|
||||||
decorative: { type: boolean }
|
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: |
|
||||||
|
`` 로 쓴 그림이다.
|
||||||
|
|
||||||
|
`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:
|
CaseRenderBlock:
|
||||||
oneOf:
|
oneOf:
|
||||||
- { $ref: "#/components/schemas/HeadingBlock" }
|
- { $ref: "#/components/schemas/HeadingBlock" }
|
||||||
@@ -1463,6 +1489,8 @@ components:
|
|||||||
- { $ref: "#/components/schemas/DataTableBlock" }
|
- { $ref: "#/components/schemas/DataTableBlock" }
|
||||||
- { $ref: "#/components/schemas/CalloutBlock" }
|
- { $ref: "#/components/schemas/CalloutBlock" }
|
||||||
- { $ref: "#/components/schemas/EvidenceFigureBlock" }
|
- { $ref: "#/components/schemas/EvidenceFigureBlock" }
|
||||||
|
- { $ref: "#/components/schemas/ThematicBreakBlock" }
|
||||||
|
- { $ref: "#/components/schemas/ImageBlock" }
|
||||||
discriminator:
|
discriminator:
|
||||||
propertyName: type
|
propertyName: type
|
||||||
mapping:
|
mapping:
|
||||||
@@ -1475,6 +1503,8 @@ components:
|
|||||||
DATA_TABLE: "#/components/schemas/DataTableBlock"
|
DATA_TABLE: "#/components/schemas/DataTableBlock"
|
||||||
CALLOUT: "#/components/schemas/CalloutBlock"
|
CALLOUT: "#/components/schemas/CalloutBlock"
|
||||||
EVIDENCE_FIGURE: "#/components/schemas/EvidenceFigureBlock"
|
EVIDENCE_FIGURE: "#/components/schemas/EvidenceFigureBlock"
|
||||||
|
THEMATIC_BREAK: "#/components/schemas/ThematicBreakBlock"
|
||||||
|
IMAGE: "#/components/schemas/ImageBlock"
|
||||||
CasePublicRenderModel:
|
CasePublicRenderModel:
|
||||||
unevaluatedProperties: false
|
unevaluatedProperties: false
|
||||||
allOf:
|
allOf:
|
||||||
@@ -1542,7 +1572,10 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
kind: { type: string, enum: [PROJECT_DECISION] }
|
kind: { type: string, enum: [PROJECT_DECISION] }
|
||||||
status: { type: string, enum: [PROPOSED, ADOPTED] }
|
status: { type: string, enum: [PROPOSED, ADOPTED] }
|
||||||
decidedOn: { type: string, format: date }
|
# 결정일은 비어 있을 수 있다. 검증은 이것을 경고로만 다루므로(DECIDED_ON_REQUIRED)
|
||||||
|
# 날짜 없이 게시할 수 있는데, 렌더 모델이 필수로 요구하면 그 문서는 미리보기조차
|
||||||
|
# 열리지 않는다 — 두 규칙이 어긋나면 작성자는 "경고라며 왜 안 되냐"를 만난다.
|
||||||
|
decidedOn: { type: [string, "null"], format: date }
|
||||||
statement: { type: string, maxLength: 100000 }
|
statement: { type: string, maxLength: 100000 }
|
||||||
rationale: { type: string, maxLength: 100000 }
|
rationale: { type: string, maxLength: 100000 }
|
||||||
consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
|
consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
|
||||||
|
|||||||
@@ -310,6 +310,103 @@ const HTTP_CONTRACTS = Object.freeze([
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
readOperation("listStudioProjectActivities", `${P}/{id}/activities`, 262_144, byId),
|
||||||
|
writeOperation(
|
||||||
|
"createProjectActivity",
|
||||||
|
"POST",
|
||||||
|
`${P}/{id}/activities`,
|
||||||
|
{ acceptedStatuses: [201], requestByteLimit: 16_384, responseByteLimit: 16_384 },
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{ id: string; body: unknown }>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ id: value.id }),
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
body: value.body,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
writeOperation(
|
||||||
|
"updateProjectActivity",
|
||||||
|
"PUT",
|
||||||
|
`${P}/{id}/activities/{activityId}`,
|
||||||
|
{ acceptedStatuses: [200], requestByteLimit: 16_384, responseByteLimit: 16_384 },
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{
|
||||||
|
id: string;
|
||||||
|
activityId: string;
|
||||||
|
body: unknown;
|
||||||
|
}>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ id: value.id, activityId: value.activityId }),
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
body: value.body,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
writeOperation(
|
||||||
|
"deleteProjectActivity",
|
||||||
|
"DELETE",
|
||||||
|
`${P}/{id}/activities/{activityId}`,
|
||||||
|
{
|
||||||
|
acceptedStatuses: [204],
|
||||||
|
emptyBodyStatuses: [204],
|
||||||
|
requestByteLimit: 1_024,
|
||||||
|
responseByteLimit: 1_024,
|
||||||
|
},
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{
|
||||||
|
id: string;
|
||||||
|
activityId: string;
|
||||||
|
expectedVersion: number;
|
||||||
|
}>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ id: value.id, activityId: value.activityId }),
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
body: { expectedVersion: value.expectedVersion },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
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(
|
writeOperation(
|
||||||
"publishRelease",
|
"publishRelease",
|
||||||
"POST",
|
"POST",
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ const TECH_LOG_ROUTE_SPECS = [
|
|||||||
defineSpec({ routeId: "TECH_LOG_STUDIO_PUBLICATION_PREVIEW", path: "/studio/publications/:publicationEventId/preview", layoutGroup: "STUDIO", paramsSchema: "TechLogPublicationEventIdParams", searchSchema: null, title: "게시 Snapshot", navigationLabel: null, navigationOrder: null }),
|
defineSpec({ routeId: "TECH_LOG_STUDIO_PUBLICATION_PREVIEW", path: "/studio/publications/:publicationEventId/preview", layoutGroup: "STUDIO", paramsSchema: "TechLogPublicationEventIdParams", searchSchema: null, title: "게시 Snapshot", navigationLabel: null, navigationOrder: null }),
|
||||||
defineSpec({ routeId: "TECH_LOG_STUDIO_ASSETS", path: "/studio/assets", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "Asset", navigationLabel: null, navigationOrder: null }),
|
defineSpec({ routeId: "TECH_LOG_STUDIO_ASSETS", path: "/studio/assets", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "Asset", navigationLabel: null, navigationOrder: null }),
|
||||||
defineSpec({ routeId: "TECH_LOG_STUDIO_TAXONOMY", path: "/studio/taxonomy", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "주제와 프로젝트", navigationLabel: "주제·프로젝트", navigationOrder: 40 }),
|
defineSpec({ routeId: "TECH_LOG_STUDIO_TAXONOMY", path: "/studio/taxonomy", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "주제와 프로젝트", navigationLabel: "주제·프로젝트", navigationOrder: 40 }),
|
||||||
|
defineSpec({ routeId: "TECH_LOG_STUDIO_PROJECT_EDIT", path: "/studio/projects/:id", layoutGroup: "STUDIO", paramsSchema: "TechLogDocumentIdParams", searchSchema: null, title: "프로젝트 편집", navigationLabel: null, navigationOrder: null }),
|
||||||
defineSpec({ routeId: "TECH_LOG_STUDIO_RELEASES", path: "/studio/releases", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "릴리즈", navigationLabel: "릴리즈", navigationOrder: 50 }),
|
defineSpec({ routeId: "TECH_LOG_STUDIO_RELEASES", path: "/studio/releases", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "릴리즈", navigationLabel: "릴리즈", navigationOrder: 50 }),
|
||||||
|
defineSpec({ routeId: "TECH_LOG_STUDIO_RELEASE_EDIT", path: "/studio/releases/:id", layoutGroup: "STUDIO", paramsSchema: "TechLogDocumentIdParams", searchSchema: null, title: "릴리즈 편집", navigationLabel: null, navigationOrder: null }),
|
||||||
defineSpec({ routeId: "TECH_LOG_STUDIO_NOT_FOUND", path: "/studio/*", layoutGroup: "STUDIO", paramsSchema: "TechLogStudioSplat", searchSchema: null, title: "Studio 화면을 찾을 수 없습니다", navigationLabel: null, navigationOrder: null }),
|
defineSpec({ routeId: "TECH_LOG_STUDIO_NOT_FOUND", path: "/studio/*", layoutGroup: "STUDIO", paramsSchema: "TechLogStudioSplat", searchSchema: null, title: "Studio 화면을 찾을 수 없습니다", navigationLabel: null, navigationOrder: null }),
|
||||||
defineSpec({ routeId: "NOT_FOUND", path: "*", layoutGroup: "PUBLIC", paramsSchema: "NotFoundSplat", searchSchema: null, title: "페이지를 찾을 수 없습니다.", navigationLabel: null, navigationOrder: null }),
|
defineSpec({ routeId: "NOT_FOUND", path: "*", layoutGroup: "PUBLIC", paramsSchema: "NotFoundSplat", searchSchema: null, title: "페이지를 찾을 수 없습니다.", navigationLabel: null, navigationOrder: null }),
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -180,14 +180,27 @@ function normalizeDirectives(source: string): string {
|
|||||||
return line;
|
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(
|
return line.replace(
|
||||||
/^(:::[a-z][a-z0-9-]*)([^\n{][^\n]*)(\n?)$/i,
|
/^(:::[a-z][a-z0-9-]*)(\s+[^\n]*?)(\n?)$/i,
|
||||||
(
|
(
|
||||||
_match,
|
_match,
|
||||||
marker: string,
|
marker: string,
|
||||||
attributes: string,
|
attributes: string,
|
||||||
newline: string,
|
newline: string,
|
||||||
) => `${marker}{${attributes.trim()}}${newline}`,
|
) => {
|
||||||
|
const trimmed = attributes.trim();
|
||||||
|
return trimmed ? `${marker}{${trimmed}}${newline}` : `${marker}${newline}`;
|
||||||
|
},
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
@@ -199,6 +212,14 @@ function assertNever(value: never): never {
|
|||||||
|
|
||||||
const trustedRelativeLinkOrigin = "https://techlog.invalid";
|
const trustedRelativeLinkOrigin = "https://techlog.invalid";
|
||||||
|
|
||||||
|
/** 서버가 아는 callout 이름과 화면에 붙일 말. 이름이 tone 을 겸하므로 속성을 받지 않는다. */
|
||||||
|
const CALLOUT_LABELS: Readonly<Record<string, string>> = {
|
||||||
|
note: "참고",
|
||||||
|
tip: "도움말",
|
||||||
|
warning: "주의",
|
||||||
|
danger: "위험",
|
||||||
|
};
|
||||||
|
|
||||||
function hasAsciiControlCharacter(value: string): boolean {
|
function hasAsciiControlCharacter(value: string): boolean {
|
||||||
return Array.from(value).some((character) => {
|
return Array.from(value).some((character) => {
|
||||||
const codePoint = character.codePointAt(0) ?? 0;
|
const codePoint = character.codePointAt(0) ?? 0;
|
||||||
@@ -326,8 +347,8 @@ function headingBlock(
|
|||||||
node: Heading,
|
node: Heading,
|
||||||
usedIds: Set<string>,
|
usedIds: Set<string>,
|
||||||
): components["schemas"]["HeadingBlock"] {
|
): components["schemas"]["HeadingBlock"] {
|
||||||
if (node.depth < 2 || node.depth > 4) {
|
if (node.depth < 1 || node.depth > 6) {
|
||||||
invalid(node, "only heading levels 2 through 4 are supported");
|
invalid(node, "only heading levels 1 through 6 are supported");
|
||||||
}
|
}
|
||||||
|
|
||||||
const children = [...node.children];
|
const children = [...node.children];
|
||||||
@@ -417,15 +438,34 @@ function tableCellContent(cell: TableCell): Inline[] {
|
|||||||
return inlineFromNodes(cell.children);
|
return inlineFromNodes(cell.children);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `:::table` 없이 쓴 GFM 표에 붙일 값.
|
||||||
|
*
|
||||||
|
* <p>서버 렌더러는 파이프 표를 그대로 읽는다 — `:::table` 이라는 directive 자체를 모른다.
|
||||||
|
* 여기서만 감싸기를 요구하면 같은 본문이 Studio 와 공개 화면에서 다르게 읽히므로, 감싸지 않은
|
||||||
|
* 표도 받는다. `id` 는 자리 순서로 만들고 `caption` 은 비운다. 설명이 필요하면 `:::table` 로
|
||||||
|
* 감싸 `caption` 을 주면 된다.
|
||||||
|
*/
|
||||||
|
function bareTableAttributes(index: number): Record<string, string> {
|
||||||
|
return { id: `table-${index}`, caption: "", rowHeaderColumn: "none" };
|
||||||
|
}
|
||||||
|
|
||||||
function tableBlock(
|
function tableBlock(
|
||||||
node: ContainerDirective,
|
node: ContainerDirective | Table,
|
||||||
usedIds: Set<string>,
|
usedIds: Set<string>,
|
||||||
|
bareIndex?: number,
|
||||||
): components["schemas"]["DataTableBlock"] {
|
): components["schemas"]["DataTableBlock"] {
|
||||||
const attributes = attributesOf(node, ["id", "caption", "rowHeaderColumn"]);
|
const bare = node.type === "table";
|
||||||
if (node.children.length !== 1 || node.children[0].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");
|
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");
|
if (table.children.length === 0) invalid(table, "table header is required");
|
||||||
|
|
||||||
const id = attributes.id;
|
const id = attributes.id;
|
||||||
@@ -506,6 +546,25 @@ function directiveBlock(
|
|||||||
content: inlineFromNodes((node.children[0] as Paragraph).children),
|
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": {
|
case "evidence": {
|
||||||
const attributes = attributesOf(node, ["key", "alt", "caption", "zoom"]);
|
const attributes = attributesOf(node, ["key", "alt", "caption", "zoom"]);
|
||||||
if (node.children.length !== 0) {
|
if (node.children.length !== 0) {
|
||||||
@@ -533,10 +592,34 @@ function directiveBlock(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 문단 하나에 그림만 있으면 그림 블록으로 읽는다.
|
||||||
|
*
|
||||||
|
* <p>Markdown 에서 `` 는 문단 안의 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(
|
function paragraphBlock(
|
||||||
node: Paragraph,
|
node: Paragraph,
|
||||||
): components["schemas"]["ParagraphBlock"] {
|
): components["schemas"]["ParagraphBlock"] | components["schemas"]["ImageBlock"] {
|
||||||
return { type: "PARAGRAPH", content: inlineFromNodes(node.children) };
|
return imageBlockOf(node) ?? { type: "PARAGRAPH", content: inlineFromNodes(node.children) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseCaseContent(source: string): CaseAuthoringBlock[] {
|
export function parseCaseContent(source: string): CaseAuthoringBlock[] {
|
||||||
@@ -564,6 +647,7 @@ export function parseCaseContent(source: string): CaseAuthoringBlock[] {
|
|||||||
listItemCount += 1;
|
listItemCount += 1;
|
||||||
return `list-item-${listItemCount}`;
|
return `list-item-${listItemCount}`;
|
||||||
};
|
};
|
||||||
|
let bareTableCount = 0;
|
||||||
|
|
||||||
return tree.children.map((node: Content): CaseAuthoringBlock => {
|
return tree.children.map((node: Content): CaseAuthoringBlock => {
|
||||||
switch (node.type) {
|
switch (node.type) {
|
||||||
@@ -585,11 +669,17 @@ export function parseCaseContent(source: string): CaseAuthoringBlock[] {
|
|||||||
return codeBlock(node);
|
return codeBlock(node);
|
||||||
case "containerDirective":
|
case "containerDirective":
|
||||||
return directiveBlock(node, usedIds);
|
return directiveBlock(node, usedIds);
|
||||||
case "html":
|
case "table": {
|
||||||
|
// 서버 렌더러는 파이프 표를 그대로 읽는다. 여기서 거절하면 같은 본문이 Studio 와
|
||||||
|
// 공개 화면에서 다르게 읽힌다.
|
||||||
|
bareTableCount += 1;
|
||||||
|
return tableBlock(node, usedIds, bareTableCount);
|
||||||
|
}
|
||||||
case "thematicBreak":
|
case "thematicBreak":
|
||||||
|
return { type: "THEMATIC_BREAK" };
|
||||||
|
case "html":
|
||||||
case "definition":
|
case "definition":
|
||||||
case "yaml":
|
case "yaml":
|
||||||
case "table":
|
|
||||||
case "footnoteDefinition":
|
case "footnoteDefinition":
|
||||||
case "leafDirective":
|
case "leafDirective":
|
||||||
return invalid(node, `unsupported block syntax: ${node.type}`);
|
return invalid(node, `unsupported block syntax: ${node.type}`);
|
||||||
|
|||||||
@@ -131,7 +131,18 @@ function renderContext(
|
|||||||
|
|
||||||
function publicPath(input: WorkingCopyInput, project: CatalogEntry | null) {
|
function publicPath(input: WorkingCopyInput, project: CatalogEntry | null) {
|
||||||
if (input.kind === "PROJECT_DECISION") {
|
if (input.kind === "PROJECT_DECISION") {
|
||||||
if (!project?.publicPath) fail("PROJECT public path is required");
|
/*
|
||||||
|
Decision 의 공개 주소는 자기 slug 가 아니라 프로젝트 주소 아래에 있다. 그래서 프로젝트가
|
||||||
|
공개되어 있지 않으면 이 문서에는 아직 주소가 없다.
|
||||||
|
|
||||||
|
예전 문구는 "PROJECT public path is required" 였다. 사실이지만 작성자가 할 일을 말해 주지
|
||||||
|
않는다 — 무엇을 어디서 눌러야 하는지 적는다.
|
||||||
|
*/
|
||||||
|
if (!project?.publicPath) {
|
||||||
|
fail(
|
||||||
|
"이 Decision 의 공개 주소는 프로젝트 주소 아래에 있습니다. 주제·프로젝트 화면에서 이 프로젝트를 먼저 게시해 주세요.",
|
||||||
|
);
|
||||||
|
}
|
||||||
return `${project.publicPath}/decisions#${input.slug}`;
|
return `${project.publicPath}/decisions#${input.slug}`;
|
||||||
}
|
}
|
||||||
const prefix =
|
const prefix =
|
||||||
@@ -186,7 +197,7 @@ export function projectWorkingCopy(
|
|||||||
const project = catalogEntry(catalog, input.projectId, "PROJECT", false);
|
const project = catalogEntry(catalog, input.projectId, "PROJECT", false);
|
||||||
if (!topic) fail("TOPIC catalog entry is required");
|
if (!topic) fail("TOPIC catalog entry is required");
|
||||||
if (input.kind === "PROJECT_DECISION" && !project) {
|
if (input.kind === "PROJECT_DECISION" && !project) {
|
||||||
fail("PROJECT catalog entry is required");
|
fail("Decision 은 프로젝트에 속합니다. 기본 정보에서 프로젝트를 골라 주세요.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const base = {
|
const base = {
|
||||||
@@ -273,12 +284,16 @@ export function projectWorkingCopy(
|
|||||||
|
|
||||||
case "PROJECT_DECISION":
|
case "PROJECT_DECISION":
|
||||||
if (!input.decisionStatus) fail("Decision status is required");
|
if (!input.decisionStatus) fail("Decision status is required");
|
||||||
if (!input.decidedOn) fail("Decision date is required");
|
/*
|
||||||
|
결정일은 비어 있어도 모델을 만든다. 검증이 그것을 경고로만 다루므로 날짜 없이 게시할 수
|
||||||
|
있는데, 여기서 막으면 그 문서는 미리보기조차 열리지 않는다 — 작성자는 "경고라면서 왜
|
||||||
|
안 되냐"를 만난다. 화면이 "미정"이라고 말하면 된다.
|
||||||
|
*/
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
kind: "PROJECT_DECISION",
|
kind: "PROJECT_DECISION",
|
||||||
status: input.decisionStatus,
|
status: input.decisionStatus,
|
||||||
decidedOn: input.decidedOn,
|
decidedOn: input.decidedOn ?? null,
|
||||||
statement: input.statement,
|
statement: input.statement,
|
||||||
rationale: input.rationale,
|
rationale: input.rationale,
|
||||||
consequences: ordered(input.consequences),
|
consequences: ordered(input.consequences),
|
||||||
|
|||||||
@@ -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))}`,
|
`:::evidence key=${quoteAttribute(block.key)} alt=${quoteAttribute(block.alt)} caption=${quoteAttribute(block.caption)} zoom=${quoteAttribute(String(block.zoom))}`,
|
||||||
":::",
|
":::",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
case "THEMATIC_BREAK":
|
||||||
|
return "---";
|
||||||
|
case "IMAGE":
|
||||||
|
// 제목은 Markdown 이 따옴표로 감싼다. 없으면 붙이지 않아야 다시 읽었을 때 빈 제목이 되지
|
||||||
|
// 않는다.
|
||||||
|
return block.title === null
|
||||||
|
? ``
|
||||||
|
: `}")`;
|
||||||
default:
|
default:
|
||||||
return assertNever(block);
|
return assertNever(block);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,3 +11,28 @@ export function createLocalId(
|
|||||||
const entropy = Math.floor(random() * 1_000_000_000).toString().padStart(9, "0");
|
const entropy = Math.floor(random() * 1_000_000_000).toString().padStart(9, "0");
|
||||||
return `${prefix}-${now()}-${entropy}`;
|
return `${prefix}-${now()}-${entropy}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 편집기가 새로 만든 목록 항목(관계·근거·규칙·선택지·순서 있는 문장)의 id.
|
||||||
|
*
|
||||||
|
* `createLocalId` 와 나눠 두는 이유는 이 값이 화면 밖으로 나가기 때문이다. 그쪽은 접두사를 붙여
|
||||||
|
* `relation-<uuid>` 같은 문자열을 만들고, 그건 React key 나 Idempotency-Key 로는 좋지만 계약에는
|
||||||
|
* 넣을 수 없다 — 계약이 요구하는 형식은 uuid 이고, 접두사가 붙은 값은 서버가 파싱조차 하지 못해
|
||||||
|
* 400 (`InvalidFormatException`) 이 된다. 저장 버튼이 "계약을 어겼다"는 말만 남기고 아무것도 저장하지
|
||||||
|
* 않던 이유가 이것이었다.
|
||||||
|
*
|
||||||
|
* 서버는 자기가 소유하지 않은 id 를 신뢰하지 않고 새로 부여한다({@code StudioRelationStore.replace}).
|
||||||
|
* 그래서 여기서 만드는 값은 "이 줄은 새것"이라는 표시일 뿐, 저장 뒤의 진짜 id 는 서버가 정한다.
|
||||||
|
*/
|
||||||
|
export function createNewItemId(
|
||||||
|
source: RandomUuidSource | null | undefined = globalThis.crypto,
|
||||||
|
random: () => number = Math.random,
|
||||||
|
): string {
|
||||||
|
const uuid = source?.randomUUID?.();
|
||||||
|
if (uuid) return uuid;
|
||||||
|
// randomUUID 가 없는 환경(비보안 컨텍스트)을 위한 대비. 형식만 uuid v4 를 지키면 된다 — 값 자체는
|
||||||
|
// 서버가 어차피 새로 부여한다.
|
||||||
|
const hex = (length: number) =>
|
||||||
|
Array.from({ length }, () => Math.floor(random() * 16).toString(16)).join("");
|
||||||
|
return `${hex(8)}-${hex(4)}-4${hex(3)}-${"89ab"[Math.floor(random() * 4)]}${hex(3)}-${hex(12)}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,7 +37,18 @@ export function ExploreFilterForm({
|
|||||||
);
|
);
|
||||||
const resolved = await Promise.all(projectSlugs.map((slug) => queries.getProject(slug)));
|
const resolved = await Promise.all(projectSlugs.map((slug) => queries.getProject(slug)));
|
||||||
return {
|
return {
|
||||||
topics: [...new Set(records.map((record) => record.topic))].sort(),
|
// 주제는 이름이 아니라 slug 로 거른다 — 프로젝트와 같다. 이름을 실었을 때는
|
||||||
|
// `topic=OAuth/OIDC 인증 경계` 가 나갔고, slug 로 거르는 API 는 0건을 돌려줬다.
|
||||||
|
// 화면에 보일 이름과 보낼 slug 가 다르므로 짝으로 들고 있어야 한다.
|
||||||
|
topics: [
|
||||||
|
...new Map(
|
||||||
|
records
|
||||||
|
.filter((record) => record.topicSlug)
|
||||||
|
.map((record) => [record.topicSlug, record.topic] as const),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.map(([slug, name]) => ({ slug, name }))
|
||||||
|
.sort((left, right) => left.name.localeCompare(right.name, "ko-KR")),
|
||||||
projects: resolved
|
projects: resolved
|
||||||
.filter((item) => item !== undefined)
|
.filter((item) => item !== undefined)
|
||||||
.map((item) => ({ slug: item.slug, title: item.title })),
|
.map((item) => ({ slug: item.slug, title: item.title })),
|
||||||
@@ -47,8 +58,10 @@ export function ExploreFilterForm({
|
|||||||
const projects = view.data?.projects ?? [];
|
const projects = view.data?.projects ?? [];
|
||||||
const normalizedTopic = topic?.toLocaleLowerCase("ko-KR");
|
const normalizedTopic = topic?.toLocaleLowerCase("ko-KR");
|
||||||
const selectedTopic = topics.find(
|
const selectedTopic = topics.find(
|
||||||
(item) => item.toLocaleLowerCase("ko-KR") === normalizedTopic,
|
(item) =>
|
||||||
);
|
item.slug.toLocaleLowerCase("ko-KR") === normalizedTopic ||
|
||||||
|
item.name.toLocaleLowerCase("ko-KR") === normalizedTopic,
|
||||||
|
)?.slug;
|
||||||
const normalizedProject = project?.toLocaleLowerCase("ko-KR");
|
const normalizedProject = project?.toLocaleLowerCase("ko-KR");
|
||||||
const selectedProject = projects.find(
|
const selectedProject = projects.find(
|
||||||
(item) =>
|
(item) =>
|
||||||
@@ -67,7 +80,7 @@ export function ExploreFilterForm({
|
|||||||
const data = new FormData(event.currentTarget);
|
const data = new FormData(event.currentTarget);
|
||||||
const search = new URLSearchParams();
|
const search = new URLSearchParams();
|
||||||
for (const [key, value] of data) {
|
for (const [key, value] of data) {
|
||||||
if (typeof value === "string") search.append(key, value);
|
if (typeof value === "string" && value !== "") search.append(key, value);
|
||||||
}
|
}
|
||||||
void navigate(`${action}?${search.toString()}`);
|
void navigate(`${action}?${search.toString()}`);
|
||||||
}
|
}
|
||||||
@@ -83,7 +96,7 @@ export function ExploreFilterForm({
|
|||||||
{showType ? (
|
{showType ? (
|
||||||
<label><span>유형</span><select name="type" defaultValue={kind ?? ""}><option value="">전체</option><option value="CASE">Case</option><option value="REFERENCE">Reference</option><option value="QUESTION">Open Question</option></select></label>
|
<label><span>유형</span><select name="type" defaultValue={kind ?? ""}><option value="">전체</option><option value="CASE">Case</option><option value="REFERENCE">Reference</option><option value="QUESTION">Open Question</option></select></label>
|
||||||
) : null}
|
) : null}
|
||||||
<label><span>주제</span><select name="topic" defaultValue={selectedTopic ?? ""}><option value="">전체</option>{topics.map((item) => <option key={item}>{item}</option>)}</select></label>
|
<label><span>주제</span><select name="topic" defaultValue={selectedTopic ?? ""}><option value="">전체</option>{topics.map((item) => <option value={item.slug} key={item.slug}>{item.name}</option>)}</select></label>
|
||||||
<label><span>프로젝트</span><select name="project" defaultValue={selectedProject ?? ""}><option value="">전체</option>{projects.map((item) => <option value={item.slug} key={item.slug}>{item.title}</option>)}</select></label>
|
<label><span>프로젝트</span><select name="project" defaultValue={selectedProject ?? ""}><option value="">전체</option>{projects.map((item) => <option value={item.slug} key={item.slug}>{item.title}</option>)}</select></label>
|
||||||
<button type="submit">적용</button>
|
<button type="submit">적용</button>
|
||||||
{hasActiveFilter ? <Link to={action}>필터 초기화</Link> : null}
|
{hasActiveFilter ? <Link to={action}>필터 초기화</Link> : null}
|
||||||
|
|||||||
@@ -19,7 +19,13 @@ export function PublicDocumentHeader({ record }: { record: PublicRecord }) {
|
|||||||
{kindLabels[record.kind]}
|
{kindLabels[record.kind]}
|
||||||
</Link>
|
</Link>
|
||||||
<span aria-hidden="true">/</span>
|
<span aria-hidden="true">/</span>
|
||||||
<Link to={`/topics/${record.topicSlug}`}>{record.topic}</Link>
|
{/*
|
||||||
|
주제 페이지가 아니라 그 주제로 거른 탐색으로 보낸다. `/topics/:slug` 는 세 개를
|
||||||
|
하드코딩해 두고 있어 실제 주제는 무엇이든 404 가 되고, 설명·범위·대표 기록이 전부
|
||||||
|
비어 있어 지금 채울 내용도 없다. 독자가 여기서 기대하는 것 — 같은 주제의 기록 목록 —
|
||||||
|
은 탐색 필터가 그대로 준다.
|
||||||
|
*/}
|
||||||
|
<Link to={`/explore?topic=${encodeURIComponent(record.topicSlug)}`}>{record.topic}</Link>
|
||||||
<span aria-hidden="true">/</span>
|
<span aria-hidden="true">/</span>
|
||||||
<Link to={`/projects/${record.projectSlug}`}>
|
<Link to={`/projects/${record.projectSlug}`}>
|
||||||
{record.projectTitle}
|
{record.projectTitle}
|
||||||
@@ -57,7 +63,9 @@ export function publicRenderModelBase(
|
|||||||
topic: {
|
topic: {
|
||||||
id: `topic-${record.topicSlug}`,
|
id: `topic-${record.topicSlug}`,
|
||||||
label: record.topic,
|
label: record.topic,
|
||||||
publicPath: `/topics/${record.topicSlug}`,
|
// 공개 문서의 머리말이 실제로 그리는 주제 링크는 이 값이다 — 위의 breadcrumb 과 같은
|
||||||
|
// 이유로 탐색 필터를 가리킨다. 둘 중 하나만 고치면 화면에서는 그대로 404 로 간다.
|
||||||
|
publicPath: `/explore?topic=${encodeURIComponent(record.topicSlug)}`,
|
||||||
},
|
},
|
||||||
project: {
|
project: {
|
||||||
id: `project-${record.projectSlug}`,
|
id: `project-${record.projectSlug}`,
|
||||||
|
|||||||
@@ -39,56 +39,43 @@ function optionalString(value: unknown): string | undefined {
|
|||||||
return typeof value === "string" ? value : undefined;
|
return typeof value === "string" ? value : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서버가 고른 최근 기록에 릴리스를 얹는다.
|
||||||
|
*
|
||||||
|
* 예전에는 이 함수가 공개된 프로젝트를 하나씩 돌며 활동을 모아 타임라인을 만들었다. 그래서 게시된
|
||||||
|
* 문서라도 그 문서가 매달린 프로젝트가 공개되어 있지 않으면 홈에서 통째로 사라졌다 — 실제로 Case 를
|
||||||
|
* 게시했는데 홈에는 릴리스 한 줄만 남았다. 무엇이 최근인지는 공개 투영이 이미 알고 있으므로 그것을
|
||||||
|
* 그대로 읽는다. 프로젝트마다 요청을 하나씩 보내던 N+1 도 같이 사라진다.
|
||||||
|
*
|
||||||
|
* 릴리스는 Publication 파이프라인을 거치지 않아 그 투영에 행이 없다. 그래서 릴리스만 따로 읽어
|
||||||
|
* 시간순으로 합친다.
|
||||||
|
*/
|
||||||
|
const latestTypeLabels: Readonly<Record<string, string>> = {
|
||||||
|
PROJECT_ACTIVITY: "PROJECT ACTIVITY",
|
||||||
|
QUESTION: "OPEN QUESTION",
|
||||||
|
};
|
||||||
|
|
||||||
async function getLatestEntries(
|
async function getLatestEntries(
|
||||||
publicContent: PublicContentQueries,
|
publicContent: PublicContentQueries,
|
||||||
): Promise<LatestEntry[]> {
|
): Promise<LatestEntry[]> {
|
||||||
const publicRecords = await publicContent.listRecords();
|
const [records, searchableEntities] = await Promise.all([
|
||||||
const publicRecordByPath = new Map(
|
publicContent.getLatestEntries(),
|
||||||
publicRecords.map((record) => [record.path, record]),
|
publicContent.searchPublicContent(""),
|
||||||
);
|
]);
|
||||||
const searchableEntities = await publicContent.searchPublicContent("");
|
|
||||||
const projectPrefix = "/projects/";
|
const recordTimeline: LatestEntry[] = records.map((entry) => ({
|
||||||
const projectSlugs = searchableEntities
|
id: entry.id,
|
||||||
.filter((entity) => entity.contentType === "PROJECT")
|
// 목록의 다른 이름들과 같은 자리에 놓이므로 표기도 같은 규칙을 쓴다 — 대문자에 공백.
|
||||||
.flatMap((entity) =>
|
typeLabel: latestTypeLabels[entry.entryType] ?? entry.entryType,
|
||||||
entity.path.startsWith(projectPrefix)
|
title: entry.title,
|
||||||
? [decodeURIComponent(entity.path.slice(projectPrefix.length))]
|
summary: entry.summary,
|
||||||
: [],
|
date: dateLabel(entry.publishedAt),
|
||||||
);
|
dateTime: entry.publishedAt,
|
||||||
// One project at a time would serialise a request per project; issuing them
|
topic: entry.topic,
|
||||||
// together keeps the timeline's cost at its slowest project rather than their
|
project: entry.project,
|
||||||
// sum. The flatten below restores the original single-list shape.
|
path: entry.path,
|
||||||
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 releaseTimeline = (
|
const releaseTimeline = (
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
searchableEntities
|
searchableEntities
|
||||||
@@ -117,11 +104,20 @@ async function getLatestEntries(
|
|||||||
)
|
)
|
||||||
).flat();
|
).flat();
|
||||||
|
|
||||||
return [...projectTimeline, ...releaseTimeline].sort((left, right) =>
|
return [...recordTimeline, ...releaseTimeline].sort((left, right) =>
|
||||||
right.dateTime.localeCompare(left.dateTime),
|
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() {
|
export function HomePage() {
|
||||||
const { search } = useRouteInput<"TECH_LOG_HOME">();
|
const { search } = useRouteInput<"TECH_LOG_HOME">();
|
||||||
const requestedKey = optionalString(search.focus);
|
const requestedKey = optionalString(search.focus);
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { Link } from "react-router-dom";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
RegisteredNotFoundRoute,
|
RegisteredNotFoundRoute,
|
||||||
useRouteInput,
|
useRouteInput,
|
||||||
@@ -21,9 +19,19 @@ export function ProjectActivityPage() {
|
|||||||
const { project, activity } = view.data;
|
const { project, activity } = view.data;
|
||||||
if (!project) return <RegisteredNotFoundRoute />;
|
if (!project) return <RegisteredNotFoundRoute />;
|
||||||
|
|
||||||
|
/*
|
||||||
|
활동은 로그다 — 언제 무엇을 올렸는지만 적는다.
|
||||||
|
|
||||||
|
예전에는 각 줄에 그 기록으로 가는 링크가 있었다. 그러면 같은 글에 닿는 길이 둘이 되고,
|
||||||
|
읽는 사람은 "기록"과 "활동"이 어떻게 다른지 매번 다시 판단해야 한다. 글을 읽는 자리는
|
||||||
|
기록 화면 하나로 둔다.
|
||||||
|
*/
|
||||||
return (
|
return (
|
||||||
<main id="main-content" className="shell project-page">
|
<main id="main-content" className="shell project-page">
|
||||||
<ProjectPageHeader project={project} title={`${project.title} 활동`} />
|
<ProjectPageHeader project={project} title={`${project.title} 활동`} />
|
||||||
|
{activity.length === 0 ? (
|
||||||
|
<p className="public-empty-note">아직 이 프로젝트에서 공개한 기록이 없습니다.</p>
|
||||||
|
) : (
|
||||||
<ol className="project-activity-list">
|
<ol className="project-activity-list">
|
||||||
{activity.map((item) => (
|
{activity.map((item) => (
|
||||||
<li key={item.id}>
|
<li key={item.id}>
|
||||||
@@ -33,16 +41,11 @@ export function ProjectActivityPage() {
|
|||||||
<time dateTime={item.dateTime}>{item.date}</time>
|
<time dateTime={item.dateTime}>{item.date}</time>
|
||||||
</div>
|
</div>
|
||||||
<h2>{item.title}</h2>
|
<h2>{item.title}</h2>
|
||||||
<p>{item.summary}</p>
|
|
||||||
<Link to={item.recordPath ?? item.path}>
|
|
||||||
{item.recordPath
|
|
||||||
? "연결된 공개 기록 읽기"
|
|
||||||
: "이 활동 위치 열기"}
|
|
||||||
</Link>
|
|
||||||
</article>
|
</article>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,10 +38,11 @@ export function TopicPage() {
|
|||||||
const topic = topicConfig(params.slug);
|
const topic = topicConfig(params.slug);
|
||||||
// Hooks run unconditionally, so the unknown-topic case is handled by the
|
// Hooks run unconditionally, so the unknown-topic case is handled by the
|
||||||
// loader and the not-found route is chosen after it.
|
// loader and the not-found route is chosen after it.
|
||||||
|
const slug = typeof params.slug === "string" ? params.slug : "";
|
||||||
const view = usePublicContent(
|
const view = usePublicContent(
|
||||||
["tech-log", "topic", topic?.title],
|
["tech-log", "topic", slug],
|
||||||
async (queries) =>
|
async (queries) =>
|
||||||
topic ? { records: await queries.listRecords({ topic: topic.title }) } : { records: [] },
|
topic ? { records: await queries.listRecords({ topic: slug }) } : { records: [] },
|
||||||
);
|
);
|
||||||
if (!topic) return <RegisteredNotFoundRoute />;
|
if (!topic) return <RegisteredNotFoundRoute />;
|
||||||
if (!view.ready) return view.fallback;
|
if (!view.ready) return view.fallback;
|
||||||
|
|||||||
@@ -99,6 +99,19 @@ function renderBlock(
|
|||||||
resolveEvidenceAsset={resolveEvidenceAsset}
|
resolveEvidenceAsset={resolveEvidenceAsset}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case "THEMATIC_BREAK":
|
||||||
|
return <hr key={key} className="document-rule" />;
|
||||||
|
case "IMAGE":
|
||||||
|
/*
|
||||||
|
작성자가 적은 경로를 그대로 쓴다. 경로 검증은 파싱할 때 끝났다(`isSafeLink`).
|
||||||
|
`title` 이 있으면 그림 설명으로 보여 준다 — Markdown 이 제목을 그런 뜻으로 쓴다.
|
||||||
|
*/
|
||||||
|
return (
|
||||||
|
<figure key={key} className="document-image">
|
||||||
|
<img src={block.src} alt={block.alt} loading="lazy" />
|
||||||
|
{block.title ? <figcaption>{block.title}</figcaption> : null}
|
||||||
|
</figure>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return assertNever(block);
|
return assertNever(block);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,20 @@ type DocumentTocProps = {
|
|||||||
|
|
||||||
export function DocumentToc({ headings, variant }: DocumentTocProps) {
|
export function DocumentToc({ headings, variant }: DocumentTocProps) {
|
||||||
const [currentId, setCurrentId] = useState(headings[0]?.id ?? "");
|
const [currentId, setCurrentId] = useState(headings[0]?.id ?? "");
|
||||||
|
const empty = headings.length === 0;
|
||||||
const detailsRef = useRef<HTMLDetailsElement>(null);
|
const detailsRef = useRef<HTMLDetailsElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
/*
|
||||||
|
`IntersectionObserver` 가 없는 환경이 있다 — jsdom 이 그렇고, 오래된 브라우저도 그렇다.
|
||||||
|
없으면 "지금 읽는 절" 표시만 못 할 뿐 목차 자체는 쓸 수 있으므로, 없다고 문서를 통째로
|
||||||
|
못 그리게 두지 않는다.
|
||||||
|
|
||||||
|
한동안 이 컴포넌트는 픽스처 Case 하나에서만 쓰여 그 환경을 만난 적이 없었다. 모든 Case 가
|
||||||
|
목차를 받게 되면서 드러났다.
|
||||||
|
*/
|
||||||
|
if (typeof IntersectionObserver === "undefined") return undefined;
|
||||||
|
|
||||||
const elements = headings
|
const elements = headings
|
||||||
.map((heading) => document.getElementById(heading.id))
|
.map((heading) => document.getElementById(heading.id))
|
||||||
.filter((element): element is HTMLElement => Boolean(element));
|
.filter((element): element is HTMLElement => Boolean(element));
|
||||||
@@ -41,6 +52,12 @@ export function DocumentToc({ headings, variant }: DocumentTocProps) {
|
|||||||
if (detailsRef.current) detailsRef.current.open = false;
|
if (detailsRef.current) detailsRef.current.open = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
제목이 없는 문서에는 목차를 그리지 않는다. 예전에는 이 컴포넌트가 픽스처 문서 하나에서만
|
||||||
|
쓰여 빈 경우를 만날 일이 없었지만, 이제 모든 Case 가 지나므로 빈 레일이 남을 수 있다.
|
||||||
|
*/
|
||||||
|
if (empty) return null;
|
||||||
|
|
||||||
if (variant === "mobile") {
|
if (variant === "mobile") {
|
||||||
const current =
|
const current =
|
||||||
headings.find((heading) => heading.id === currentId) ?? headings[0];
|
headings.find((heading) => heading.id === currentId) ?? headings[0];
|
||||||
|
|||||||
+18
-61
@@ -128,57 +128,18 @@ function publicRelations(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function GenericCase({
|
|
||||||
model,
|
|
||||||
embedded,
|
|
||||||
resolveEvidenceAsset,
|
|
||||||
resolvePublishedLabel,
|
|
||||||
}: {
|
|
||||||
model: CasePublicRenderModel;
|
|
||||||
embedded: boolean;
|
|
||||||
} & RenderDependencies) {
|
|
||||||
const Root = embedded ? "div" : "main";
|
|
||||||
return (
|
|
||||||
<Root
|
|
||||||
id={embedded ? undefined : "main-content"}
|
|
||||||
className={`shell public-document-page${embedded ? " public-record-embedded" : ""}`}
|
|
||||||
>
|
|
||||||
<ModelDocumentHeader
|
|
||||||
model={model}
|
|
||||||
resolvePublishedLabel={resolvePublishedLabel}
|
|
||||||
/>
|
|
||||||
<section className="document-snapshot" aria-label="문제와 결론">
|
|
||||||
<div>
|
|
||||||
<p className="snapshot-label">문제</p>
|
|
||||||
<p><PlainText text={model.problem} /></p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="snapshot-label snapshot-label--answer">결론</p>
|
|
||||||
<p><PlainText text={model.conclusion} /></p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<dl className="document-facts">
|
|
||||||
<div>
|
|
||||||
<dt>검증 환경</dt>
|
|
||||||
<dd><PlainText text={model.environment} /></dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>검증 데이터</dt>
|
|
||||||
<dd><PlainText text={model.reproduction} /></dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
<article className="public-document-body">
|
|
||||||
<CaseBodyRenderer
|
|
||||||
blocks={model.bodyBlocks}
|
|
||||||
resolveEvidenceAsset={resolveEvidenceAsset}
|
|
||||||
/>
|
|
||||||
</article>
|
|
||||||
<PublicDocumentRelations relations={publicRelations(model.relations)} />
|
|
||||||
</Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FetchJoinCase({
|
/**
|
||||||
|
* Case 한 편.
|
||||||
|
*
|
||||||
|
* 한동안 Case 렌더러가 둘이었다. 이 완성된 배치와, 목차도 breadcrumb 도 없는 축약본. 어느 쪽을
|
||||||
|
* 쓸지는 `publicPath === "/cases/collection-fetch-join-pagination"` 라는 슬러그 비교가 정했다 —
|
||||||
|
* 설계 픽스처로 만든 문서 하나만 제대로 된 화면을 받고, 실제로 작성한 Case 는 전부 축약본으로
|
||||||
|
* 떨어졌다. 그래서 오른쪽 목차가 어떤 문서에서도 나타나지 않았다.
|
||||||
|
*
|
||||||
|
* 배치는 하나다. 목차는 본문에 제목이 있을 때 나온다.
|
||||||
|
*/
|
||||||
|
function CaseDocument({
|
||||||
model,
|
model,
|
||||||
embedded,
|
embedded,
|
||||||
resolveEvidenceAsset,
|
resolveEvidenceAsset,
|
||||||
@@ -233,7 +194,7 @@ function FetchJoinCase({
|
|||||||
<dd><PlainText text={model.environment} /></dd>
|
<dd><PlainText text={model.environment} /></dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>데이터셋</dt>
|
<dt>검증 데이터</dt>
|
||||||
<dd><PlainText text={model.reproduction.replace(/^Dataset:\s*/, "")} /></dd>
|
<dd><PlainText text={model.reproduction.replace(/^Dataset:\s*/, "")} /></dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -474,7 +435,11 @@ function ProjectDecisionDocument({
|
|||||||
<header>
|
<header>
|
||||||
<div>
|
<div>
|
||||||
<span>{model.status}</span>
|
<span>{model.status}</span>
|
||||||
|
{model.decidedOn ? (
|
||||||
<time dateTime={model.decidedOn}>{displayDate(model.decidedOn)}</time>
|
<time dateTime={model.decidedOn}>{displayDate(model.decidedOn)}</time>
|
||||||
|
) : (
|
||||||
|
<span>결정일 미정</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h2>{model.title}</h2>
|
<h2>{model.title}</h2>
|
||||||
<p><PlainText text={model.statement} /></p>
|
<p><PlainText text={model.statement} /></p>
|
||||||
@@ -519,16 +484,8 @@ export function PublicRecordRenderer({
|
|||||||
} & RenderDependencies) {
|
} & RenderDependencies) {
|
||||||
switch (model.kind) {
|
switch (model.kind) {
|
||||||
case "CASE":
|
case "CASE":
|
||||||
return model.publicPath ===
|
return (
|
||||||
"/cases/collection-fetch-join-pagination" ? (
|
<CaseDocument
|
||||||
<FetchJoinCase
|
|
||||||
model={model}
|
|
||||||
embedded={embedded}
|
|
||||||
resolveEvidenceAsset={resolveEvidenceAsset}
|
|
||||||
resolvePublishedLabel={resolvePublishedLabel}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<GenericCase
|
|
||||||
model={model}
|
model={model}
|
||||||
embedded={embedded}
|
embedded={embedded}
|
||||||
resolveEvidenceAsset={resolveEvidenceAsset}
|
resolveEvidenceAsset={resolveEvidenceAsset}
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export function CaseFields({
|
|||||||
<label className="studio-field"><span>마지막 검증일</span><input type="date" value={draft.lastVerifiedOn ?? ""} onChange={(event) => update({ lastVerifiedOn: event.currentTarget.value || null })} /><FieldNotice issues={issues} path="/lastVerifiedOn" /></label>
|
<label className="studio-field"><span>마지막 검증일</span><input type="date" value={draft.lastVerifiedOn ?? ""} onChange={(event) => update({ lastVerifiedOn: event.currentTarget.value || null })} /><FieldNotice issues={issues} path="/lastVerifiedOn" /></label>
|
||||||
<label className="studio-field studio-field--wide"><span>본문 Markdown</span><textarea ref={bodyRef} className="studio-markdown-field" value={draft.bodyMarkdown} onChange={(event) => update({ bodyMarkdown: event.currentTarget.value })} /><FieldNotice issues={issues} path="/bodyMarkdown" /></label>
|
<label className="studio-field studio-field--wide"><span>본문 Markdown</span><textarea ref={bodyRef} className="studio-markdown-field" value={draft.bodyMarkdown} onChange={(event) => update({ bodyMarkdown: event.currentTarget.value })} /><FieldNotice issues={issues} path="/bodyMarkdown" /></label>
|
||||||
</div>
|
</div>
|
||||||
<div className="studio-asset-panel" aria-labelledby="studio-asset-panel-title">
|
<div className="studio-asset-panel" role="group" aria-labelledby="studio-asset-panel-title">
|
||||||
<p className="studio-eyebrow">EVIDENCE</p>
|
<p className="studio-eyebrow">EVIDENCE</p>
|
||||||
<h3 id="studio-asset-panel-title">본문에 Asset 삽입</h3>
|
<h3 id="studio-asset-panel-title">본문에 Asset 삽입</h3>
|
||||||
<p>목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다.</p>
|
<p>목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다.</p>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { DocumentEditor } from "./document-editor.tsx";
|
|||||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||||
import { deriveValidationState } from "../../../domain/studio/document-state.ts";
|
import { deriveValidationState } from "../../../domain/studio/document-state.ts";
|
||||||
import { slugFromName } from "./slug-from-name.ts";
|
import { slugFromName } from "./slug-from-name.ts";
|
||||||
|
import { useSaveShortcut } from "./use-save-shortcut.ts";
|
||||||
import { useStudio, useStudioEditorSession } from "../use-studio.ts";
|
import { useStudio, useStudioEditorSession } from "../use-studio.ts";
|
||||||
|
|
||||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||||
@@ -176,6 +177,12 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
|||||||
}
|
}
|
||||||
}, [begin, editor, setStatus, studio]);
|
}, [begin, editor, setStatus, studio]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
`save` 가 스스로 CLEAN·SAVING·CONFLICT 를 걸러 내므로 단축키는 늘 열어 둔다 — 저장할 것이
|
||||||
|
없을 때 눌러도 아무 일도 일어나지 않는다.
|
||||||
|
*/
|
||||||
|
useSaveShortcut(save);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 저장 → 검증 → 미리보기 → 게시를 한 번에 수행한다.
|
* 저장 → 검증 → 미리보기 → 게시를 한 번에 수행한다.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { useRef, useState, type KeyboardEvent } from "react";
|
|
||||||
|
|
||||||
import type { components } from "../../../contracts/studio/generated.ts";
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
import type { Asset } from "../../../contracts/studio/contract.ts";
|
import type { Asset } from "../../../contracts/studio/contract.ts";
|
||||||
import type { DocumentEditorController } from "./document-editor-controller.ts";
|
import type { DocumentEditorController } from "./document-editor-controller.ts";
|
||||||
import { CASE_FIELD_PATHS, CaseFields } from "./case-fields.tsx";
|
import { CASE_FIELD_PATHS, CaseFields } from "./case-fields.tsx";
|
||||||
import { COMMON_FIELD_PATHS, CommonDocumentFields } from "./common-document-fields.tsx";
|
import { COMMON_FIELD_PATHS, CommonDocumentFields } from "./common-document-fields.tsx";
|
||||||
import { DocumentStatusRail } from "./document-status-rail.tsx";
|
import { DocumentStatusBar } from "./document-status-bar.tsx";
|
||||||
import { InstantPreview } from "./instant-preview.tsx";
|
import { InstantPreview } from "./instant-preview.tsx";
|
||||||
import { DECISION_FIELD_PATHS, ProjectDecisionFields } from "./project-decision-fields.tsx";
|
import { DECISION_FIELD_PATHS, ProjectDecisionFields } from "./project-decision-fields.tsx";
|
||||||
import { issuesOutside } from "./field-issues.tsx";
|
import { issuesOutside } from "./field-issues.tsx";
|
||||||
@@ -28,22 +26,6 @@ export function DocumentEditor({
|
|||||||
onAssetsObserved: (assets: readonly Asset[]) => void;
|
onAssetsObserved: (assets: readonly Asset[]) => void;
|
||||||
onAssetUploaded: (asset: Asset) => void;
|
onAssetUploaded: (asset: Asset) => void;
|
||||||
}) {
|
}) {
|
||||||
const [tab, setTab] = useState<"EDIT" | "PREVIEW">("EDIT");
|
|
||||||
const editTab = useRef<HTMLButtonElement>(null);
|
|
||||||
const previewTab = useRef<HTMLButtonElement>(null);
|
|
||||||
const selectTab = (next: "EDIT" | "PREVIEW") => {
|
|
||||||
setTab(next);
|
|
||||||
(next === "EDIT" ? editTab : previewTab).current?.focus();
|
|
||||||
};
|
|
||||||
const keyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
|
||||||
let next: "EDIT" | "PREVIEW" | null = null;
|
|
||||||
if (event.key === "ArrowLeft" || event.key === "ArrowRight") next = tab === "EDIT" ? "PREVIEW" : "EDIT";
|
|
||||||
if (event.key === "Home") next = "EDIT";
|
|
||||||
if (event.key === "End") next = "PREVIEW";
|
|
||||||
if (!next) return;
|
|
||||||
event.preventDefault();
|
|
||||||
selectTab(next);
|
|
||||||
};
|
|
||||||
const topics = catalog.filter(({ type }) => type === "TOPIC");
|
const topics = catalog.filter(({ type }) => type === "TOPIC");
|
||||||
const projects = catalog.filter(({ type }) => type === "PROJECT");
|
const projects = catalog.filter(({ type }) => type === "PROJECT");
|
||||||
const relations = catalog.filter(({ type }) => type === "RELATION");
|
const relations = catalog.filter(({ type }) => type === "RELATION");
|
||||||
@@ -66,18 +48,21 @@ export function DocumentEditor({
|
|||||||
: DECISION_FIELD_PATHS;
|
: DECISION_FIELD_PATHS;
|
||||||
const unplaced = issuesOutside(issues, [...COMMON_FIELD_PATHS, ...kindPaths]);
|
const unplaced = issuesOutside(issues, [...COMMON_FIELD_PATHS, ...kindPaths]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
편집과 미리보기를 한 화면에 나란히 둔다. 예전에는 탭이었고, 고친 것이 어떻게 보이는지
|
||||||
|
확인하려면 탭을 옮겨야 했다 — 옮기는 동안 편집 중이던 칸은 화면에서 사라졌고, 돌아오면
|
||||||
|
스크롤 위치도 잃었다. 두 패널을 동시에 두면 그 왕복이 통째로 없어진다.
|
||||||
|
|
||||||
|
탭을 없앴으므로 각 패널은 스스로 이름을 가져야 한다. `aria-labelledby` 로 제목을 가리켜
|
||||||
|
landmark 로 만든다 — 탭 목록이 하던 "여기는 편집, 저기는 미리보기" 안내를 대신한다.
|
||||||
|
*/
|
||||||
return (
|
return (
|
||||||
<div className="studio-editor-page">
|
<div className="studio-editor-page">
|
||||||
<div className="studio-editor-tabs" role="tablist" aria-label="문서 편집 화면">
|
<div className="studio-editor-split">
|
||||||
<button id="studio-edit-tab" ref={editTab} type="button" role="tab" aria-selected={tab === "EDIT"} aria-controls="studio-edit-panel" tabIndex={tab === "EDIT" ? 0 : -1} onClick={() => selectTab("EDIT")} onKeyDown={keyDown}>편집</button>
|
<section className="studio-editor-workspace" aria-labelledby="studio-edit-title">
|
||||||
<button id="studio-preview-tab" ref={previewTab} type="button" role="tab" aria-selected={tab === "PREVIEW"} aria-controls="studio-preview-panel" tabIndex={tab === "PREVIEW" ? 0 : -1} onClick={() => selectTab("PREVIEW")} onKeyDown={keyDown}>즉시 미리보기</button>
|
|
||||||
</div>
|
|
||||||
<div className="studio-editor-layout">
|
|
||||||
<div className="studio-editor-workspace">
|
|
||||||
<div id="studio-edit-panel" role="tabpanel" aria-labelledby="studio-edit-tab" hidden={tab !== "EDIT"}>
|
|
||||||
<header className="studio-editor-heading">
|
<header className="studio-editor-heading">
|
||||||
<p className="studio-eyebrow">{controller.draft.kind} · VERSION {controller.saved.version}</p>
|
<p className="studio-eyebrow">{controller.draft.kind} · VERSION {controller.saved.version}</p>
|
||||||
<h1>문서 편집</h1>
|
<h1 id="studio-edit-title">문서 편집</h1>
|
||||||
<p>{controller.draft.title || "제목 없는 작업본"}</p>
|
<p>{controller.draft.title || "제목 없는 작업본"}</p>
|
||||||
</header>
|
</header>
|
||||||
<CommonDocumentFields draft={controller.draft} topics={topics} projects={projects} relations={relations} issues={issues} onUpdate={controller.update} />
|
<CommonDocumentFields draft={controller.draft} topics={topics} projects={projects} relations={relations} issues={issues} onUpdate={controller.update} />
|
||||||
@@ -88,13 +73,22 @@ export function DocumentEditor({
|
|||||||
: controller.draft.kind === "QUESTION"
|
: controller.draft.kind === "QUESTION"
|
||||||
? <QuestionFields draft={controller.draft} evidence={evidence} issues={issues} onChange={controller.replace} />
|
? <QuestionFields draft={controller.draft} evidence={evidence} issues={issues} onChange={controller.replace} />
|
||||||
: <ProjectDecisionFields draft={controller.draft} issues={issues} onChange={controller.replace} />}
|
: <ProjectDecisionFields draft={controller.draft} issues={issues} onChange={controller.replace} />}
|
||||||
</div>
|
</section>
|
||||||
<div id="studio-preview-panel" role="tabpanel" aria-labelledby="studio-preview-tab" hidden={tab !== "PREVIEW"}>
|
{/*
|
||||||
|
미리보기는 편집 칸보다 훨씬 짧을 수도, 길 수도 있다. 그대로 두면 본문을 스크롤하는
|
||||||
|
동안 화면 밖으로 나가 버리므로 붙잡아 두고 자기 높이 안에서 따로 스크롤한다.
|
||||||
|
*/}
|
||||||
|
<section className="studio-editor-preview" aria-labelledby="studio-preview-title">
|
||||||
|
<header className="studio-editor-preview__heading">
|
||||||
|
<p className="studio-eyebrow">LIVE</p>
|
||||||
|
<h2 id="studio-preview-title">즉시 미리보기</h2>
|
||||||
|
</header>
|
||||||
|
<div className="studio-editor-preview__body">
|
||||||
<InstantPreview draft={controller.draft} catalog={catalog} assets={assets} />
|
<InstantPreview draft={controller.draft} catalog={catalog} assets={assets} />
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<DocumentStatusRail controller={controller} unplacedIssues={unplaced} />
|
<DocumentStatusBar controller={controller} unplacedIssues={unplaced} />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type { DocumentEditorController } from "./document-editor-controller.ts";
|
||||||
|
import type { FieldIssue } from "./field-issues.tsx";
|
||||||
|
|
||||||
|
const labels = {
|
||||||
|
CLEAN: "저장됨",
|
||||||
|
DIRTY: "저장되지 않음",
|
||||||
|
SAVING: "저장 중…",
|
||||||
|
CONFLICT: "저장 충돌",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const kindLabels = {
|
||||||
|
CASE: "CASE",
|
||||||
|
REFERENCE: "REFERENCE",
|
||||||
|
QUESTION: "QUESTION",
|
||||||
|
PROJECT_DECISION: "Decision",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 상태와 저장·게시를 화면 아래에 고정한다.
|
||||||
|
*
|
||||||
|
* 예전에는 오른쪽 세로 rail 이었다. 그 자리는 편집기와 미리보기가 함께 쓸 가로 폭을 가져갔고,
|
||||||
|
* 편집 칸이 길어질수록 rail 은 위에 붙은 채 본문만 멀어졌다. 아래에 고정하면 폭을 돌려주면서
|
||||||
|
* 스크롤 위치와 무관하게 저장·게시에 손이 닿는다 — 릴리즈 편집에서 버튼이 아래로 밀려나던
|
||||||
|
* 것과 같은 문제를 여기서는 만들지 않는다.
|
||||||
|
*
|
||||||
|
* `aside` 와 「작업 상태」라는 이름은 그대로 둔다. 자리가 바뀐 것이지 이 묶음이 무엇인지가
|
||||||
|
* 바뀐 것은 아니고, 보조 기술이 이 영역을 부르던 이름도 그대로여야 한다.
|
||||||
|
*/
|
||||||
|
export function DocumentStatusBar({
|
||||||
|
controller,
|
||||||
|
unplacedIssues,
|
||||||
|
}: {
|
||||||
|
controller: DocumentEditorController;
|
||||||
|
/**
|
||||||
|
* 어느 칸에도 붙지 못한 지적. 화면에 없는 칸을 가리키는 것들이며, 여기 남기지 않으면 이유를
|
||||||
|
* 말해 주지 않는 실패만 남는다.
|
||||||
|
*/
|
||||||
|
unplacedIssues: readonly FieldIssue[];
|
||||||
|
}) {
|
||||||
|
const busy = controller.status === "SAVING" || controller.publishing;
|
||||||
|
return (
|
||||||
|
<aside className="studio-document-status-bar" aria-labelledby="studio-document-status-title">
|
||||||
|
<h2 id="studio-document-status-title" className="studio-visually-hidden">작업 상태</h2>
|
||||||
|
<p className={`studio-editor-status studio-editor-status--${controller.status.toLowerCase()}`} role="status" aria-label="편집 상태">{labels[controller.status]}</p>
|
||||||
|
<dl><div><dt>저장 버전</dt><dd>{controller.saved.version}</dd></div><div><dt>종류</dt><dd>{kindLabels[controller.draft.kind]}</dd></div></dl>
|
||||||
|
{/*
|
||||||
|
경고는 하나만 띄운다. 충돌은 그 자체로 무엇을 해야 하는지 말해 주므로 서버가 준 문구보다
|
||||||
|
앞서고, 그 밖의 실패는 서버가 준 이유를 그대로 보여 준다. 둘을 함께 띄우면 같은 실패를
|
||||||
|
두 번 말하게 된다.
|
||||||
|
*/}
|
||||||
|
{controller.status === "CONFLICT" ? (
|
||||||
|
<p className="studio-editor-conflict" role="alert">서버 최신본과 충돌했습니다. 이 세션에서는 다시 열어 비교해 주세요.</p>
|
||||||
|
) : controller.saveError ? (
|
||||||
|
<p className="studio-editor-conflict" role="alert">{controller.saveError}</p>
|
||||||
|
) : controller.publishError ? (
|
||||||
|
<p className="studio-editor-conflict" role="alert">{controller.publishError}</p>
|
||||||
|
) : (
|
||||||
|
<p className="studio-document-status-bar__note">불완전한 초안도 저장할 수 있습니다. Ctrl+S 로도 저장합니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다.</p>
|
||||||
|
)}
|
||||||
|
<div className="studio-document-status-bar__actions">
|
||||||
|
<button type="button" title="Ctrl+S" onClick={() => { void controller.save(); }} disabled={busy || controller.status === "CLEAN" || controller.status === "CONFLICT"}>{controller.status === "SAVING" ? "저장 중…" : "저장"}</button>
|
||||||
|
{/*
|
||||||
|
버튼은 저장과 게시 둘뿐이다. 예전에는 게시까지 검증 → 미리보기 → 게시 세 화면을 차례로
|
||||||
|
밟아야 했다 — 백엔드가 게시 요청에 신선한 검증 id 와 미리보기 id, 그리고 현재 경고를 모두
|
||||||
|
확인했다는 목록을 요구하기 때문이다(`PublishStudioDocumentUseCase`).
|
||||||
|
|
||||||
|
그 셋은 작성자에게 물어볼 것이 없다. 검증은 서버가 판정하고, 미리보기는 그 판정으로부터
|
||||||
|
만들어지며, 경고는 게시를 막지 않는다. 그래서 세 요청을 이 버튼 뒤로 옮겼다. 계약도
|
||||||
|
백엔드도 그대로다 — 사라진 것은 작성자가 밟던 화면이지 서버가 지키던 불변식이 아니다.
|
||||||
|
*/}
|
||||||
|
<button
|
||||||
|
className="studio-primary-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => { void controller.publish(); }}
|
||||||
|
disabled={busy || controller.status === "CONFLICT"}
|
||||||
|
>
|
||||||
|
{controller.publishing ? "게시 중…" : "게시"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{unplacedIssues.length ? (
|
||||||
|
<ul className="studio-editor-unplaced-issues" aria-label="칸에 붙지 못한 지적">
|
||||||
|
{unplacedIssues.map((issue) => (
|
||||||
|
<li key={`${issue.code}:${issue.path}`} data-severity={issue.severity}>
|
||||||
|
{issue.message} <code>{issue.path}</code>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import type { DocumentEditorController } from "./document-editor-controller.ts";
|
|
||||||
import type { FieldIssue } from "./field-issues.tsx";
|
|
||||||
|
|
||||||
const labels = {
|
|
||||||
CLEAN: "저장됨",
|
|
||||||
DIRTY: "저장되지 않음",
|
|
||||||
SAVING: "저장 중…",
|
|
||||||
CONFLICT: "저장 충돌",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const kindLabels = {
|
|
||||||
CASE: "CASE",
|
|
||||||
REFERENCE: "REFERENCE",
|
|
||||||
QUESTION: "QUESTION",
|
|
||||||
PROJECT_DECISION: "Decision",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export function DocumentStatusRail({
|
|
||||||
controller,
|
|
||||||
unplacedIssues,
|
|
||||||
}: {
|
|
||||||
controller: DocumentEditorController;
|
|
||||||
/**
|
|
||||||
* 어느 칸에도 붙지 못한 지적. 화면에 없는 칸을 가리키는 것들이며, 여기 남기지 않으면 이유를
|
|
||||||
* 말해 주지 않는 실패만 남는다.
|
|
||||||
*/
|
|
||||||
unplacedIssues: readonly FieldIssue[];
|
|
||||||
}) {
|
|
||||||
const busy = controller.status === "SAVING" || controller.publishing;
|
|
||||||
return (
|
|
||||||
<aside className="studio-document-status-rail" aria-labelledby="studio-document-status-title">
|
|
||||||
<p className="studio-eyebrow">WORKING COPY</p>
|
|
||||||
<h2 id="studio-document-status-title">작업 상태</h2>
|
|
||||||
<p className={`studio-editor-status studio-editor-status--${controller.status.toLowerCase()}`} role="status" aria-label="편집 상태">{labels[controller.status]}</p>
|
|
||||||
<dl><div><dt>저장 버전</dt><dd>{controller.saved.version}</dd></div><div><dt>종류</dt><dd>{kindLabels[controller.draft.kind]}</dd></div></dl>
|
|
||||||
<button type="button" onClick={() => { void controller.save(); }} disabled={busy || controller.status === "CLEAN" || controller.status === "CONFLICT"}>{controller.status === "SAVING" ? "저장 중…" : "저장"}</button>
|
|
||||||
{/*
|
|
||||||
버튼은 저장과 게시 둘뿐이다. 예전에는 게시까지 검증 → 미리보기 → 게시 세 화면을 차례로
|
|
||||||
밟아야 했다 — 백엔드가 게시 요청에 신선한 검증 id 와 미리보기 id, 그리고 현재 경고를 모두
|
|
||||||
확인했다는 목록을 요구하기 때문이다(`PublishStudioDocumentUseCase`).
|
|
||||||
|
|
||||||
그 셋은 작성자에게 물어볼 것이 없다. 검증은 서버가 판정하고, 미리보기는 그 판정으로부터
|
|
||||||
만들어지며, 경고는 게시를 막지 않는다. 그래서 세 요청을 이 버튼 뒤로 옮겼다. 계약도
|
|
||||||
백엔드도 그대로다 — 사라진 것은 작성자가 밟던 화면이지 서버가 지키던 불변식이 아니다.
|
|
||||||
*/}
|
|
||||||
<button
|
|
||||||
className="studio-primary-button"
|
|
||||||
type="button"
|
|
||||||
onClick={() => { void controller.publish(); }}
|
|
||||||
disabled={busy || controller.status === "CONFLICT"}
|
|
||||||
>
|
|
||||||
{controller.publishing ? "게시 중…" : "게시"}
|
|
||||||
</button>
|
|
||||||
{/*
|
|
||||||
경고는 하나만 띄운다. 충돌은 그 자체로 무엇을 해야 하는지 말해 주므로 서버가 준 문구보다
|
|
||||||
앞서고, 그 밖의 실패는 서버가 준 이유를 그대로 보여 준다. 둘을 함께 띄우면 같은 실패를
|
|
||||||
두 번 말하게 된다.
|
|
||||||
*/}
|
|
||||||
{controller.status === "CONFLICT" ? (
|
|
||||||
<p className="studio-editor-conflict" role="alert">서버 최신본과 충돌했습니다. 이 세션에서는 다시 열어 비교해 주세요.</p>
|
|
||||||
) : controller.saveError ? (
|
|
||||||
<p className="studio-editor-conflict" role="alert">{controller.saveError}</p>
|
|
||||||
) : controller.publishError ? (
|
|
||||||
<p className="studio-editor-conflict" role="alert">{controller.publishError}</p>
|
|
||||||
) : (
|
|
||||||
<p>불완전한 초안도 저장할 수 있습니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다.</p>
|
|
||||||
)}
|
|
||||||
{unplacedIssues.length ? (
|
|
||||||
<ul className="studio-editor-unplaced-issues" aria-label="칸에 붙지 못한 지적">
|
|
||||||
{unplacedIssues.map((issue) => (
|
|
||||||
<li key={`${issue.code}:${issue.path}`} data-severity={issue.severity}>
|
|
||||||
{issue.message} <code>{issue.path}</code>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : null}
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||||
|
|
||||||
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
|
import type {
|
||||||
|
HomeFocusResponse,
|
||||||
|
ProjectIndexItem,
|
||||||
|
} from "../../../contracts/management/contract.ts";
|
||||||
|
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
|
||||||
|
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||||
|
import { useStudio } from "../use-studio.ts";
|
||||||
|
|
||||||
|
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공개 홈의 "지금 집중하는 것" 을 정하는 화면.
|
||||||
|
*
|
||||||
|
* <p>그 영역은 세 칸(현재 작업·열린 질문·최근 결정)을 가지며 셋이 모두 비면 홈은 영역 자체를 그리지
|
||||||
|
* 않는다. `home_focus_config` 는 마이그레이션이 빈 행 하나만 넣어 두었고 그 값을 채울 화면이 없었으므로,
|
||||||
|
* 홈에서는 그 영역이 한 번도 나타난 적이 없었다.
|
||||||
|
*
|
||||||
|
* <p>고를 수 있는 것은 실제로 존재하는 기록뿐이다. 질문과 결정은 Studio catalog 의 `RELATION` 목록에서
|
||||||
|
* 가져온다 — 그 목록이 곧 "연결 가능한 대상" 의 정의이고, 여기서 다른 기준을 쓰면 두 화면이 서로 다른
|
||||||
|
* 것을 보여 준다.
|
||||||
|
*
|
||||||
|
* <p>비공개 프로젝트도 고를 수 있게 둔다. 미리 지목해 두고 게시와 동시에 홈에 뜨게 하는 것이 정상적인
|
||||||
|
* 순서이기 때문이다. 다만 게시되지 않은 동안에는 홈이 그 칸을 그리지 않으므로 그 사실을 적어 둔다.
|
||||||
|
*/
|
||||||
|
export function HomeFocusEditor() {
|
||||||
|
const { gateway, managementGateway, setRequestAnnouncement } = useStudio();
|
||||||
|
const [focus, setFocus] = useState<HomeFocusResponse | null>(null);
|
||||||
|
const [projects, setProjects] = useState<ProjectIndexItem[]>([]);
|
||||||
|
const [relations, setRelations] = useState<CatalogEntry[]>([]);
|
||||||
|
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;
|
||||||
|
/*
|
||||||
|
try/catch 로 감싼다. `Promise.all([gateway.foo()])` 은 `foo` 가 거절하는 것만 잡는다 —
|
||||||
|
호출 자체가 동기적으로 던지면(예: 그 표면을 갖추지 않은 게이트웨이) 배열을 만드는 중에
|
||||||
|
터지므로 rejection handler 를 지나지 못한다. 그러면 이 섹션이 아니라 대시보드 전체가
|
||||||
|
빈 화면이 된다. 한 칸의 실패가 화면을 통째로 날리지 않게 한다.
|
||||||
|
*/
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const [current, projectPage, catalog] = await Promise.all([
|
||||||
|
managementGateway.getHomeFocus(),
|
||||||
|
managementGateway.listProjects(0, 50),
|
||||||
|
gateway.getCatalog({ type: "RELATION", limit: 100 }),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
setFocus(current);
|
||||||
|
setProjects(projectPage.items ?? []);
|
||||||
|
setRelations(catalog.items ?? []);
|
||||||
|
setProjectId(current.currentProjectId ?? "");
|
||||||
|
setQuestionId(current.openQuestionId ?? "");
|
||||||
|
setDecisionId(current.recentDecisionId ?? "");
|
||||||
|
setError("");
|
||||||
|
} catch {
|
||||||
|
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 (
|
||||||
|
<section className="studio-editor-section" aria-labelledby="studio-home-focus-title">
|
||||||
|
<div className="studio-editor-section-heading">
|
||||||
|
<p className="studio-eyebrow">PUBLIC HOME</p>
|
||||||
|
<h2 id="studio-home-focus-title">지금 집중하는 것</h2>
|
||||||
|
<p>
|
||||||
|
공개 홈 맨 위 영역입니다. 셋 다 비워 두면 그 영역은 나타나지 않습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p className="studio-screen-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{!focus && !error ? (
|
||||||
|
<p className="studio-loading" role="status">
|
||||||
|
홈 설정을 불러오는 중입니다.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{focus ? (
|
||||||
|
<form onSubmit={save}>
|
||||||
|
<div className="studio-field-grid">
|
||||||
|
<label className="studio-field studio-field--wide">
|
||||||
|
<span>현재 작업 (프로젝트)</span>
|
||||||
|
<select value={projectId} onChange={(event) => setProjectId(event.currentTarget.value)}>
|
||||||
|
<option value="">고르지 않음</option>
|
||||||
|
{projects.map((project) => (
|
||||||
|
<option key={project.id} value={project.id}>
|
||||||
|
{project.name}
|
||||||
|
{project.targetVisibility === "PRIVATE" ? " (비공개)" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{projectHidden ? (
|
||||||
|
<small>
|
||||||
|
이 프로젝트는 아직 비공개입니다. 주제·프로젝트 화면에서 게시해야 홈에 나타납니다.
|
||||||
|
</small>
|
||||||
|
) : (
|
||||||
|
<small>단계·현재 목표·다음 작업이 카드에 함께 나옵니다.</small>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="studio-field">
|
||||||
|
<span>열린 질문</span>
|
||||||
|
<select
|
||||||
|
value={questionId}
|
||||||
|
onChange={(event) => setQuestionId(event.currentTarget.value)}
|
||||||
|
disabled={questions.length === 0}
|
||||||
|
>
|
||||||
|
<option value="">고르지 않음</option>
|
||||||
|
{questions.map((entry) => (
|
||||||
|
<option key={entry.id} value={entry.id}>
|
||||||
|
{entry.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{questions.length === 0 ? <small>아직 Question 문서가 없습니다.</small> : null}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="studio-field">
|
||||||
|
<span>최근 결정</span>
|
||||||
|
<select
|
||||||
|
value={decisionId}
|
||||||
|
onChange={(event) => setDecisionId(event.currentTarget.value)}
|
||||||
|
disabled={decisions.length === 0}
|
||||||
|
>
|
||||||
|
<option value="">고르지 않음</option>
|
||||||
|
{decisions.map((entry) => (
|
||||||
|
<option key={entry.id} value={entry.id}>
|
||||||
|
{entry.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{decisions.length === 0 ? <small>아직 Decision 문서가 없습니다.</small> : null}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="studio-editor-footer">
|
||||||
|
<button className="studio-secondary-button" type="submit" disabled={pending}>
|
||||||
|
{pending ? "저장하는 중" : "홈 설정 저장"}
|
||||||
|
</button>
|
||||||
|
<p className="studio-field-note">
|
||||||
|
{nothingChosen
|
||||||
|
? "지금은 아무것도 고르지 않아 홈에 이 영역이 나타나지 않습니다."
|
||||||
|
: "저장하면 공개 홈에 바로 반영됩니다."}
|
||||||
|
{" "}
|
||||||
|
<GuardedStudioLink href="/studio/taxonomy">주제·프로젝트</GuardedStudioLink>
|
||||||
|
에서 프로젝트를 만들고 게시할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { components } from "../../../contracts/studio/generated.ts";
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
import { createNewItemId } from "../../../domain/studio/local-id.ts";
|
||||||
|
|
||||||
type OrderedText = components["schemas"]["OrderedText"];
|
type OrderedText = components["schemas"]["OrderedText"];
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ export function OrderedTextList({ label, fieldId, items, onChange }: { label: st
|
|||||||
<div className="studio-item-actions"><button type="button" onClick={() => move(index, -1)} disabled={index === 0}>위로</button><button type="button" onClick={() => move(index, 1)} disabled={index === items.length - 1}>아래로</button><button type="button" onClick={() => onChange(ordered(items.filter((_, candidateIndex) => candidateIndex !== index)))}>삭제</button></div>
|
<div className="studio-item-actions"><button type="button" onClick={() => move(index, -1)} disabled={index === 0}>위로</button><button type="button" onClick={() => move(index, 1)} disabled={index === items.length - 1}>아래로</button><button type="button" onClick={() => onChange(ordered(items.filter((_, candidateIndex) => candidateIndex !== index)))}>삭제</button></div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button className="studio-add-item" type="button" disabled={items.length >= 50} onClick={() => { if (items.length < 50) onChange(ordered([...items, { id: createLocalId("item"), text: "", order: items.length }])); }}>{label} 추가</button>
|
<button className="studio-add-item" type="button" disabled={items.length >= 50} onClick={() => { if (items.length < 50) onChange(ordered([...items, { id: createNewItemId(), text: "", order: items.length }])); }}>{label} 추가</button>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,458 @@
|
|||||||
|
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||||
|
|
||||||
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
|
import type {
|
||||||
|
ProjectActivityResponse,
|
||||||
|
ProjectEditResponse,
|
||||||
|
TopicEdit,
|
||||||
|
} from "../../../contracts/management/contract.ts";
|
||||||
|
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
|
||||||
|
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
|
||||||
|
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||||
|
import { slugFromName } from "./slug-from-name.ts";
|
||||||
|
import { useSaveShortcut } from "./use-save-shortcut.ts";
|
||||||
|
import { useStudio } from "../use-studio.ts";
|
||||||
|
|
||||||
|
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||||
|
|
||||||
|
/** 경로 파라미터는 `unknown` 으로 들어온다 — 라우터가 코덱을 통과시킨 값이라도 타입은 좁혀 써야 한다. */
|
||||||
|
function routeId(value: unknown): string {
|
||||||
|
return typeof value === "string" ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `project_phase_check` 와 같은 집합이다 — 화면이 더 많은 값을 보여 주면 저장이 제약에서 터진다. */
|
||||||
|
const PHASES = [
|
||||||
|
{ value: "RESEARCH", label: "조사" },
|
||||||
|
{ value: "DESIGN", label: "설계" },
|
||||||
|
{ value: "IMPLEMENTATION", label: "구현" },
|
||||||
|
{ value: "VERIFICATION", label: "검증" },
|
||||||
|
{ value: "MAINTENANCE", label: "유지" },
|
||||||
|
{ value: "PAUSED", label: "중단" },
|
||||||
|
{ value: "COMPLETED", label: "완료" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** `project_activity_activity_type_check` 와 같은 집합이다. */
|
||||||
|
const ACTIVITY_TYPES = [
|
||||||
|
{ value: "MILESTONE_REACHED", label: "이정표 도달" },
|
||||||
|
{ value: "PHASE_CHANGED", label: "단계 전환" },
|
||||||
|
{ value: "QUESTION_OPENED", label: "질문 열림" },
|
||||||
|
{ value: "QUESTION_RESOLVED", label: "질문 해결" },
|
||||||
|
{ value: "DECISION_ACCEPTED", label: "결정 수락" },
|
||||||
|
{ value: "CASE_PUBLISHED", label: "Case 게시" },
|
||||||
|
{ value: "REFERENCE_PUBLISHED", label: "Reference 게시" },
|
||||||
|
{ value: "PROJECT_PAUSED", label: "프로젝트 중단" },
|
||||||
|
{ value: "PROJECT_RESUMED", label: "프로젝트 재개" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const activityTypeLabel = (value: string) =>
|
||||||
|
ACTIVITY_TYPES.find((type) => type.value === value)?.label ?? value;
|
||||||
|
|
||||||
|
/** `<input type="datetime-local">` 은 초와 시간대가 없는 지역 시각을 쓴다. */
|
||||||
|
function toLocalInput(isoTimestamp: string): string {
|
||||||
|
const value = new Date(isoTimestamp);
|
||||||
|
if (Number.isNaN(value.getTime())) return "";
|
||||||
|
const pad = (part: number) => String(part).padStart(2, "0");
|
||||||
|
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}T${pad(
|
||||||
|
value.getHours(),
|
||||||
|
)}:${pad(value.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromLocalInput(value: string): string {
|
||||||
|
const parsed = new Date(value);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
type Draft = {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
oneLinePurpose: string;
|
||||||
|
purposeMarkdown: string;
|
||||||
|
boundaryMarkdown: string;
|
||||||
|
systemOverviewMarkdown: string;
|
||||||
|
phase: string;
|
||||||
|
currentObjective: string;
|
||||||
|
nextStep: string;
|
||||||
|
technologyLabels: string;
|
||||||
|
topicIds: readonly string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function toDraft(project: ProjectEditResponse): Draft {
|
||||||
|
return {
|
||||||
|
name: project.name,
|
||||||
|
slug: project.slug ?? "",
|
||||||
|
oneLinePurpose: project.oneLinePurpose ?? "",
|
||||||
|
purposeMarkdown: project.purposeMarkdown ?? "",
|
||||||
|
boundaryMarkdown: project.boundaryMarkdown ?? "",
|
||||||
|
systemOverviewMarkdown: project.systemOverviewMarkdown ?? "",
|
||||||
|
phase: project.phase,
|
||||||
|
currentObjective: project.currentObjective ?? "",
|
||||||
|
nextStep: project.nextStep ?? "",
|
||||||
|
technologyLabels: (project.technologyLabels ?? []).join(", "),
|
||||||
|
topicIds: project.topicIds ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로젝트 한 편의 편집 화면.
|
||||||
|
*
|
||||||
|
* <p>이 화면이 없는 동안 프로젝트는 이름과 slug 만 가질 수 있었다. 그래서 공개 화면의 "현재 목표"·"다음
|
||||||
|
* 작업"·"주요 주제" 가 늘 비어 있었고 홈의 집중 카드도 제목만 남았다 — 백엔드의 `updateProject` 는
|
||||||
|
* 처음부터 구현돼 있었고 채울 화면만 없었다.
|
||||||
|
*
|
||||||
|
* <p>활동도 여기서 다룬다. 프로젝트 밖의 활동은 존재하지 않으므로 따로 둘 이유가 없고, 무엇이 공개
|
||||||
|
* 타임라인에 실리는지를 프로젝트 필드와 같은 자리에서 보는 편이 낫다.
|
||||||
|
*
|
||||||
|
* <p>새 CSS 를 만들지 않는다 — 문서 편집기의 `studio-editor-section`·`studio-field-grid` 와 작업본
|
||||||
|
* 목록의 행 클래스를 그대로 쓰므로 Studio 의 나머지와 같은 간격·타이포·색을 따른다.
|
||||||
|
*/
|
||||||
|
export function ProjectEditor() {
|
||||||
|
const { params } = useRouteInput<"TECH_LOG_STUDIO_PROJECT_EDIT">();
|
||||||
|
const projectId = routeId(params.id);
|
||||||
|
const { gateway, managementGateway, setRequestAnnouncement } = useStudio();
|
||||||
|
|
||||||
|
const [project, setProject] = useState<ProjectEditResponse | null>(null);
|
||||||
|
const [topics, setTopics] = useState<TopicEdit[]>([]);
|
||||||
|
const [records, setRecords] = useState<CatalogEntry[]>([]);
|
||||||
|
const [activities, setActivities] = useState<ProjectActivityResponse[]>([]);
|
||||||
|
const [draft, setDraft] = useState<Draft | null>(null);
|
||||||
|
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;
|
||||||
|
// 동기적으로 던지는 호출도 잡는다 — `home-focus-editor` 와 같은 이유다.
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const [loaded, activityList, topicList, catalog] = await Promise.all([
|
||||||
|
managementGateway.getProject(projectId),
|
||||||
|
managementGateway.listProjectActivities(projectId),
|
||||||
|
managementGateway.listTopics(),
|
||||||
|
gateway.getCatalog({ type: "RELATION", limit: 100 }),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
setProject(loaded);
|
||||||
|
setDraft(toDraft(loaded));
|
||||||
|
setActivities(activityList);
|
||||||
|
setTopics(topicList.filter((topic) => topic.status !== "ARCHIVED"));
|
||||||
|
setRecords(catalog.items ?? []);
|
||||||
|
setError("");
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setError("프로젝트를 불러오지 못했습니다.");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [gateway, managementGateway, projectId, generation]);
|
||||||
|
|
||||||
|
const update = (patch: Partial<Draft>) =>
|
||||||
|
setDraft((current) => (current ? { ...current, ...patch } : current));
|
||||||
|
|
||||||
|
const toggleTopic = (topicId: string) =>
|
||||||
|
setDraft((current) => {
|
||||||
|
if (!current) return current;
|
||||||
|
const chosen = current.topicIds.includes(topicId)
|
||||||
|
? current.topicIds.filter((id) => id !== topicId)
|
||||||
|
: [...current.topicIds, topicId];
|
||||||
|
return { ...current, topicIds: chosen };
|
||||||
|
});
|
||||||
|
|
||||||
|
const save = async (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (pending || !project || !draft) return;
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
/*
|
||||||
|
slug 는 공개 주소다. 비워 두면 게시할 수 없으므로 이름에서 만들어 채운다 — 예전에는 조용히
|
||||||
|
빈 값으로 저장되었고, 게시할 때가 되어서야 "slug 가 없다" 는 말을 들었다.
|
||||||
|
*/
|
||||||
|
const saved = await managementGateway.updateProject(project.id, {
|
||||||
|
expectedVersion: project.version,
|
||||||
|
name: draft.name.trim(),
|
||||||
|
slug: draft.slug.trim() || slugFromName(draft.name),
|
||||||
|
oneLinePurpose: draft.oneLinePurpose,
|
||||||
|
purposeMarkdown: draft.purposeMarkdown,
|
||||||
|
boundaryMarkdown: draft.boundaryMarkdown,
|
||||||
|
systemOverviewMarkdown: draft.systemOverviewMarkdown,
|
||||||
|
phase: draft.phase,
|
||||||
|
currentObjective: draft.currentObjective,
|
||||||
|
nextStep: draft.nextStep,
|
||||||
|
technologyLabels: draft.technologyLabels
|
||||||
|
.split(",")
|
||||||
|
.map((label) => label.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
topicIds: [...draft.topicIds],
|
||||||
|
targetVisibility:
|
||||||
|
project.targetVisibility === "PUBLIC" || project.targetVisibility === "UNLISTED"
|
||||||
|
? project.targetVisibility
|
||||||
|
: "PRIVATE",
|
||||||
|
featuredOrder: project.featuredOrder,
|
||||||
|
});
|
||||||
|
/*
|
||||||
|
이미 공개된 프로젝트라면 공개 투영도 함께 세워야 한다. 투영은 게시할 때 만들어지므로 저장만
|
||||||
|
하면 공개 화면에는 예전 값이 남는다.
|
||||||
|
*/
|
||||||
|
if (saved.targetVisibility !== "PRIVATE") {
|
||||||
|
await managementGateway.publishProject(
|
||||||
|
saved.id,
|
||||||
|
saved.version,
|
||||||
|
saved.targetVisibility === "UNLISTED" ? "UNLISTED" : "PUBLIC",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setRequestAnnouncement(`프로젝트를 버전 ${saved.version}으로 저장했습니다.`);
|
||||||
|
reload();
|
||||||
|
} catch (failure) {
|
||||||
|
setError(managementFailureMessage(failure, "프로젝트를 저장하지 못했습니다."));
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 폼 제출과 같은 일을 하므로 submit 이벤트를 만들어 보낸다 — 검증·기본값 처리가 한 곳에 남는다.
|
||||||
|
useSaveShortcut(
|
||||||
|
() => void save({ preventDefault: () => {} } as FormEvent),
|
||||||
|
Boolean(project && draft) && !pending,
|
||||||
|
);
|
||||||
|
|
||||||
|
const published = project ? project.targetVisibility !== "PRIVATE" : false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="studio-page studio-editor-page">
|
||||||
|
<header className="studio-page-top">
|
||||||
|
<div className="studio-page-heading">
|
||||||
|
<p className="studio-eyebrow">
|
||||||
|
PROJECT · VERSION {project?.version ?? "—"}
|
||||||
|
</p>
|
||||||
|
<h1>{project?.name ?? "프로젝트"}</h1>
|
||||||
|
<p>
|
||||||
|
공개 프로젝트 화면과 프로필의 “현재 프로젝트”, 홈의 집중 카드가 여기 값을
|
||||||
|
그대로 읽습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<GuardedStudioLink className="studio-primary-action" href="/studio/taxonomy">
|
||||||
|
주제·프로젝트로
|
||||||
|
</GuardedStudioLink>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p className="studio-screen-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{!project && !error ? (
|
||||||
|
<p className="studio-loading" role="status">
|
||||||
|
프로젝트를 불러오는 중입니다.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{project && draft ? (
|
||||||
|
<>
|
||||||
|
<form onSubmit={save}>
|
||||||
|
<section
|
||||||
|
className="studio-editor-section"
|
||||||
|
aria-labelledby="studio-project-basics-title"
|
||||||
|
>
|
||||||
|
<div className="studio-editor-section-heading">
|
||||||
|
<p className="studio-eyebrow">PROJECT</p>
|
||||||
|
<h2 id="studio-project-basics-title">기본 정보</h2>
|
||||||
|
<p>목록과 카드에 그대로 나가는 값입니다.</p>
|
||||||
|
</div>
|
||||||
|
<div className="studio-field-grid">
|
||||||
|
<label className="studio-field studio-field--wide">
|
||||||
|
<span>이름</span>
|
||||||
|
<input
|
||||||
|
value={draft.name}
|
||||||
|
maxLength={120}
|
||||||
|
onChange={(event) => update({ name: event.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="studio-field">
|
||||||
|
<span>slug</span>
|
||||||
|
<input
|
||||||
|
value={draft.slug}
|
||||||
|
maxLength={100}
|
||||||
|
placeholder="비우면 이름에서 만듭니다"
|
||||||
|
onChange={(event) => update({ slug: event.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="studio-field">
|
||||||
|
<span>단계</span>
|
||||||
|
<select
|
||||||
|
value={draft.phase}
|
||||||
|
onChange={(event) => update({ phase: event.currentTarget.value })}
|
||||||
|
>
|
||||||
|
{PHASES.map((phase) => (
|
||||||
|
<option key={phase.value} value={phase.value}>
|
||||||
|
{phase.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="studio-field studio-field--wide">
|
||||||
|
<span>한 줄 목적</span>
|
||||||
|
<input
|
||||||
|
value={draft.oneLinePurpose}
|
||||||
|
maxLength={200}
|
||||||
|
onChange={(event) => update({ oneLinePurpose: event.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="studio-field">
|
||||||
|
<span>현재 목표</span>
|
||||||
|
<input
|
||||||
|
value={draft.currentObjective}
|
||||||
|
onChange={(event) => update({ currentObjective: event.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="studio-field">
|
||||||
|
<span>다음 작업</span>
|
||||||
|
<input
|
||||||
|
value={draft.nextStep}
|
||||||
|
onChange={(event) => update({ nextStep: event.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="studio-field studio-field--wide">
|
||||||
|
<span>기술 라벨</span>
|
||||||
|
<input
|
||||||
|
value={draft.technologyLabels}
|
||||||
|
placeholder="쉼표로 구분합니다"
|
||||||
|
onChange={(event) => update({ technologyLabels: event.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
className="studio-editor-section"
|
||||||
|
aria-labelledby="studio-project-topics-title"
|
||||||
|
>
|
||||||
|
<div className="studio-editor-section-heading">
|
||||||
|
<p className="studio-eyebrow">TOPICS</p>
|
||||||
|
<h2 id="studio-project-topics-title">주요 주제</h2>
|
||||||
|
<p>프로젝트 화면 상단의 “주요 주제”가 고른 순서대로 나옵니다.</p>
|
||||||
|
</div>
|
||||||
|
{topics.length === 0 ? (
|
||||||
|
<p className="studio-empty-inline">
|
||||||
|
아직 주제가 없습니다. 주제·프로젝트 화면에서 먼저 만듭니다.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="studio-choice-list">
|
||||||
|
{topics.map((topic) => (
|
||||||
|
<li key={topic.id}>
|
||||||
|
<label className="studio-choice">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.topicIds.includes(topic.id ?? "")}
|
||||||
|
onChange={() => toggleTopic(topic.id ?? "")}
|
||||||
|
/>
|
||||||
|
<span>{topic.name}</span>
|
||||||
|
<small>{topic.slug}</small>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
className="studio-editor-section"
|
||||||
|
aria-labelledby="studio-project-narrative-title"
|
||||||
|
>
|
||||||
|
<div className="studio-editor-section-heading">
|
||||||
|
<p className="studio-eyebrow">NARRATIVE</p>
|
||||||
|
<h2 id="studio-project-narrative-title">목적</h2>
|
||||||
|
<p>
|
||||||
|
프로젝트 화면의 “프로젝트가 지키는 기준”이 이 글을 그립니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/*
|
||||||
|
경계와 시스템 개요 칸이 여기 함께 있었다. 저장은 되지만 공개 화면 어디에도
|
||||||
|
나오지 않는다 — 프로젝트 화면이 그리는 것은 목적 하나다. 쓰는 사람에게는 쓴 글이
|
||||||
|
어딘가 실릴 것처럼 보이는 칸이었고, 실제로는 아무도 읽지 않았다.
|
||||||
|
|
||||||
|
열의 데이터는 남겨 둔다(계약이 요구하고, 이미 적어 둔 글이 있을 수 있다). 저장할
|
||||||
|
때 서버가 가진 값을 그대로 돌려보내므로 지워지지 않는다.
|
||||||
|
*/}
|
||||||
|
<div className="studio-field-grid">
|
||||||
|
<label className="studio-field studio-field--wide">
|
||||||
|
<span>목적</span>
|
||||||
|
<textarea
|
||||||
|
className="studio-markdown"
|
||||||
|
value={draft.purposeMarkdown}
|
||||||
|
onChange={(event) => update({ purposeMarkdown: event.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
<small>마크다운을 씁니다.</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="studio-editor-footer">
|
||||||
|
<button className="studio-primary-button" type="submit" title="Ctrl+S" disabled={pending}>
|
||||||
|
{pending ? "저장하는 중" : "저장"}
|
||||||
|
</button>
|
||||||
|
<p className="studio-field-note">
|
||||||
|
{published
|
||||||
|
? "공개된 프로젝트입니다. 저장하면 공개 화면도 함께 갱신됩니다."
|
||||||
|
: "아직 비공개입니다. 주제·프로젝트 화면에서 게시해야 공개 화면에 나옵니다."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section
|
||||||
|
className="studio-editor-section"
|
||||||
|
aria-labelledby="studio-project-activity-title"
|
||||||
|
>
|
||||||
|
<div className="studio-editor-section-heading">
|
||||||
|
<p className="studio-eyebrow">ACTIVITY</p>
|
||||||
|
<h2 id="studio-project-activity-title">활동</h2>
|
||||||
|
<p>
|
||||||
|
기록을 게시하면 이 목록에 한 줄이 자동으로 남습니다. 손으로 적는 자리가 아닙니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activities.length === 0 ? (
|
||||||
|
<p className="studio-empty-inline">
|
||||||
|
아직 이 프로젝트에서 게시한 기록이 없습니다.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="studio-document-list">
|
||||||
|
{activities.map((activity) => (
|
||||||
|
<article className="studio-document-row" key={activity.id}>
|
||||||
|
<p className="studio-row-label">{activityTypeLabel(activity.activityType)}</p>
|
||||||
|
<div className="studio-document-title">
|
||||||
|
<h2>{activity.title}</h2>
|
||||||
|
<p>
|
||||||
|
{activity.visibility === "PUBLIC"
|
||||||
|
? "공개 타임라인에 나옵니다"
|
||||||
|
: "비공개 — 타임라인에 나오지 않습니다"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>게시</dt>
|
||||||
|
<dd>
|
||||||
|
<time dateTime={activity.occurredAt}>
|
||||||
|
{new Date(activity.occurredAt).toLocaleDateString("ko-KR", {
|
||||||
|
timeZone: "Asia/Seoul",
|
||||||
|
})}
|
||||||
|
</time>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>기록</dt>
|
||||||
|
<dd>{activity.origin === "AUTO" ? "게시가 남김" : "손으로 적음"}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { components } from "../../../contracts/studio/generated.ts";
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
import { createNewItemId } from "../../../domain/studio/local-id.ts";
|
||||||
import { FieldNotice, type FieldIssue } from "./field-issues.tsx";
|
import { FieldNotice, type FieldIssue } from "./field-issues.tsx";
|
||||||
import { OrderedTextList } from "./ordered-text-list.tsx";
|
import { OrderedTextList } from "./ordered-text-list.tsx";
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ export function QuestionFields({ draft, evidence, issues, onChange }: { draft: Q
|
|||||||
<label><span>선택지 {index + 1} 설명</span><textarea value={option.description} onChange={(event) => update({ options: orderedOptions(draft.options.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, description: event.currentTarget.value } : candidate)) })} /></label>
|
<label><span>선택지 {index + 1} 설명</span><textarea value={option.description} onChange={(event) => update({ options: orderedOptions(draft.options.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, description: event.currentTarget.value } : candidate)) })} /></label>
|
||||||
<div className="studio-item-actions"><button type="button" onClick={() => moveOption(index, -1)} disabled={index === 0}>위로</button><button type="button" onClick={() => moveOption(index, 1)} disabled={index === draft.options.length - 1}>아래로</button><button type="button" onClick={() => update({ options: orderedOptions(draft.options.filter((_, candidateIndex) => candidateIndex !== index)) })}>삭제</button></div>
|
<div className="studio-item-actions"><button type="button" onClick={() => moveOption(index, -1)} disabled={index === 0}>위로</button><button type="button" onClick={() => moveOption(index, 1)} disabled={index === draft.options.length - 1}>아래로</button><button type="button" onClick={() => update({ options: orderedOptions(draft.options.filter((_, candidateIndex) => candidateIndex !== index)) })}>삭제</button></div>
|
||||||
</div>)}
|
</div>)}
|
||||||
<button className="studio-add-item" type="button" disabled={draft.options.length >= 50} onClick={() => { if (draft.options.length < 50) update({ options: orderedOptions([...draft.options, { id: createLocalId("option"), title: "", description: "", order: draft.options.length }]) }); }}>선택지 추가</button>
|
<button className="studio-add-item" type="button" disabled={draft.options.length >= 50} onClick={() => { if (draft.options.length < 50) update({ options: orderedOptions([...draft.options, { id: createNewItemId(), title: "", description: "", order: draft.options.length }]) }); }}>선택지 추가</button>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<label className="studio-field studio-field--wide"><span>다음 검증</span><textarea value={draft.nextValidation} onChange={(event) => update({ nextValidation: event.currentTarget.value })} /><FieldNotice issues={issues} path="/nextValidation" /></label>
|
<label className="studio-field studio-field--wide"><span>다음 검증</span><textarea value={draft.nextValidation} onChange={(event) => update({ nextValidation: event.currentTarget.value })} /><FieldNotice issues={issues} path="/nextValidation" /></label>
|
||||||
{draft.questionStatus === "RESOLVED" && draft.resolution ? <fieldset className="studio-resolution-fields"><legend>해결 내용</legend>
|
{draft.questionStatus === "RESOLVED" && draft.resolution ? <fieldset className="studio-resolution-fields"><legend>해결 내용</legend>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { components } from "../../../contracts/studio/generated.ts";
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
import { createNewItemId } from "../../../domain/studio/local-id.ts";
|
||||||
import { FieldNotice, type FieldIssue } from "./field-issues.tsx";
|
import { FieldNotice, type FieldIssue } from "./field-issues.tsx";
|
||||||
import { OrderedTextList } from "./ordered-text-list.tsx";
|
import { OrderedTextList } from "./ordered-text-list.tsx";
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ export function ReferenceFields({ draft, issues, onChange }: { draft: ReferenceI
|
|||||||
<label><span>규칙 {index + 1} 본문</span><textarea value={rule.body} onChange={(event) => update({ rules: orderedRules(draft.rules.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, body: event.currentTarget.value } : candidate)) })} /></label>
|
<label><span>규칙 {index + 1} 본문</span><textarea value={rule.body} onChange={(event) => update({ rules: orderedRules(draft.rules.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, body: event.currentTarget.value } : candidate)) })} /></label>
|
||||||
<div className="studio-item-actions"><button type="button" onClick={() => moveRule(index, -1)} disabled={index === 0}>위로</button><button type="button" onClick={() => moveRule(index, 1)} disabled={index === draft.rules.length - 1}>아래로</button><button type="button" onClick={() => update({ rules: orderedRules(draft.rules.filter((_, candidateIndex) => candidateIndex !== index)) })}>삭제</button></div>
|
<div className="studio-item-actions"><button type="button" onClick={() => moveRule(index, -1)} disabled={index === 0}>위로</button><button type="button" onClick={() => moveRule(index, 1)} disabled={index === draft.rules.length - 1}>아래로</button><button type="button" onClick={() => update({ rules: orderedRules(draft.rules.filter((_, candidateIndex) => candidateIndex !== index)) })}>삭제</button></div>
|
||||||
</div>)}
|
</div>)}
|
||||||
<button className="studio-add-item" type="button" disabled={draft.rules.length >= 50} onClick={() => { if (draft.rules.length < 50) update({ rules: orderedRules([...draft.rules, { id: createLocalId("rule"), title: "", body: "", order: draft.rules.length }]) }); }}>규칙 추가</button>
|
<button className="studio-add-item" type="button" disabled={draft.rules.length >= 50} onClick={() => { if (draft.rules.length < 50) update({ rules: orderedRules([...draft.rules, { id: createNewItemId(), title: "", body: "", order: draft.rules.length }]) }); }}>규칙 추가</button>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<OrderedTextList label="적용 조건" items={draft.applyWhen} onChange={(applyWhen) => update({ applyWhen })} />
|
<OrderedTextList label="적용 조건" items={draft.applyWhen} onChange={(applyWhen) => update({ applyWhen })} />
|
||||||
<FieldNotice issues={issues} path="/applyWhen" />
|
<FieldNotice issues={issues} path="/applyWhen" />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { components } from "../../../contracts/studio/generated.ts";
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
import { createNewItemId } from "../../../domain/studio/local-id.ts";
|
||||||
|
|
||||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||||
type RelationInput = components["schemas"]["RelationInput"];
|
type RelationInput = components["schemas"]["RelationInput"];
|
||||||
@@ -29,7 +29,7 @@ export function RelationEditor({ relations, catalog, evidence = false, onChange
|
|||||||
<div className="studio-item-actions"><button type="button" onClick={() => move(index, -1)} disabled={index === 0}>위로</button><button type="button" onClick={() => move(index, 1)} disabled={index === relations.length - 1}>아래로</button><button type="button" onClick={() => onChange(ordered(relations.filter((_, candidateIndex) => candidateIndex !== index)))}>삭제</button></div>
|
<div className="studio-item-actions"><button type="button" onClick={() => move(index, -1)} disabled={index === 0}>위로</button><button type="button" onClick={() => move(index, 1)} disabled={index === relations.length - 1}>아래로</button><button type="button" onClick={() => onChange(ordered(relations.filter((_, candidateIndex) => candidateIndex !== index)))}>삭제</button></div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button className="studio-add-item" type="button" disabled={relations.length >= 20} onClick={() => { if (relations.length < 20) onChange(ordered([...relations, { id: createLocalId("relation"), targetId: null, reason: "", order: relations.length }])); }}>{noun} 추가</button>
|
<button className="studio-add-item" type="button" disabled={relations.length >= 20} onClick={() => { if (relations.length < 20) onChange(ordered([...relations, { id: createNewItemId(), targetId: null, reason: "", order: relations.length }])); }}>{noun} 추가</button>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ReleaseEditResponse,
|
||||||
|
ReleaseUpdateRequest,
|
||||||
|
} from "../../../contracts/management/contract.ts";
|
||||||
|
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
|
||||||
|
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
|
||||||
|
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||||
|
import { useSaveShortcut } from "./use-save-shortcut.ts";
|
||||||
|
import { useStudio } from "../use-studio.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 릴리즈 한 편의 편집 화면.
|
||||||
|
*
|
||||||
|
* <p>예전에는 이 폼이 릴리즈 목록 <em>아래</em>에 열렸다. 릴리즈가 열 개 스무 개로 늘면 편집하려고
|
||||||
|
* 목록 전체를 지나 내려가야 하고, 저장 버튼은 그보다 더 아래에 있다. 문서·프로젝트가 각자 편집
|
||||||
|
* 주소를 갖는 것과 같은 이유로 릴리즈도 자기 주소를 갖는다.
|
||||||
|
*
|
||||||
|
* <p>본문이 마크다운 여섯 칸으로 나뉜 것은 계약의 모양이자 릴리즈 노트의 성격이다. 한 칸짜리 자유
|
||||||
|
* 서술이었다면 "검증을 안 썼다"를 아무도 알아채지 못한다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 백엔드 {@code UpdateReleaseUseCase.CHANGE_TYPES} 와 같은 집합이다. */
|
||||||
|
const CHANGE_TYPES = [
|
||||||
|
{ value: "FEATURE", label: "기능" },
|
||||||
|
{ value: "FIX", label: "수정" },
|
||||||
|
{ value: "REFACTOR", label: "구조" },
|
||||||
|
{ value: "DOCS", label: "문서" },
|
||||||
|
{ value: "INFRA", label: "인프라" },
|
||||||
|
{ value: "BREAKING", label: "호환 깨짐" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
{ key: "reasonMarkdown", label: "왜 바꿨나" },
|
||||||
|
{ key: "changesMarkdown", label: "무엇을 바꿨나" },
|
||||||
|
{ key: "userImpactMarkdown", label: "사용자에게 달라지는 것" },
|
||||||
|
{ key: "implementationImpactMarkdown", label: "구현에 남는 것" },
|
||||||
|
{ key: "verificationMarkdown", label: "어떻게 검증했나" },
|
||||||
|
{ key: "knownLimitationsMarkdown", label: "아직 못 한 것" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type Draft = Readonly<{
|
||||||
|
expectedVersion: number;
|
||||||
|
versionLabel: string;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
releasedOn: string;
|
||||||
|
changeTypes: readonly string[];
|
||||||
|
reasonMarkdown: string;
|
||||||
|
changesMarkdown: string;
|
||||||
|
userImpactMarkdown: string;
|
||||||
|
implementationImpactMarkdown: string;
|
||||||
|
verificationMarkdown: string;
|
||||||
|
knownLimitationsMarkdown: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
function toDraft(release: ReleaseEditResponse): Draft {
|
||||||
|
return {
|
||||||
|
expectedVersion: release.version,
|
||||||
|
versionLabel: release.versionLabel,
|
||||||
|
title: release.title,
|
||||||
|
summary: release.summary ?? "",
|
||||||
|
releasedOn: release.releasedOn ?? "",
|
||||||
|
changeTypes: release.changeTypes ?? [],
|
||||||
|
reasonMarkdown: release.reasonMarkdown ?? "",
|
||||||
|
changesMarkdown: release.changesMarkdown ?? "",
|
||||||
|
userImpactMarkdown: release.userImpactMarkdown ?? "",
|
||||||
|
implementationImpactMarkdown: release.implementationImpactMarkdown ?? "",
|
||||||
|
verificationMarkdown: release.verificationMarkdown ?? "",
|
||||||
|
knownLimitationsMarkdown: release.knownLimitationsMarkdown ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Readonly<Record<string, string>> = {
|
||||||
|
DRAFT: "작성 중",
|
||||||
|
PUBLISHED: "공개",
|
||||||
|
ARCHIVED: "보관",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 경로 파라미터는 `unknown` 으로 들어온다 — 라우터가 코덱을 통과시킨 값이라도 타입은 좁혀 써야 한다. */
|
||||||
|
function routeId(value: unknown): string {
|
||||||
|
return typeof value === "string" ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReleaseEditor() {
|
||||||
|
const { params } = useRouteInput<"TECH_LOG_STUDIO_RELEASE_EDIT">();
|
||||||
|
const releaseId = routeId(params.id);
|
||||||
|
const { managementGateway, setRequestAnnouncement } = useStudio();
|
||||||
|
|
||||||
|
const [draft, setDraft] = useState<Draft | null>(null);
|
||||||
|
const [status, setStatus] = 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 managementGateway.getRelease(releaseId).then(
|
||||||
|
(release) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setDraft(toDraft(release));
|
||||||
|
setStatus(release.workflowStatus);
|
||||||
|
setError("");
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (!cancelled) setError("릴리즈를 불러오지 못했습니다.");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [managementGateway, releaseId, generation]);
|
||||||
|
|
||||||
|
const update = (patch: Partial<Draft>) =>
|
||||||
|
setDraft((current) => (current === null ? current : { ...current, ...patch }));
|
||||||
|
|
||||||
|
const requestOf = (current: Draft): 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) return;
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const saved = await managementGateway.updateRelease(releaseId, requestOf(draft));
|
||||||
|
setDraft(toDraft(saved));
|
||||||
|
setRequestAnnouncement(`릴리즈 ${saved.versionLabel} 을(를) 저장했습니다.`);
|
||||||
|
reload();
|
||||||
|
} catch (failure) {
|
||||||
|
setError(managementFailureMessage(failure, "저장하지 못했습니다."));
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const publish = async () => {
|
||||||
|
if (pending || draft === null) return;
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
/*
|
||||||
|
먼저 저장한다. 발행은 서버에 저장된 릴리즈를 검사하는데(`PublishReleaseUseCase`), 예전에는
|
||||||
|
저장하지 않고 발행만 불렀다 — 화면의 칸을 다 채우고 공개를 눌러도 서버 쪽은 여전히 빈
|
||||||
|
초안이라 "모두 채워져야 합니다" 가 떴다. 채웠는데 안 된다는 말이 나온 이유가 이것이다.
|
||||||
|
*/
|
||||||
|
const saved = await managementGateway.updateRelease(releaseId, requestOf(draft));
|
||||||
|
setDraft(toDraft(saved));
|
||||||
|
const published = await managementGateway.publishRelease(releaseId, saved.version);
|
||||||
|
setRequestAnnouncement(`릴리즈를 공개했습니다: ${published.canonicalPath}`);
|
||||||
|
reload();
|
||||||
|
} catch (failure) {
|
||||||
|
// 무엇이 모자란지는 서버가 안다. 예전에는 그 답을 버리고 필수 항목을 전부 나열했는데,
|
||||||
|
// 그러면 이미 채운 칸까지 비었다고 말하게 된다.
|
||||||
|
setError(
|
||||||
|
managementFailureMessage(
|
||||||
|
failure,
|
||||||
|
"공개하지 못했습니다. 버전, 제목, 한 줄 요약, 변경 유형, 변경 내용, 검증, 공개일이 모두 채워져야 합니다.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const archive = async () => {
|
||||||
|
if (pending || draft === null) return;
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await managementGateway.archiveRelease(releaseId, draft.expectedVersion);
|
||||||
|
setRequestAnnouncement("릴리즈를 공개에서 내렸습니다.");
|
||||||
|
reload();
|
||||||
|
} catch (failure) {
|
||||||
|
setError(managementFailureMessage(failure, "공개에서 내리지 못했습니다."));
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useSaveShortcut(save, draft !== null && !pending);
|
||||||
|
|
||||||
|
const toggleChangeType = (value: string) => {
|
||||||
|
if (draft === null) return;
|
||||||
|
update({
|
||||||
|
changeTypes: draft.changeTypes.includes(value)
|
||||||
|
? draft.changeTypes.filter((entry) => entry !== value)
|
||||||
|
: [...draft.changeTypes, value],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="studio-page studio-editor-page">
|
||||||
|
<header className="studio-page-top">
|
||||||
|
<div className="studio-page-heading">
|
||||||
|
<p className="studio-eyebrow">
|
||||||
|
RELEASE · {STATUS_LABELS[status] ?? status}
|
||||||
|
</p>
|
||||||
|
<h1>{draft?.title || "릴리즈"}</h1>
|
||||||
|
<p>공개 변경 기록 화면과 푸터 링크가 이 값을 그대로 읽습니다.</p>
|
||||||
|
</div>
|
||||||
|
<GuardedStudioLink className="studio-primary-action" href="/studio/releases">
|
||||||
|
릴리즈 목록으로
|
||||||
|
</GuardedStudioLink>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p className="studio-screen-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{draft === null && !error ? (
|
||||||
|
<p className="studio-loading" role="status">
|
||||||
|
릴리즈를 불러오는 중입니다.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{draft === null ? null : (
|
||||||
|
<section className="studio-editor-section" aria-labelledby="studio-release-edit-title">
|
||||||
|
<div className="studio-editor-section-heading">
|
||||||
|
<p className="studio-eyebrow">RELEASE · {draft.versionLabel}</p>
|
||||||
|
<h2 id="studio-release-edit-title">{draft.title || "릴리즈"}</h2>
|
||||||
|
<p>변경 기록 화면과 푸터 링크가 이 값을 그대로 읽습니다.</p>
|
||||||
|
</div>
|
||||||
|
<div className="studio-field-grid">
|
||||||
|
<label className="studio-field">
|
||||||
|
<span>버전</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={draft.versionLabel}
|
||||||
|
placeholder="0.1.0"
|
||||||
|
onChange={(event) => update({ versionLabel: event.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
{/*
|
||||||
|
새 릴리즈는 `draft-…` 라는 자리표시자 버전으로 만들어지고, 서버는 그것이 남아
|
||||||
|
있으면 공개를 거절한다(`ReleaseDrafts.isPlaceholder`). 화면에는 값이 채워져
|
||||||
|
보이므로 왜 거절당하는지 알 길이 없었다.
|
||||||
|
*/}
|
||||||
|
{draft.versionLabel.startsWith("draft-") ? (
|
||||||
|
<span className="studio-field-notice studio-field-notice--error" role="alert">
|
||||||
|
자리표시자 버전입니다. 공개하려면 실제 버전으로 바꿔 주세요 (예: 0.2.0).
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</label>
|
||||||
|
<label className="studio-field">
|
||||||
|
<span>제목</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={draft.title}
|
||||||
|
onChange={(event) => update({ title: event.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="studio-field">
|
||||||
|
<span>공개일</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={draft.releasedOn}
|
||||||
|
onChange={(event) => update({ releasedOn: event.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="studio-field studio-field--wide">
|
||||||
|
<span>한 줄 요약</span>
|
||||||
|
<textarea
|
||||||
|
value={draft.summary}
|
||||||
|
onChange={(event) => update({ summary: event.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset className="studio-checkbox-set">
|
||||||
|
<legend>변경 유형</legend>
|
||||||
|
{CHANGE_TYPES.map((changeType) => (
|
||||||
|
<label key={changeType.value} className="studio-field studio-field--checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.changeTypes.includes(changeType.value)}
|
||||||
|
onChange={() => toggleChangeType(changeType.value)}
|
||||||
|
/>
|
||||||
|
<span>{changeType.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div className="studio-field-grid">
|
||||||
|
{SECTIONS.map((section) => (
|
||||||
|
<label key={section.key} className="studio-field studio-field--wide">
|
||||||
|
<span>{section.label}</span>
|
||||||
|
<textarea
|
||||||
|
value={draft[section.key]}
|
||||||
|
onChange={(event) => update({ [section.key]: event.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="studio-create-footer">
|
||||||
|
<button
|
||||||
|
className="studio-primary-button"
|
||||||
|
type="button"
|
||||||
|
title="Ctrl+S"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => void save()}
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="studio-secondary-button"
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => void publish()}
|
||||||
|
>
|
||||||
|
공개
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="studio-secondary-button"
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => void archive()}
|
||||||
|
>
|
||||||
|
공개에서 내리기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
ReleaseUpdateRequest,
|
ReleaseUpdateRequest,
|
||||||
} from "../../../contracts/management/contract.ts";
|
} from "../../../contracts/management/contract.ts";
|
||||||
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
|
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
|
||||||
|
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||||
import { useStudio } from "../use-studio.ts";
|
import { useStudio } from "../use-studio.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,13 +82,13 @@ const STATUS_LABELS: Readonly<Record<string, string>> = {
|
|||||||
export function ReleaseManager() {
|
export function ReleaseManager() {
|
||||||
const { managementGateway, setRequestAnnouncement } = useStudio();
|
const { managementGateway, setRequestAnnouncement } = useStudio();
|
||||||
const [releases, setReleases] = useState<ReleaseIndexItem[] | null>(null);
|
const [releases, setReleases] = useState<ReleaseIndexItem[] | null>(null);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
||||||
const [draft, setDraft] = useState<Draft | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
const [generation, setGeneration] = useState(0);
|
const [generation, setGeneration] = useState(0);
|
||||||
const [newTitle, setNewTitle] = useState("");
|
const [newTitle, setNewTitle] = useState("");
|
||||||
|
/* 방금 만든 릴리즈를 목록에서 짚어 준다 — 목록이 길면 어느 것이 새것인지 알기 어렵다. */
|
||||||
|
const [createdId, setCreatedId] = useState<string | null>(null);
|
||||||
|
|
||||||
const reload = useCallback(() => setGeneration((value) => value + 1), []);
|
const reload = useCallback(() => setGeneration((value) => value + 1), []);
|
||||||
|
|
||||||
@@ -112,29 +113,6 @@ export function ReleaseManager() {
|
|||||||
};
|
};
|
||||||
}, [managementGateway, generation]);
|
}, [managementGateway, generation]);
|
||||||
|
|
||||||
// 선택된 릴리즈의 본문은 목록에 없다 — 목록 행은 마크다운을 싣지 않으므로 따로 읽는다.
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedId === null) {
|
|
||||||
setDraft(null);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
let cancelled = false;
|
|
||||||
void managementGateway.getRelease(selectedId).then(
|
|
||||||
(release) => {
|
|
||||||
if (!cancelled) setDraft(toDraft(release));
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
if (!cancelled) setError("릴리즈를 불러오지 못했습니다.");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [managementGateway, selectedId, generation]);
|
|
||||||
|
|
||||||
const update = (patch: Partial<Draft>) =>
|
|
||||||
setDraft((current) => (current === null ? current : { ...current, ...patch }));
|
|
||||||
|
|
||||||
const submitNew = async (event: FormEvent) => {
|
const submitNew = async (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (pending) return;
|
if (pending) return;
|
||||||
@@ -148,8 +126,10 @@ export function ReleaseManager() {
|
|||||||
try {
|
try {
|
||||||
const created = await managementGateway.createRelease(title);
|
const created = await managementGateway.createRelease(title);
|
||||||
setNewTitle("");
|
setNewTitle("");
|
||||||
setRequestAnnouncement(`릴리즈 ${title} 초안을 만들었습니다.`);
|
setRequestAnnouncement(
|
||||||
setSelectedId(created.id);
|
`릴리즈 ${title} 초안을 만들었습니다. 목록에서 편집을 눌러 내용을 채웁니다.`,
|
||||||
|
);
|
||||||
|
setCreatedId(created.id);
|
||||||
reload();
|
reload();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(managementFailureMessage(error, "릴리즈를 만들지 못했습니다."));
|
setError(managementFailureMessage(error, "릴리즈를 만들지 못했습니다."));
|
||||||
@@ -158,77 +138,12 @@ export function ReleaseManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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);
|
|
||||||
setDraft(toDraft(saved));
|
|
||||||
setRequestAnnouncement(`릴리즈 ${saved.versionLabel} 을(를) 저장했습니다.`);
|
|
||||||
reload();
|
|
||||||
} catch (error) {
|
|
||||||
setError(managementFailureMessage(error, "저장하지 못했습니다."));
|
|
||||||
} finally {
|
|
||||||
setPending(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const publish = async () => {
|
|
||||||
if (pending || draft === null || selectedId === null) return;
|
|
||||||
setPending(true);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const published = await managementGateway.publishRelease(selectedId, draft.expectedVersion);
|
|
||||||
setRequestAnnouncement(`릴리즈를 공개했습니다: ${published.canonicalPath}`);
|
|
||||||
reload();
|
|
||||||
} catch {
|
|
||||||
// 발행은 저장보다 요구가 많다. 무엇이 비었는지는 서버가 알고 있지만, 그 목록을 그대로
|
|
||||||
// 옮기려면 오류 details 를 읽는 화면이 필요하다 — 여기서는 필수 항목을 그대로 안내한다.
|
|
||||||
setError(
|
|
||||||
"공개하지 못했습니다. 버전, 제목, 한 줄 요약, 변경 유형, 변경 내용, 검증, 공개일이 모두 채워져야 합니다.",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setPending(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const archive = async () => {
|
|
||||||
if (pending || draft === null || selectedId === null) return;
|
|
||||||
setPending(true);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
await managementGateway.archiveRelease(selectedId, draft.expectedVersion);
|
|
||||||
setRequestAnnouncement("릴리즈를 공개에서 내렸습니다.");
|
|
||||||
reload();
|
|
||||||
} catch (error) {
|
|
||||||
setError(managementFailureMessage(error, "공개에서 내리지 못했습니다."));
|
|
||||||
} finally {
|
|
||||||
setPending(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const remove = async (release: ReleaseIndexItem) => {
|
const remove = async (release: ReleaseIndexItem) => {
|
||||||
if (pending) return;
|
if (pending) return;
|
||||||
setPending(true);
|
setPending(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
await managementGateway.deleteRelease(release.id, release.version);
|
await managementGateway.deleteRelease(release.id, release.version);
|
||||||
if (selectedId === release.id) setSelectedId(null);
|
|
||||||
setRequestAnnouncement(`릴리즈 ${release.versionLabel} 을(를) 삭제했습니다.`);
|
setRequestAnnouncement(`릴리즈 ${release.versionLabel} 을(를) 삭제했습니다.`);
|
||||||
reload();
|
reload();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -238,14 +153,6 @@ export function ReleaseManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleChangeType = (value: string) => {
|
|
||||||
if (draft === null) return;
|
|
||||||
const next = draft.changeTypes.includes(value)
|
|
||||||
? draft.changeTypes.filter((entry) => entry !== value)
|
|
||||||
: [...draft.changeTypes, value];
|
|
||||||
update({ changeTypes: next });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="studio-page studio-documents-page">
|
<div className="studio-page studio-documents-page">
|
||||||
<header className="studio-page-top">
|
<header className="studio-page-top">
|
||||||
@@ -292,29 +199,38 @@ export function ReleaseManager() {
|
|||||||
<article
|
<article
|
||||||
key={release.id}
|
key={release.id}
|
||||||
className="studio-document-row"
|
className="studio-document-row"
|
||||||
aria-current={selectedId === release.id ? "true" : undefined}
|
aria-current={createdId === release.id ? "true" : undefined}
|
||||||
>
|
>
|
||||||
|
{/*
|
||||||
|
첫 칸의 유형 라벨은 목록 격자의 첫 열이다. 빠뜨리면 제목이 그 자리로 당겨져
|
||||||
|
작업본·주제 목록과 줄이 맞지 않는다.
|
||||||
|
*/}
|
||||||
|
<p className="studio-row-label">RELEASE</p>
|
||||||
<div className="studio-document-title">
|
<div className="studio-document-title">
|
||||||
<h2>{release.title}</h2>
|
<h2>{release.title}</h2>
|
||||||
<p>
|
<p>{release.publication.canonicalPath ?? "아직 공개하지 않음"}</p>
|
||||||
{release.versionLabel}
|
|
||||||
{release.releasedOn ? ` · ${release.releasedOn}` : ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<dl>
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>버전</dt>
|
||||||
|
<dd>{release.versionLabel}</dd>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>상태</dt>
|
<dt>상태</dt>
|
||||||
<dd>{STATUS_LABELS[release.workflowStatus] ?? release.workflowStatus}</dd>
|
<dd>{STATUS_LABELS[release.workflowStatus] ?? release.workflowStatus}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>공개일</dt>
|
||||||
|
<dd>{release.releasedOn || "미정"}</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<button
|
<div className="studio-row-actions">
|
||||||
|
<GuardedStudioLink
|
||||||
className="studio-secondary-button"
|
className="studio-secondary-button"
|
||||||
type="button"
|
href={`/studio/releases/${release.id}`}
|
||||||
disabled={pending}
|
|
||||||
onClick={() => setSelectedId(selectedId === release.id ? null : release.id)}
|
|
||||||
>
|
>
|
||||||
{selectedId === release.id ? "닫기" : "편집"}
|
편집
|
||||||
</button>
|
</GuardedStudioLink>
|
||||||
<button
|
<button
|
||||||
className="studio-secondary-button"
|
className="studio-secondary-button"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -323,106 +239,13 @@ export function ReleaseManager() {
|
|||||||
>
|
>
|
||||||
삭제
|
삭제
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{draft === null ? null : (
|
|
||||||
<section aria-label="릴리즈 편집">
|
|
||||||
<h2 className="studio-section-title">{draft.title || "릴리즈"} 편집</h2>
|
|
||||||
<div className="studio-field-grid">
|
|
||||||
<label className="studio-field">
|
|
||||||
<span>버전</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={draft.versionLabel}
|
|
||||||
placeholder="0.1.0"
|
|
||||||
onChange={(event) => update({ versionLabel: event.currentTarget.value })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="studio-field">
|
|
||||||
<span>제목</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={draft.title}
|
|
||||||
onChange={(event) => update({ title: event.currentTarget.value })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="studio-field">
|
|
||||||
<span>공개일</span>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={draft.releasedOn}
|
|
||||||
onChange={(event) => update({ releasedOn: event.currentTarget.value })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="studio-field studio-field--wide">
|
|
||||||
<span>한 줄 요약</span>
|
|
||||||
<textarea
|
|
||||||
value={draft.summary}
|
|
||||||
onChange={(event) => update({ summary: event.currentTarget.value })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<fieldset className="studio-field-grid">
|
|
||||||
<legend className="studio-field">
|
|
||||||
<span>변경 유형</span>
|
|
||||||
</legend>
|
|
||||||
{CHANGE_TYPES.map((changeType) => (
|
|
||||||
<label key={changeType.value} className="studio-field studio-field--checkbox">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={draft.changeTypes.includes(changeType.value)}
|
|
||||||
onChange={() => toggleChangeType(changeType.value)}
|
|
||||||
/>
|
|
||||||
<span>{changeType.label}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<div className="studio-field-grid">
|
|
||||||
{SECTIONS.map((section) => (
|
|
||||||
<label key={section.key} className="studio-field studio-field--wide">
|
|
||||||
<span>{section.label}</span>
|
|
||||||
<textarea
|
|
||||||
value={draft[section.key]}
|
|
||||||
onChange={(event) => update({ [section.key]: event.currentTarget.value })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="studio-create-footer">
|
|
||||||
<button
|
|
||||||
className="studio-primary-button"
|
|
||||||
type="button"
|
|
||||||
disabled={pending}
|
|
||||||
onClick={() => void save()}
|
|
||||||
>
|
|
||||||
저장
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="studio-secondary-button"
|
|
||||||
type="button"
|
|
||||||
disabled={pending}
|
|
||||||
onClick={() => void publish()}
|
|
||||||
>
|
|
||||||
공개
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="studio-secondary-button"
|
|
||||||
type="button"
|
|
||||||
disabled={pending}
|
|
||||||
onClick={() => void archive()}
|
|
||||||
>
|
|
||||||
공개에서 내리기
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { StudioDashboard as StudioDashboardData } from "../../../contracts/
|
|||||||
import type { components } from "../../../contracts/studio/generated.ts";
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
import { useStudio } from "../use-studio.ts";
|
import { useStudio } from "../use-studio.ts";
|
||||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||||
|
import { HomeFocusEditor } from "./home-focus-editor.tsx";
|
||||||
|
|
||||||
type DocumentSummary = components["schemas"]["DocumentSummary"];
|
type DocumentSummary = components["schemas"]["DocumentSummary"];
|
||||||
|
|
||||||
@@ -158,6 +159,7 @@ export function StudioDashboard() {
|
|||||||
href="/studio/documents"
|
href="/studio/documents"
|
||||||
empty="게시 준비가 끝난 문서가 없습니다."
|
empty="게시 준비가 끝난 문서가 없습니다."
|
||||||
/>
|
/>
|
||||||
|
<HomeFocusEditor />
|
||||||
<section className="studio-work-section">
|
<section className="studio-work-section">
|
||||||
<div className="studio-section-title">
|
<div className="studio-section-title">
|
||||||
<h2>최근 게시</h2>
|
<h2>최근 게시</h2>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState, type FormEvent } from "react";
|
|||||||
|
|
||||||
import type { ProjectIndexItem, TopicEdit } from "../../../contracts/management/contract.ts";
|
import type { ProjectIndexItem, TopicEdit } from "../../../contracts/management/contract.ts";
|
||||||
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
|
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
|
||||||
|
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||||
import { slugFromName } from "./slug-from-name.ts";
|
import { slugFromName } from "./slug-from-name.ts";
|
||||||
import { useStudio } from "../use-studio.ts";
|
import { useStudio } from "../use-studio.ts";
|
||||||
|
|
||||||
@@ -15,6 +16,23 @@ import { useStudio } from "../use-studio.ts";
|
|||||||
* <p>새 CSS 를 만들지 않는다. 작업본 목록이 쓰는 클래스만 재사용하므로 이 화면은 Studio 의
|
* <p>새 CSS 를 만들지 않는다. 작업본 목록이 쓰는 클래스만 재사용하므로 이 화면은 Studio 의
|
||||||
* 나머지와 같은 간격·타이포·색을 그대로 따른다.
|
* 나머지와 같은 간격·타이포·색을 그대로 따른다.
|
||||||
*/
|
*/
|
||||||
|
/** 공개 프로젝트 화면과 같은 말을 쓴다 — 두 화면이 다른 낱말을 쓰면 같은 것인지 알기 어렵다. */
|
||||||
|
const phaseLabel: Record<string, string> = {
|
||||||
|
RESEARCH: "조사",
|
||||||
|
DESIGN: "설계",
|
||||||
|
IMPLEMENTATION: "구현",
|
||||||
|
VERIFICATION: "검증",
|
||||||
|
MAINTENANCE: "유지",
|
||||||
|
PAUSED: "중단",
|
||||||
|
COMPLETED: "완료",
|
||||||
|
};
|
||||||
|
|
||||||
|
const visibilityLabel: Record<string, string> = {
|
||||||
|
PRIVATE: "비공개",
|
||||||
|
UNLISTED: "주소로만",
|
||||||
|
PUBLIC: "공개",
|
||||||
|
};
|
||||||
|
|
||||||
export function TaxonomyManager() {
|
export function TaxonomyManager() {
|
||||||
const { managementGateway, setRequestAnnouncement } = useStudio();
|
const { managementGateway, setRequestAnnouncement } = useStudio();
|
||||||
const [topics, setTopics] = useState<TopicEdit[] | null>(null);
|
const [topics, setTopics] = useState<TopicEdit[] | null>(null);
|
||||||
@@ -159,6 +177,37 @@ export function TaxonomyManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
프로젝트 게시는 문서 게시와 별개다. 문서를 게시해도 그 문서가 속한 프로젝트는 비공개로 남고,
|
||||||
|
공개 화면들(프로젝트 목록·프로필의 "현재 프로젝트"·홈의 focus)은 전부 게시된 프로젝트만 읽는다.
|
||||||
|
그래서 그 화면들이 조용히 비어 있었다 — 게시할 방법 자체가 없었기 때문이다.
|
||||||
|
*/
|
||||||
|
const togglePublish = async (project: ProjectIndexItem) => {
|
||||||
|
if (pending) return;
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
const published = project.targetVisibility !== "PRIVATE";
|
||||||
|
try {
|
||||||
|
if (published) {
|
||||||
|
await managementGateway.unpublishProject(project.id, project.version);
|
||||||
|
setRequestAnnouncement(`프로젝트 ${project.name} 을(를) 비공개로 되돌렸습니다.`);
|
||||||
|
} else {
|
||||||
|
await managementGateway.publishProject(project.id, project.version, "PUBLIC");
|
||||||
|
setRequestAnnouncement(`프로젝트 ${project.name} 을(를) 공개했습니다.`);
|
||||||
|
}
|
||||||
|
reload();
|
||||||
|
} catch (error) {
|
||||||
|
setError(
|
||||||
|
managementFailureMessage(
|
||||||
|
error,
|
||||||
|
published ? "프로젝트를 비공개로 되돌리지 못했습니다." : "프로젝트를 게시하지 못했습니다.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="studio-page studio-documents-page">
|
<div className="studio-page studio-documents-page">
|
||||||
<header className="studio-page-top">
|
<header className="studio-page-top">
|
||||||
@@ -269,19 +318,45 @@ export function TaxonomyManager() {
|
|||||||
<article className="studio-document-row" key={project.id}>
|
<article className="studio-document-row" key={project.id}>
|
||||||
<p className="studio-row-label">PROJECT</p>
|
<p className="studio-row-label">PROJECT</p>
|
||||||
<div className="studio-document-title">
|
<div className="studio-document-title">
|
||||||
<h2>{project.name}</h2>
|
<h2>
|
||||||
|
<GuardedStudioLink href={`/studio/projects/${project.id}`}>
|
||||||
|
{project.name}
|
||||||
|
</GuardedStudioLink>
|
||||||
|
</h2>
|
||||||
<p>{project.currentObjective ?? "목표 미지정"}</p>
|
<p>{project.currentObjective ?? "목표 미지정"}</p>
|
||||||
</div>
|
</div>
|
||||||
<dl>
|
<dl>
|
||||||
<div>
|
<div>
|
||||||
<dt>단계</dt>
|
<dt>단계</dt>
|
||||||
<dd>{project.phase}</dd>
|
<dd>{phaseLabel[project.phase] ?? project.phase}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>공개</dt>
|
<dt>공개</dt>
|
||||||
<dd>{project.targetVisibility}</dd>
|
{/*
|
||||||
|
"게시됨" 이 아니라 어디까지 보이는지를 적는다 — UNLISTED 는 주소를 아는
|
||||||
|
사람만 닿고 목록에는 없다. 둘을 같은 말로 적으면 왜 목록에 안 나오는지
|
||||||
|
설명할 방법이 없다.
|
||||||
|
*/}
|
||||||
|
<dd>{visibilityLabel[project.targetVisibility] ?? project.targetVisibility}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>다음</dt>
|
||||||
|
<dd>
|
||||||
|
<GuardedStudioLink href={`/studio/projects/${project.id}`}>
|
||||||
|
내용 편집
|
||||||
|
</GuardedStudioLink>
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
<div className="studio-row-actions">
|
||||||
|
<button
|
||||||
|
className="studio-secondary-button"
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => void togglePublish(project)}
|
||||||
|
>
|
||||||
|
{project.targetVisibility === "PRIVATE" ? "게시" : "게시 취소"}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="studio-secondary-button"
|
className="studio-secondary-button"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -290,6 +365,7 @@ export function TaxonomyManager() {
|
|||||||
>
|
>
|
||||||
삭제
|
삭제
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ctrl+S(맥에서는 Cmd+S)로 저장한다.
|
||||||
|
*
|
||||||
|
* <p>브라우저의 기본 동작은 "페이지를 파일로 저장"이다. 글을 쓰다가 그 손버릇이 나오면 저장 대화상자가
|
||||||
|
* 뜨고 편집한 내용은 그대로 남는다 — 저장한 줄 알고 창을 닫으면 잃는다. 그래서 기본 동작을 막고 이
|
||||||
|
* 화면의 저장으로 돌린다.
|
||||||
|
*
|
||||||
|
* <p>`enabled` 가 거짓이면 아무것도 하지 않는다. 저장할 것이 없거나 이미 저장 중일 때 단축키가 요청을
|
||||||
|
* 겹쳐 보내지 않게 하는 것은 호출자의 몫이다 — 화면마다 "지금 저장할 수 있는가"의 뜻이 다르다.
|
||||||
|
*/
|
||||||
|
export function useSaveShortcut(
|
||||||
|
save: () => void | Promise<void>,
|
||||||
|
enabled = true,
|
||||||
|
) {
|
||||||
|
useEffect(() => {
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key !== "s" && event.key !== "S") return;
|
||||||
|
if (!(event.ctrlKey || event.metaKey)) return;
|
||||||
|
// Ctrl+Shift+S 는 다른 뜻으로 쓰는 곳이 있어 가로채지 않는다.
|
||||||
|
if (event.shiftKey || event.altKey) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (enabled) void save();
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [enabled, save]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { ProjectEditor } from "../components/project-editor.tsx";
|
||||||
|
|
||||||
|
export function ProjectEditPage() {
|
||||||
|
return <ProjectEditor />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { ReleaseEditor } from "../components/release-editor.tsx";
|
||||||
|
|
||||||
|
export function ReleaseEditPage() {
|
||||||
|
return <ReleaseEditor />;
|
||||||
|
}
|
||||||
@@ -17,7 +17,12 @@
|
|||||||
--warning-soft: #fff7ed;
|
--warning-soft: #fff7ed;
|
||||||
--code-canvas: #15181d;
|
--code-canvas: #15181d;
|
||||||
--shell: 1180px;
|
--shell: 1180px;
|
||||||
--body-copy: 42rem;
|
/*
|
||||||
|
읽는 단의 폭. 본문·코드블록·표·콜아웃·그림이 모두 이 값을 쓰므로 한 곳만 바꾸면 단 전체가 함께
|
||||||
|
움직인다. 42rem(672px)은 영문 기준 측정값이었고, 한글 본문에서는 좁아 보이는 데다 바로 위의
|
||||||
|
유형/프로젝트/게시 줄이 shell 전체(1180px)를 쓰고 있어 대비가 더 심했다.
|
||||||
|
*/
|
||||||
|
--body-copy: 56rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -95,6 +100,102 @@ a {
|
|||||||
same reserved height: with only `main` covered the footer still sat at the
|
same reserved height: with only `main` covered the footer still sat at the
|
||||||
viewport bottom during loading and then dropped out of view when the content
|
viewport bottom during loading and then dropped out of view when the content
|
||||||
arrived, which is the whole 0.192 the page scored. */
|
arrived, which is the whole 0.192 the page scored. */
|
||||||
|
/*
|
||||||
|
로그인 관문.
|
||||||
|
|
||||||
|
이 화면은 공개 셸과 Studio 셸 양쪽에서 나온다 — 보호된 주소를 열면 그 주소가 속한 레이아웃 안에
|
||||||
|
이 자리가 대신 그려진다. 그래서 두 곳 모두에 있는 토큰만 쓴다.
|
||||||
|
|
||||||
|
카드로 세우는 이유는 이것이 "화면을 못 그렸다"가 아니라 "여기서 한 걸음 더 필요하다"이기
|
||||||
|
때문이다. 오류 화면과 같은 모양이면 작성자는 무언가 고장 난 줄로 읽는다.
|
||||||
|
*/
|
||||||
|
.auth-gate {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding-block: clamp(64px, 12vh, 140px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-gate .auth-gate__card {
|
||||||
|
width: min(100%, 34rem);
|
||||||
|
padding: clamp(32px, 5vw, 52px);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-gate .page-header {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-gate .page-header__eyebrow {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
color: var(--signal);
|
||||||
|
font-family: "IBM Plex Mono", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-gate .page-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(27px, 3.2vw, 33px);
|
||||||
|
font-weight: 660;
|
||||||
|
letter-spacing: -0.035em;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-gate .page-header__description {
|
||||||
|
margin: 16px 0 0;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-gate .auth-gate__actions {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 32px;
|
||||||
|
padding-top: 28px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 이 버튼은 화면에서 유일한 행동이다. 폭을 채워 어디를 눌러야 하는지 묻지 않게 한다. */
|
||||||
|
.auth-gate .auth-gate__actions button {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 48px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
padding-inline: 20px;
|
||||||
|
border: 1px solid var(--signal);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--signal);
|
||||||
|
color: #fff;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 650;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-gate .auth-gate__actions button:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-gate .auth-gate__return {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--faint);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-gate .ui-terminal-error {
|
||||||
|
margin: 20px 0 0;
|
||||||
|
color: var(--warning);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.site-frame > main,
|
.site-frame > main,
|
||||||
.site-frame > .ui-page {
|
.site-frame > .ui-page {
|
||||||
/* Takes the slack so the footer stays put whether the route rendered a long
|
/* Takes the slack so the footer stays put whether the route rendered a long
|
||||||
@@ -1329,11 +1430,18 @@ dialog::backdrop {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 모달 dialog 를 화면 가운데에 둔다. UA 기본값(margin:auto)에 맡기면 이 빌드에서는 좌상단에
|
||||||
|
붙는다 — .search-dialog 와 Studio 의 .studio-unsaved-dialog 가 같은 이유로 position/inset/
|
||||||
|
margin 을 명시한다. 여기만 빠져 있어 "크게 보기" 가 왼쪽 위에 열렸다. */
|
||||||
.figure-dialog {
|
.figure-dialog {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
margin: auto;
|
||||||
width: min(1180px, calc(100% - 48px));
|
width: min(1180px, calc(100% - 48px));
|
||||||
max-width: none;
|
max-width: none;
|
||||||
|
max-height: calc(100dvh - 48px);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: hidden;
|
overflow: auto;
|
||||||
border: 1px solid var(--line-strong);
|
border: 1px solid var(--line-strong);
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
background: var(--paper);
|
background: var(--paper);
|
||||||
@@ -2174,6 +2282,20 @@ dialog::backdrop {
|
|||||||
max-width: 920px;
|
max-width: 920px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
문서 한 편은 하나의 단으로 읽힌다.
|
||||||
|
|
||||||
|
예전에는 세 조각이 저마다 다른 폭이었다 — 머리말 920px, 사실 줄은 shell 전체 1180px, 본문은
|
||||||
|
672px 를 가운데 정렬. 그래서 눈이 왼쪽 끝을 세 번 다시 찾아야 했고, 가장 좁은 본문이 가장 넓은
|
||||||
|
사실 줄 안에 갇힌 것처럼 보였다. 셋을 같은 폭·같은 왼쪽 끝에 세운다.
|
||||||
|
*/
|
||||||
|
.public-document-header,
|
||||||
|
.document-snapshot,
|
||||||
|
.document-facts {
|
||||||
|
width: min(100%, var(--body-copy));
|
||||||
|
max-width: var(--body-copy);
|
||||||
|
}
|
||||||
|
|
||||||
.public-page-header h1,
|
.public-page-header h1,
|
||||||
.public-document-header h1 {
|
.public-document-header h1 {
|
||||||
margin: 15px 0 18px;
|
margin: 15px 0 18px;
|
||||||
@@ -2297,7 +2419,7 @@ dialog::backdrop {
|
|||||||
.reference-purpose,
|
.reference-purpose,
|
||||||
.document-relations {
|
.document-relations {
|
||||||
width: min(100%, var(--body-copy));
|
width: min(100%, var(--body-copy));
|
||||||
margin: 82px auto 0;
|
margin: 82px 0 0;
|
||||||
}
|
}
|
||||||
.public-document-body > section + section { margin-top: 76px; padding-top: 70px; border-top: 1px solid var(--line); }
|
.public-document-body > section + section { margin-top: 76px; padding-top: 70px; border-top: 1px solid var(--line); }
|
||||||
.public-document-body h2,
|
.public-document-body h2,
|
||||||
@@ -2613,3 +2735,29 @@ textarea.studio-control { min-height: 118px; resize: vertical; line-height: 1.65
|
|||||||
.studio-diff-grid section, .studio-diff-grid section + section { min-height: 0; padding: 23px 0; border-left: 0; }
|
.studio-diff-grid section, .studio-diff-grid section + section { min-height: 0; padding: 23px 0; border-left: 0; }
|
||||||
.studio-diff-grid section + section { border-top: 1px solid var(--line); }
|
.studio-diff-grid section + section { border-top: 1px solid var(--line); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 본문 구분선과 그림. 문서 본문의 다른 블록과 같은 세로 리듬을 따른다. */
|
||||||
|
.document-body > section > .document-rule {
|
||||||
|
margin: 34px 0;
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-body > section > .document-image {
|
||||||
|
margin: 26px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-body > section > .document-image img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-body > section > .document-image figcaption {
|
||||||
|
margin-top: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,37 @@
|
|||||||
.studio-app .studio-editor-page,
|
.studio-app .studio-editor-page,
|
||||||
.studio-app .studio-editor-layout,
|
.studio-app .studio-editor-split,
|
||||||
.studio-app .studio-editor-workspace,
|
.studio-app .studio-editor-workspace,
|
||||||
.studio-app .studio-editor-workspace > [role="tabpanel"] { min-width: 0; }
|
.studio-app .studio-editor-preview { min-width: 0; }
|
||||||
|
|
||||||
.studio-app .studio-editor-loading { min-height: 240px; padding-block: 56px; color: var(--muted); }
|
.studio-app .studio-editor-loading { min-height: 240px; padding-block: 56px; color: var(--muted); }
|
||||||
.studio-app .studio-editor-tabs { display: flex; gap: 28px; border-bottom: 1px solid var(--line-strong); }
|
/*
|
||||||
.studio-app .studio-editor-tabs button { position: relative; min-width: 112px; min-height: 44px; padding: 0; border: 0; background: transparent; color: var(--muted); font: inherit; font-weight: 650; }
|
편집과 미리보기를 한 화면에 나란히 둔다. 탭이었을 때는 한 번에 하나만 보였고, 고친 결과를
|
||||||
.studio-app .studio-editor-tabs button[aria-selected="true"] { color: var(--ink); }
|
보려면 편집하던 자리를 화면에서 치워야 했다.
|
||||||
.studio-app .studio-editor-tabs button[aria-selected="true"]::after { position: absolute; right: 0; bottom: -1px; left: 0; height: 2px; background: var(--signal); content: ""; }
|
*/
|
||||||
|
/*
|
||||||
|
이 화면만 공용 컨테이너보다 넓다. `.studio-main` 은 모든 Studio 화면이 1180px 를 함께
|
||||||
|
쓰는데, 여기서는 그 폭에 본문 두 벌이 들어가야 해서 한 칸이 566px 로 좁아졌다. 헤더와
|
||||||
|
다른 화면은 그대로 두고 편집기가 놓인 경우에만 넓힌다.
|
||||||
|
*/
|
||||||
|
.studio-app:has(.studio-editor-split) .studio-header-inner,
|
||||||
|
.studio-app:has(.studio-editor-split) .studio-main { width: min(1600px, calc(100% - 80px)); }
|
||||||
|
|
||||||
.studio-app .studio-editor-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(240px, 280px); gap: 64px; align-items: start; padding-top: 40px; }
|
/*
|
||||||
|
편집과 미리보기를 한 화면에 나란히 둔다. 탭이었을 때는 한 번에 하나만 보였고, 고친 결과를
|
||||||
|
보려면 편집하던 자리를 화면에서 치워야 했다.
|
||||||
|
|
||||||
|
두 칸 모두 페이지 스크롤을 그대로 탄다. 미리보기를 붙박이로 두고 자체 스크롤을 주었더니
|
||||||
|
편집기를 내려도 미리보기는 제자리였고, 보려면 그 안을 따로 굴려야 했다 — 나란히 둔 이유가
|
||||||
|
둘을 같이 보는 것인데 움직임이 갈라지면 그 이점이 없다.
|
||||||
|
*/
|
||||||
|
.studio-app .studio-editor-split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 48px; padding-top: 40px; }
|
||||||
|
/* 반쪽 폭에는 62px 짜리 제목이 앉을 자리가 없다. */
|
||||||
|
.studio-app .studio-editor-split .studio-editor-heading h1 { font-size: clamp(28px, 3vw, 40px); }
|
||||||
|
|
||||||
|
.studio-app .studio-editor-preview { display: flex; flex-direction: column; padding-left: 40px; border-left: 1px solid var(--line); }
|
||||||
|
.studio-app .studio-editor-preview__heading { padding-bottom: 20px; border-bottom: 1px solid var(--line); }
|
||||||
|
.studio-app .studio-editor-preview__heading .studio-eyebrow { margin-bottom: 8px; }
|
||||||
|
.studio-app .studio-editor-preview__body { padding-top: 20px; }
|
||||||
.studio-app .studio-editor-heading { padding-bottom: 36px; border-bottom: 1px solid var(--line-strong); }
|
.studio-app .studio-editor-heading { padding-bottom: 36px; border-bottom: 1px solid var(--line-strong); }
|
||||||
.studio-app .studio-editor-heading h1 { margin: 0; font-size: clamp(38px, 5vw, 62px); line-height: 1.05; letter-spacing: -0.045em; }
|
.studio-app .studio-editor-heading h1 { margin: 0; font-size: clamp(38px, 5vw, 62px); line-height: 1.05; letter-spacing: -0.045em; }
|
||||||
.studio-app .studio-editor-heading > p:last-child { max-width: 780px; margin: 18px 0 0; color: var(--muted); font-size: 17px; line-height: 1.65; overflow-wrap: anywhere; }
|
.studio-app .studio-editor-heading > p:last-child { max-width: 780px; margin: 18px 0 0; color: var(--muted); font-size: 17px; line-height: 1.65; overflow-wrap: anywhere; }
|
||||||
@@ -17,7 +39,7 @@
|
|||||||
.studio-app .studio-editor-section-heading { margin-bottom: 24px; }
|
.studio-app .studio-editor-section-heading { margin-bottom: 24px; }
|
||||||
.studio-app .studio-editor-section-heading .studio-eyebrow { margin-bottom: 8px; }
|
.studio-app .studio-editor-section-heading .studio-eyebrow { margin-bottom: 8px; }
|
||||||
.studio-app .studio-editor-section-heading h2,
|
.studio-app .studio-editor-section-heading h2,
|
||||||
.studio-app .studio-document-status-rail h2,
|
.studio-app .studio-editor-preview__heading h2,
|
||||||
.studio-app .studio-preview-error h2 { margin: 0; font-size: 25px; letter-spacing: -0.03em; }
|
.studio-app .studio-preview-error h2 { margin: 0; font-size: 25px; letter-spacing: -0.03em; }
|
||||||
|
|
||||||
.studio-app .studio-field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px; }
|
.studio-app .studio-field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px; }
|
||||||
@@ -36,6 +58,31 @@
|
|||||||
.studio-app .studio-ordered-item textarea { min-height: 112px; resize: vertical; }
|
.studio-app .studio-ordered-item textarea { min-height: 112px; resize: vertical; }
|
||||||
.studio-app .studio-field .studio-markdown-field { min-height: 420px; font-family: "IBM Plex Mono", monospace; font-size: 13px; }
|
.studio-app .studio-field .studio-markdown-field { min-height: 420px; font-family: "IBM Plex Mono", monospace; font-size: 13px; }
|
||||||
|
|
||||||
|
/* 칸 아래 한 줄 설명. 라벨과 같은 격자 안에 들어가므로 별도 여백을 두지 않는다. */
|
||||||
|
.studio-app .studio-field > small { color: var(--faint); font-size: 12px; font-weight: 450; line-height: 1.6; }
|
||||||
|
|
||||||
|
/* 여러 개를 고르는 목록(주제 등). 작업본 목록의 유형 선택과 같은 모양을 쓴다. */
|
||||||
|
.studio-app .studio-choice-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 0 24px; margin: 0; padding: 0; list-style: none; border-top: 1px solid var(--line); }
|
||||||
|
.studio-app .studio-choice { display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 4px 12px; align-items: center; min-height: 60px; padding: 12px 0; border-bottom: 1px solid var(--line); cursor: pointer; }
|
||||||
|
.studio-app .studio-choice input { width: 20px; min-width: 20px; min-height: 20px; padding: 0; }
|
||||||
|
.studio-app .studio-choice span { font-size: 15px; font-weight: 550; }
|
||||||
|
.studio-app .studio-choice small { grid-column: 2; color: var(--faint); font-family: "IBM Plex Mono", monospace; font-size: 11px; }
|
||||||
|
|
||||||
|
/* 편집 화면 하단의 저장 줄. 버튼과 그 옆의 상태 한 줄. */
|
||||||
|
.studio-app .studio-editor-footer { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; padding: 32px 0 0; }
|
||||||
|
.studio-app .studio-editor-footer .studio-primary-button,
|
||||||
|
.studio-app .studio-editor-footer .studio-secondary-button { margin: 0; }
|
||||||
|
.studio-app .studio-editor-footer .studio-field-note { flex: 1 1 260px; margin: 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
|
||||||
|
|
||||||
|
/* 체크박스 묶음(변경 유형 등). `.studio-field-grid` 를 쓰면 legend 가 한 칸을 차지해 줄이 어긋난다. */
|
||||||
|
.studio-app .studio-checkbox-set { margin: 34px 0 0; padding: 0; border: 0; }
|
||||||
|
.studio-app .studio-checkbox-set legend { padding: 0 0 14px; color: var(--muted); font-size: 12px; font-weight: 650; }
|
||||||
|
.studio-app .studio-checkbox-set { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 0 24px; }
|
||||||
|
.studio-app .studio-checkbox-set legend { grid-column: 1 / -1; }
|
||||||
|
.studio-app .studio-checkbox-set .studio-field--checkbox { display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 12px; align-items: center; min-height: 52px; padding: 8px 0; border-bottom: 1px solid var(--line); cursor: pointer; }
|
||||||
|
.studio-app .studio-checkbox-set .studio-field--checkbox input { width: 20px; min-width: 20px; min-height: 20px; padding: 0; }
|
||||||
|
.studio-app .studio-checkbox-set .studio-field--checkbox span { font-size: 15px; font-weight: 500; }
|
||||||
|
|
||||||
.studio-app .studio-ordered-list,
|
.studio-app .studio-ordered-list,
|
||||||
.studio-app .studio-resolution-fields { min-width: 0; margin: 34px 0 0; padding: 0; border: 0; }
|
.studio-app .studio-resolution-fields { min-width: 0; margin: 34px 0 0; padding: 0; border: 0; }
|
||||||
.studio-app .studio-ordered-list legend,
|
.studio-app .studio-ordered-list legend,
|
||||||
@@ -46,27 +93,38 @@
|
|||||||
.studio-app .studio-item-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
.studio-app .studio-item-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
.studio-app .studio-item-actions button,
|
.studio-app .studio-item-actions button,
|
||||||
.studio-app .studio-add-item,
|
.studio-app .studio-add-item,
|
||||||
.studio-app .studio-document-status-rail button { min-height: 44px; padding-inline: 13px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--paper); color: var(--ink); font-weight: 650; }
|
.studio-app .studio-document-status-bar button { min-height: 44px; padding-inline: 13px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--paper); color: var(--ink); font-weight: 650; }
|
||||||
.studio-app .studio-item-actions button:disabled,
|
.studio-app .studio-item-actions button:disabled,
|
||||||
.studio-app .studio-add-item:disabled { opacity: 0.45; }
|
.studio-app .studio-add-item:disabled { opacity: 0.45; }
|
||||||
.studio-app .studio-add-item { margin-top: 12px; }
|
.studio-app .studio-add-item { margin-top: 12px; }
|
||||||
.studio-app .studio-resolution-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px; }
|
.studio-app .studio-resolution-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px; }
|
||||||
.studio-app .studio-resolution-fields legend { grid-column: 1 / -1; }
|
.studio-app .studio-resolution-fields legend { grid-column: 1 / -1; }
|
||||||
|
|
||||||
.studio-app .studio-document-status-rail { position: sticky; top: 28px; min-width: 0; padding: 24px 0; border-top: 1px solid var(--line-strong); border-bottom: 1px solid var(--line-strong); }
|
/*
|
||||||
.studio-app .studio-document-status-rail .studio-eyebrow { margin-bottom: 8px; }
|
작업 상태와 저장·게시를 화면 아래에 붙여 둔다. 문서가 길어져도 스크롤 위치와 무관하게 손이
|
||||||
.studio-app .studio-editor-status { margin: 20px 0; font-weight: 700; }
|
닿는다. 배경을 불투명하게 두는 것은 장식이 아니라 필요다 — 밑으로 지나가는 본문이 비치면
|
||||||
|
상태 글자를 읽을 수 없다.
|
||||||
|
*/
|
||||||
|
.studio-app .studio-document-status-bar { position: sticky; bottom: 0; z-index: 2; display: flex; flex-wrap: wrap; align-items: center; gap: 12px 28px; min-width: 0; margin-top: 48px; padding: 16px 0; border-top: 1px solid var(--line-strong); background: var(--paper); }
|
||||||
|
.studio-app .studio-editor-status { margin: 0; font-weight: 700; }
|
||||||
.studio-app .studio-editor-status--dirty,
|
.studio-app .studio-editor-status--dirty,
|
||||||
.studio-app .studio-editor-status--conflict,
|
.studio-app .studio-editor-status--conflict,
|
||||||
.studio-app .studio-editor-conflict { color: #8f2f27; }
|
.studio-app .studio-editor-conflict { color: #8f2f27; }
|
||||||
.studio-app .studio-editor-status--clean { color: #166748; }
|
.studio-app .studio-editor-status--clean { color: #166748; }
|
||||||
.studio-app .studio-document-status-rail dl { display: grid; gap: 12px; margin: 0 0 20px; }
|
.studio-app .studio-document-status-bar dl { display: flex; gap: 20px; margin: 0; }
|
||||||
.studio-app .studio-document-status-rail dl div { display: flex; justify-content: space-between; gap: 16px; }
|
.studio-app .studio-document-status-bar dl div { display: flex; align-items: baseline; gap: 8px; }
|
||||||
.studio-app .studio-document-status-rail dt { color: var(--muted); font-size: 12px; }
|
.studio-app .studio-document-status-bar dt { color: var(--muted); font-size: 12px; }
|
||||||
.studio-app .studio-document-status-rail dd { margin: 0; font-size: 13px; }
|
.studio-app .studio-document-status-bar dd { margin: 0; font-size: 13px; }
|
||||||
.studio-app .studio-document-status-rail button { width: 100%; border-color: var(--signal); background: var(--signal); color: #fff; }
|
.studio-app .studio-document-status-bar__note { flex: 1 1 240px; margin: 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
|
||||||
.studio-app .studio-document-status-rail button:disabled { border-color: var(--line-strong); background: var(--paper); color: var(--muted); }
|
/* 색은 위 묶음이 정한다 — 여기서 다시 칠하면 실패가 안내문처럼 보인다. */
|
||||||
.studio-app .studio-document-status-rail > p:last-child { margin: 16px 0 0; color: var(--muted); font-size: 12px; line-height: 1.65; }
|
.studio-app .studio-document-status-bar .studio-editor-conflict { flex: 1 1 240px; margin: 0; font-size: 12px; line-height: 1.6; }
|
||||||
|
.studio-app .studio-document-status-bar__actions { display: flex; align-items: center; gap: 12px; margin-left: auto; }
|
||||||
|
/* `.studio-primary-button` 은 세로로 쌓이는 자리에 맞춘 바깥 여백을 갖고 있다. 한 줄에
|
||||||
|
나란히 두면 그 여백이 줄 높이를 늘리고, 옆 버튼만 늘어나 짝이 어긋난다. */
|
||||||
|
.studio-app .studio-document-status-bar__actions .studio-primary-button { margin: 0; }
|
||||||
|
.studio-app .studio-document-status-bar button { min-width: 104px; border-color: var(--signal); background: var(--signal); color: #fff; }
|
||||||
|
.studio-app .studio-document-status-bar button:disabled { border-color: var(--line-strong); background: var(--paper); color: var(--muted); }
|
||||||
|
.studio-app .studio-document-status-bar .studio-editor-unplaced-issues { flex: 1 1 100%; }
|
||||||
|
|
||||||
.studio-app .studio-asset-panel { margin-top: 28px; padding-top: 28px; border-top: 1px solid var(--line); }
|
.studio-app .studio-asset-panel { margin-top: 28px; padding-top: 28px; border-top: 1px solid var(--line); }
|
||||||
.studio-app .studio-asset-panel .studio-eyebrow { margin-bottom: 8px; }
|
.studio-app .studio-asset-panel .studio-eyebrow { margin-bottom: 8px; }
|
||||||
@@ -82,7 +140,7 @@
|
|||||||
matches this form's submit button, and both selectors carry the same
|
matches this form's submit button, and both selectors carry the same
|
||||||
specificity, so only source order tells them apart. */
|
specificity, so only source order tells them apart. */
|
||||||
/* `minmax(0, 1fr)` and the row's `min-width: 0` are both load bearing, for the
|
/* `minmax(0, 1fr)` and the row's `min-width: 0` are both load bearing, for the
|
||||||
reason `.studio-editor-layout` already needs the same pair: a grid track and
|
reason `.studio-editor-split` already needs the same pair: a grid track and
|
||||||
a flex container both default to an automatic minimum of their min-content
|
a flex container both default to an automatic minimum of their min-content
|
||||||
size, so at 360px the search row sized itself to 366px inside a 328px column
|
size, so at 360px the search row sized itself to 366px inside a 328px column
|
||||||
and pushed the submit button off-screen (document scrollWidth 382). */
|
and pushed the submit button off-screen (document scrollWidth 382). */
|
||||||
@@ -103,15 +161,19 @@
|
|||||||
.studio-app .studio-preview-error ul { margin: 20px 0 0; padding-left: 20px; color: #8f2f27; line-height: 1.7; overflow-wrap: anywhere; }
|
.studio-app .studio-preview-error ul { margin: 20px 0 0; padding-left: 20px; color: #8f2f27; line-height: 1.7; overflow-wrap: anywhere; }
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.studio-app .studio-editor-layout { grid-template-columns: minmax(0, 1fr); gap: 40px; }
|
.studio-app .studio-editor-split { grid-template-columns: minmax(0, 1fr); gap: 40px; }
|
||||||
.studio-app .studio-document-status-rail { position: static; }
|
.studio-app .studio-editor-split .studio-editor-heading h1 { font-size: clamp(38px, 5vw, 62px); }
|
||||||
|
.studio-app .studio-editor-preview { padding-top: 8px; padding-left: 0; border-top: 1px solid var(--line-strong); border-left: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.studio-app .studio-editor-heading { padding-bottom: 28px; }
|
.studio-app .studio-editor-heading { padding-bottom: 28px; }
|
||||||
.studio-app .studio-editor-tabs { gap: 18px; }
|
.studio-app .studio-editor-split { padding-top: 28px; }
|
||||||
.studio-app .studio-editor-tabs button { flex: 1; min-width: 0; }
|
/* 안내문은 세 줄로 접혀 뷰포트의 20%를 고정으로 가져간다. 실패 문구는 남기고 안내만 접는다 —
|
||||||
.studio-app .studio-editor-layout { padding-top: 28px; }
|
Ctrl+S 는 여기서 누를 수 있는 것도 아니다. */
|
||||||
|
.studio-app .studio-document-status-bar__note { display: none; }
|
||||||
|
.studio-app .studio-document-status-bar__actions { flex: 1 1 100%; margin-left: 0; }
|
||||||
|
.studio-app .studio-document-status-bar__actions button { flex: 1; min-width: 0; }
|
||||||
.studio-app .studio-field-grid,
|
.studio-app .studio-field-grid,
|
||||||
.studio-app .studio-resolution-fields { grid-template-columns: minmax(0, 1fr); }
|
.studio-app .studio-resolution-fields { grid-template-columns: minmax(0, 1fr); }
|
||||||
.studio-app .studio-field--wide,
|
.studio-app .studio-field--wide,
|
||||||
@@ -156,6 +218,6 @@
|
|||||||
저장과 게시는 되돌릴 수 있는 정도가 다르다 — 하나는 초안을 남기고, 하나는 공개한다. 둘이
|
저장과 게시는 되돌릴 수 있는 정도가 다르다 — 하나는 초안을 남기고, 하나는 공개한다. 둘이
|
||||||
맞붙어 있으면 누르려던 것을 지나쳐 누르기 쉬우므로 사이를 벌린다.
|
맞붙어 있으면 누르려던 것을 지나쳐 누르기 쉬우므로 사이를 벌린다.
|
||||||
*/
|
*/
|
||||||
.studio-app .studio-document-status-rail button + button { margin-top: 10px; }
|
.studio-app .studio-document-status-bar__actions { gap: 12px; }
|
||||||
/* 게시만 강조한다. 저장은 되돌릴 수 있으므로 같은 무게로 부를 이유가 없다. */
|
/* 게시만 강조한다. 저장은 되돌릴 수 있으므로 같은 무게로 부를 이유가 없다. */
|
||||||
.studio-app .studio-document-status-rail button:not(.studio-primary-button) { border-color: var(--line-strong); background: var(--paper); color: var(--ink); }
|
.studio-app .studio-document-status-bar button:not(.studio-primary-button) { border-color: var(--line-strong); background: var(--paper); color: var(--ink); }
|
||||||
|
|||||||
@@ -107,6 +107,15 @@
|
|||||||
.studio-app .studio-document-row dt { color: var(--muted); font-size: 11px; }
|
.studio-app .studio-document-row dt { color: var(--muted); font-size: 11px; }
|
||||||
.studio-app .studio-document-row dd { margin: 5px 0 0; font-size: 13px; overflow-wrap: anywhere; }
|
.studio-app .studio-document-row dd { margin: 5px 0 0; font-size: 13px; overflow-wrap: anywhere; }
|
||||||
.studio-app .studio-secondary-button { margin-top: 24px; }
|
.studio-app .studio-secondary-button { margin-top: 24px; }
|
||||||
|
/*
|
||||||
|
한 행에 버튼이 둘 이상일 때. `.studio-secondary-button` 의 위쪽 여백은 버튼이 하나뿐이던 때의
|
||||||
|
값이라, 묶음 안에서는 묶음이 여백을 갖고 버튼은 나란히 선다.
|
||||||
|
*/
|
||||||
|
.studio-app .studio-row-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 24px; }
|
||||||
|
|
||||||
|
.studio-app .studio-field-note { margin: 6px 0 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
|
||||||
|
.studio-app .studio-row-actions .studio-primary-button,
|
||||||
|
.studio-app .studio-row-actions .studio-secondary-button { margin: 0; }
|
||||||
.studio-app .studio-empty-state { padding-block: 56px; border-bottom: 1px solid var(--line); }
|
.studio-app .studio-empty-state { padding-block: 56px; border-bottom: 1px solid var(--line); }
|
||||||
.studio-app .studio-empty-state h2 { margin: 0; font-size: 25px; }
|
.studio-app .studio-empty-state h2 { margin: 0; font-size: 25px; }
|
||||||
.studio-app .studio-empty-state p { margin: 12px 0 22px; color: var(--muted); }
|
.studio-app .studio-empty-state p { margin: 12px 0 22px; color: var(--muted); }
|
||||||
@@ -118,8 +127,14 @@
|
|||||||
.studio-app .studio-type-list strong { font-size: 18px; }
|
.studio-app .studio-type-list strong { font-size: 18px; }
|
||||||
.studio-app .studio-type-list span,
|
.studio-app .studio-type-list span,
|
||||||
.studio-app .studio-type-list small { color: var(--muted); line-height: 1.6; }
|
.studio-app .studio-type-list small { color: var(--muted); line-height: 1.6; }
|
||||||
.studio-app .studio-create-footer { display: flex; align-items: center; gap: 20px; margin-top: 28px; }
|
.studio-app .studio-create-footer { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 20px; margin-top: 28px; }
|
||||||
.studio-app .studio-create-footer .studio-primary-button { margin: 0; }
|
/*
|
||||||
|
`.studio-secondary-button` 는 목록 행에서 쓰려고 전역으로 `margin-top: 24px` 를 갖는다. 여기서
|
||||||
|
풀어 주지 않으면 primary(저장)만 위로 붙고 secondary(공개·공개에서 내리기)는 24px 아래로
|
||||||
|
내려앉아, 같은 줄의 버튼들이 서로 다른 높이에 선다.
|
||||||
|
*/
|
||||||
|
.studio-app .studio-create-footer .studio-primary-button,
|
||||||
|
.studio-app .studio-create-footer .studio-secondary-button { margin: 0; }
|
||||||
.studio-app .studio-create-footer p { margin: 0; color: var(--muted); font-size: 13px; }
|
.studio-app .studio-create-footer p { margin: 0; color: var(--muted); font-size: 13px; }
|
||||||
.studio-app .studio-primary-button:disabled { opacity: 0.55; cursor: wait; }
|
.studio-app .studio-primary-button:disabled { opacity: 0.55; cursor: wait; }
|
||||||
|
|
||||||
|
|||||||
@@ -216,6 +216,13 @@ export const TECH_LOG_ROUTE_RUNTIME = Object.freeze({
|
|||||||
"TaxonomyPage",
|
"TaxonomyPage",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
TECH_LOG_STUDIO_PROJECT_EDIT: runtime(
|
||||||
|
"TECH_LOG_STUDIO_PROJECT_EDIT",
|
||||||
|
routeModule(
|
||||||
|
() => import("./studio/pages/project-edit-page.tsx"),
|
||||||
|
"ProjectEditPage",
|
||||||
|
),
|
||||||
|
),
|
||||||
TECH_LOG_STUDIO_RELEASES: runtime(
|
TECH_LOG_STUDIO_RELEASES: runtime(
|
||||||
"TECH_LOG_STUDIO_RELEASES",
|
"TECH_LOG_STUDIO_RELEASES",
|
||||||
routeModule(
|
routeModule(
|
||||||
@@ -223,6 +230,13 @@ export const TECH_LOG_ROUTE_RUNTIME = Object.freeze({
|
|||||||
"StudioReleasesPage",
|
"StudioReleasesPage",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
TECH_LOG_STUDIO_RELEASE_EDIT: runtime(
|
||||||
|
"TECH_LOG_STUDIO_RELEASE_EDIT",
|
||||||
|
routeModule(
|
||||||
|
() => import("./studio/pages/release-edit-page.tsx"),
|
||||||
|
"ReleaseEditPage",
|
||||||
|
),
|
||||||
|
),
|
||||||
TECH_LOG_STUDIO_NOT_FOUND: runtime(
|
TECH_LOG_STUDIO_NOT_FOUND: runtime(
|
||||||
"TECH_LOG_STUDIO_NOT_FOUND",
|
"TECH_LOG_STUDIO_NOT_FOUND",
|
||||||
routeModule(
|
routeModule(
|
||||||
|
|||||||
@@ -87,11 +87,16 @@ const PLATFORM_KO_MESSAGES = {
|
|||||||
"route.auth.integration.description":
|
"route.auth.integration.description":
|
||||||
"외부 인증 소유자가 연결되면 이 보호 라우트를 사용할 수 있습니다.",
|
"외부 인증 소유자가 연결되면 이 보호 라우트를 사용할 수 있습니다.",
|
||||||
"route.auth.recovering.title": "세션을 복구하고 있습니다.",
|
"route.auth.recovering.title": "세션을 복구하고 있습니다.",
|
||||||
"route.auth.required.title": "세션이 필요합니다.",
|
"route.auth.eyebrow": "SIGN IN",
|
||||||
|
"route.auth.returnTo": "로그인하면 {path} 로 돌아옵니다.",
|
||||||
|
"route.auth.required.title": "Studio에 로그인해 주세요.",
|
||||||
"route.auth.recovering.description":
|
"route.auth.recovering.description":
|
||||||
"기존 세션 확인을 계속하려면 복구를 실행하세요.",
|
"이전 로그인이 아직 살아 있는지 확인하고 있습니다. 잠시 뒤에도 이 화면이면 다시 시도해 주세요.",
|
||||||
|
// 예전 문구("인증 연동 지점을 확인하기 위한 보호 라우트")는 이 뼈대를 만들던 사람에게 하는
|
||||||
|
// 말이었다. 실제로 이 화면을 보는 사람은 글을 쓰러 온 작성자이고, 알아야 할 것은 무엇을
|
||||||
|
// 누르면 되는지다.
|
||||||
"route.auth.required.description":
|
"route.auth.required.description":
|
||||||
"이 화면은 인증 연동 지점을 확인하기 위한 보호 라우트입니다.",
|
"기록을 쓰고 게시하려면 로그인이 필요합니다. 로그인하면 방금 열려던 화면으로 돌아옵니다.",
|
||||||
"route.documentTitle": "{title} · {appName}",
|
"route.documentTitle": "{title} · {appName}",
|
||||||
"chunk.checking": "새 릴리스 정보를 확인하고 있습니다.",
|
"chunk.checking": "새 릴리스 정보를 확인하고 있습니다.",
|
||||||
"chunk.reloadOnce": "새 버전으로 한 번만 전환합니다.",
|
"chunk.reloadOnce": "새 버전으로 한 번만 전환합니다.",
|
||||||
@@ -271,11 +276,13 @@ const PLATFORM_EN_MESSAGES = {
|
|||||||
"route.auth.integration.description":
|
"route.auth.integration.description":
|
||||||
"This protected route is available after an external authentication owner is connected.",
|
"This protected route is available after an external authentication owner is connected.",
|
||||||
"route.auth.recovering.title": "Recovering the session.",
|
"route.auth.recovering.title": "Recovering the session.",
|
||||||
"route.auth.required.title": "A session is required.",
|
"route.auth.eyebrow": "SIGN IN",
|
||||||
|
"route.auth.returnTo": "You return to {path} after signing in.",
|
||||||
|
"route.auth.required.title": "Sign in to Studio.",
|
||||||
"route.auth.recovering.description":
|
"route.auth.recovering.description":
|
||||||
"Run recovery to continue checking the existing session.",
|
"Checking whether the previous sign-in is still valid. Try again if this screen stays.",
|
||||||
"route.auth.required.description":
|
"route.auth.required.description":
|
||||||
"This protected route demonstrates the authentication integration seam.",
|
"Writing and publishing records needs a signed-in session. You return to this screen afterwards.",
|
||||||
"route.documentTitle": "{title} · {appName}",
|
"route.documentTitle": "{title} · {appName}",
|
||||||
"chunk.checking": "Checking the new release information.",
|
"chunk.checking": "Checking the new release information.",
|
||||||
"chunk.reloadOnce": "Switching to the new version once.",
|
"chunk.reloadOnce": "Switching to the new version once.",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export type MessageParameters = Readonly<{
|
|||||||
"action.alertCloseNamed": { title: string };
|
"action.alertCloseNamed": { title: string };
|
||||||
"route.loadingNamed": { title: string };
|
"route.loadingNamed": { title: string };
|
||||||
"route.documentTitle": { title: string; appName: string };
|
"route.documentTitle": { title: string; appName: string };
|
||||||
|
"route.auth.returnTo": { path: string };
|
||||||
"template.supportReference": { reference: string };
|
"template.supportReference": { reference: string };
|
||||||
"form.remaining": { count: number };
|
"form.remaining": { count: number };
|
||||||
"boot.supportReference": { reference: string };
|
"boot.supportReference": { reference: string };
|
||||||
|
|||||||
@@ -241,9 +241,17 @@ function ProtectedRoute({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const recovering = decision.action === "wait-for-session";
|
const recovering = decision.action === "wait-for-session";
|
||||||
|
/*
|
||||||
|
Signing in is a screen, not a bare gate. This used to be a title and a button
|
||||||
|
dropped on `ui-page`, so opening a protected address showed no page at all —
|
||||||
|
just two stray elements. It is one card now, and it names the address the
|
||||||
|
visitor came for so they know where signing in returns them.
|
||||||
|
*/
|
||||||
return (
|
return (
|
||||||
<section className="ui-page" data-surface="authentication-required">
|
<section className="ui-page auth-gate" data-surface="authentication-required">
|
||||||
|
<div className="auth-gate__card">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
|
eyebrow={message("route.auth.eyebrow")}
|
||||||
title={
|
title={
|
||||||
recovering
|
recovering
|
||||||
? message("route.auth.recovering.title")
|
? message("route.auth.recovering.title")
|
||||||
@@ -255,21 +263,26 @@ function ProtectedRoute({
|
|||||||
: message("route.auth.required.description")
|
: message("route.auth.required.description")
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Button
|
<div className="auth-gate__actions">
|
||||||
disabled={pending}
|
<Button disabled={pending} onClick={() => void continueSession()}>
|
||||||
onClick={() => void continueSession()}
|
|
||||||
>
|
|
||||||
{pending
|
{pending
|
||||||
? message("common.processing")
|
? message("common.processing")
|
||||||
: recovering
|
: recovering
|
||||||
? message("action.recoverSession")
|
? message("action.recoverSession")
|
||||||
: message("action.signIn")}
|
: message("action.signIn")}
|
||||||
</Button>
|
</Button>
|
||||||
|
<p className="auth-gate__return">
|
||||||
|
{message("route.auth.returnTo", {
|
||||||
|
path: `${location.pathname}${location.search}`,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
{failed ? (
|
{failed ? (
|
||||||
<p className="ui-terminal-error" role="alert">
|
<p className="ui-terminal-error" role="alert">
|
||||||
{message("shell.session.actionFailed")}
|
{message("shell.session.actionFailed")}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -461,8 +461,7 @@ test("inserts the directive at the saved cursor position and the live preview re
|
|||||||
`${body.slice(0, cursor)}\n\n${expectedDirective}${body.slice(cursor)}`,
|
`${body.slice(0, cursor)}\n\n${expectedDirective}${body.slice(cursor)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
await user.click(screen.getByRole("tab", { name: "즉시 미리보기" }));
|
const panel = screen.getByRole("region", { name: "즉시 미리보기" });
|
||||||
const panel = screen.getByRole("tabpanel", { name: "즉시 미리보기" });
|
|
||||||
|
|
||||||
expect(within(panel).queryByRole("alert")).not.toBeInTheDocument();
|
expect(within(panel).queryByRole("alert")).not.toBeInTheDocument();
|
||||||
// `zoom: true` (DIAGRAM kind) renders the figure's image twice -- once as
|
// `zoom: true` (DIAGRAM kind) renders the figure's image twice -- once as
|
||||||
@@ -1819,12 +1818,12 @@ test("a Picker search never drops an already-inserted asset out of Instant Previ
|
|||||||
// must narrow; the preview must not.
|
// must narrow; the preview must not.
|
||||||
await user.type(screen.getByLabelText("Asset 검색"), "other");
|
await user.type(screen.getByLabelText("Asset 검색"), "other");
|
||||||
await user.click(screen.getByRole("button", { name: "검색" }));
|
await user.click(screen.getByRole("button", { name: "검색" }));
|
||||||
|
const picker = screen.getByRole("group", { name: "본문에 Asset 삽입" });
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.queryByRole("button", { name: /inserted-diagram/ })).not.toBeInTheDocument(),
|
expect(within(picker).queryByRole("button", { name: /inserted-diagram/ })).not.toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
|
|
||||||
await user.click(screen.getByRole("tab", { name: "즉시 미리보기" }));
|
const panel = screen.getByRole("region", { name: "즉시 미리보기" });
|
||||||
const panel = screen.getByRole("tabpanel", { name: "즉시 미리보기" });
|
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
within(panel).queryByRole("alert")?.textContent ?? null,
|
within(panel).queryByRole("alert")?.textContent ?? null,
|
||||||
|
|||||||
@@ -60,6 +60,36 @@ describe("Content Format v1", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts the plain Markdown the server renderer already accepted", () => {
|
||||||
|
/*
|
||||||
|
이 문법들은 서버 렌더러가 원래 읽던 것인데 이 파서만 거절하고 있었다 — 같은 본문이 Studio
|
||||||
|
와 공개 화면에서 다르게 읽혔다. 표는 `:::table` 로 감싸지 않아도 되고, callout 이름은 서버가
|
||||||
|
아는 note/tip/warning/danger 를 쓴다.
|
||||||
|
*/
|
||||||
|
expect(parseCaseContent("| A | B |\n|---|---|\n| 1 | 2 |").map((block) => block.type)).toEqual([
|
||||||
|
"DATA_TABLE",
|
||||||
|
]);
|
||||||
|
expect(parseCaseContent(":::note\n\n참고할 것.\n\n:::").map((block) => block.type)).toEqual([
|
||||||
|
"CALLOUT",
|
||||||
|
]);
|
||||||
|
expect(parseCaseContent(":::danger\n\n위험.\n\n:::").map((block) => block.type)).toEqual([
|
||||||
|
"CALLOUT",
|
||||||
|
]);
|
||||||
|
expect(parseCaseContent("# 제목1\n\n###### 제목6").map((block) => block.type)).toEqual([
|
||||||
|
"HEADING",
|
||||||
|
"HEADING",
|
||||||
|
]);
|
||||||
|
expect(parseCaseContent("---").map((block) => block.type)).toEqual(["THEMATIC_BREAK"]);
|
||||||
|
expect(parseCaseContent("").map((block) => block.type)).toEqual([
|
||||||
|
"IMAGE",
|
||||||
|
]);
|
||||||
|
// 속성이 없는 directive 도 이름이 온전해야 한다. 정규화 정규식이 되돌아가며 마지막 글자를
|
||||||
|
// 속성 쪽으로 넘기는 바람에 `:::note` 가 `:::not{e}` 로 바뀌던 적이 있다.
|
||||||
|
expect(() => parseCaseContent(":::unknown\n\ntext\n\n:::")).toThrow(
|
||||||
|
/unknown block directive: unknown/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects unsafe, raw HTML, and unsupported input with source positions", () => {
|
it("rejects unsafe, raw HTML, and unsupported input with source positions", () => {
|
||||||
const rejected = [
|
const rejected = [
|
||||||
"<script>alert(1)</script>",
|
"<script>alert(1)</script>",
|
||||||
@@ -74,11 +104,12 @@ describe("Content Format v1", () => {
|
|||||||
"[x](</safe\u001fpath>)",
|
"[x](</safe\u001fpath>)",
|
||||||
"[x](</safe\u007fpath>)",
|
"[x](</safe\u007fpath>)",
|
||||||
"- outer\n - nested",
|
"- outer\n - nested",
|
||||||
"# level one",
|
|
||||||
"- [ ] task",
|
"- [ ] task",
|
||||||
"> > nested",
|
"> > nested",
|
||||||
':::unknown key="value"\ntext\n:::',
|
':::unknown key="value"\ntext\n:::',
|
||||||
':::evidence key="https://example.com/x.png" alt="x" caption="x" zoom="true"\n:::',
|
':::evidence key="https://example.com/x.png" alt="x" caption="x" zoom="true"\n:::',
|
||||||
|
")",
|
||||||
|
"",
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const source of rejected) {
|
for (const source of rejected) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ test("vendored contract matches the recorded canonical digest", () => {
|
|||||||
|
|
||||||
test("canonical source records the pinned revision and version", () => {
|
test("canonical source records the pinned revision and version", () => {
|
||||||
assert.equal(canonicalSource.packageId, "@tech-log/studio-contract");
|
assert.equal(canonicalSource.packageId, "@tech-log/studio-contract");
|
||||||
assert.equal(canonicalSource.version, "3.0.0");
|
assert.equal(canonicalSource.version, "3.1.0");
|
||||||
// revision은 생성 시점에 기록된다. canonical 저장소는 활발히 편집 중이므로
|
// revision은 생성 시점에 기록된다. canonical 저장소는 활발히 편집 중이므로
|
||||||
// 특정 값을 박아두면 계약이 그대로인데도 테스트가 깨진다. 형식만 고정한다.
|
// 특정 값을 박아두면 계약이 그대로인데도 테스트가 깨진다. 형식만 고정한다.
|
||||||
assert.match(canonicalSource.sourceRevision, /^[0-9a-f]{7,64}$/);
|
assert.match(canonicalSource.sourceRevision, /^[0-9a-f]{7,64}$/);
|
||||||
|
|||||||
@@ -296,7 +296,7 @@ describe("TechLog explore discovery", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { router } = await renderDiscoveryRoute(
|
const { router } = await renderDiscoveryRoute(
|
||||||
"TECH_LOG_EXPLORE",
|
"TECH_LOG_EXPLORE",
|
||||||
"/explore?type=CASE&topic=JPA&project=backend-skeleton",
|
"/explore?type=CASE&topic=jpa&project=backend-skeleton",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByRole("heading", { level: 1, name: "탐색" })).toBeVisible();
|
expect(screen.getByRole("heading", { level: 1, name: "탐색" })).toBeVisible();
|
||||||
@@ -306,22 +306,28 @@ describe("TechLog explore discovery", () => {
|
|||||||
// 필터의 선택지는 카탈로그가 도착한 뒤 채워지고, select 의 값도 그때 설정된다.
|
// 필터의 선택지는 카탈로그가 도착한 뒤 채워지고, select 의 값도 그때 설정된다.
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByLabelText("유형")).toHaveValue("CASE");
|
expect(screen.getByLabelText("유형")).toHaveValue("CASE");
|
||||||
expect(screen.getByLabelText("주제")).toHaveValue("JPA");
|
expect(screen.getByLabelText("주제")).toHaveValue("jpa");
|
||||||
expect(screen.getByLabelText("프로젝트")).toHaveValue("backend-skeleton");
|
expect(screen.getByLabelText("프로젝트")).toHaveValue("backend-skeleton");
|
||||||
});
|
});
|
||||||
|
// 주제 선택지는 보이는 이름과 보내는 값이 다르다. 값이 이름이면 slug 로 거르는 API 가
|
||||||
|
// 0건을 돌려주고, 화면은 「조건에 맞는 공개 기록이 없습니다」만 남는다 — 운영에서 실제로
|
||||||
|
// 그랬다. 목록에서 유도한 값이라 이 단언이 없으면 조용히 되돌아간다.
|
||||||
|
expect(
|
||||||
|
within(screen.getByLabelText("주제")).getByRole("option", { name: "Authentication" }),
|
||||||
|
).toHaveValue("authentication");
|
||||||
expect(screen.getByText("1개의 공개 기록")).toBeVisible();
|
expect(screen.getByText("1개의 공개 기록")).toBeVisible();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("link", { name: /컬렉션 Fetch Join과 페이징은 왜 충돌하는가/ }),
|
screen.getByRole("link", { name: /컬렉션 Fetch Join과 페이징은 왜 충돌하는가/ }),
|
||||||
).toHaveAttribute("href", "/cases/collection-fetch-join-pagination");
|
).toHaveAttribute("href", "/cases/collection-fetch-join-pagination");
|
||||||
|
|
||||||
await user.selectOptions(screen.getByLabelText("유형"), "QUESTION");
|
await user.selectOptions(screen.getByLabelText("유형"), "QUESTION");
|
||||||
await user.selectOptions(screen.getByLabelText("주제"), "Authentication");
|
await user.selectOptions(screen.getByLabelText("주제"), "authentication");
|
||||||
await user.selectOptions(screen.getByLabelText("프로젝트"), "auth-lab");
|
await user.selectOptions(screen.getByLabelText("프로젝트"), "auth-lab");
|
||||||
await user.click(screen.getByRole("button", { name: "적용" }));
|
await user.click(screen.getByRole("button", { name: "적용" }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(router.state.location.search).toBe(
|
expect(router.state.location.search).toBe(
|
||||||
"?project=auth-lab&topic=Authentication&type=QUESTION",
|
"?project=auth-lab&topic=authentication&type=QUESTION",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
expect(screen.getByText("1개의 공개 기록")).toBeVisible();
|
expect(screen.getByText("1개의 공개 기록")).toBeVisible();
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ const routeComponents = {
|
|||||||
|
|
||||||
type DocumentRouteId = keyof typeof routeComponents;
|
type DocumentRouteId = keyof typeof routeComponents;
|
||||||
|
|
||||||
|
/** 머리말이 거는 주제 링크를 확인하기 위한 픽스처의 이름 → slug 대응. */
|
||||||
|
const topicSlugs: Readonly<Record<string, string>> = {
|
||||||
|
JPA: "jpa",
|
||||||
|
Authentication: "authentication",
|
||||||
|
Redis: "redis",
|
||||||
|
};
|
||||||
|
|
||||||
class NoopIntersectionObserver implements IntersectionObserver {
|
class NoopIntersectionObserver implements IntersectionObserver {
|
||||||
readonly root = null;
|
readonly root = null;
|
||||||
readonly rootMargin = "0px";
|
readonly rootMargin = "0px";
|
||||||
@@ -205,13 +212,30 @@ describe("TechLog canonical Public documents", () => {
|
|||||||
const main = screen.getByRole("main");
|
const main = screen.getByRole("main");
|
||||||
|
|
||||||
expect(within(main).getByRole("heading", { level: 1, name: title })).toBeVisible();
|
expect(within(main).getByRole("heading", { level: 1, name: title })).toBeVisible();
|
||||||
expect(within(main).getByText(evidence, { exact: false })).toBeVisible();
|
/*
|
||||||
expect(within(main).getByRole("navigation", { name: "문서 경로" })).toHaveTextContent(
|
목차가 본문의 절 제목을 그대로 다시 적으므로 같은 글이 두 자리에 나온다. 여기서 확인하려는
|
||||||
`${kind}/${topic}/${project}`,
|
것은 "그 글이 문서에 있는가" 이므로 첫 자리로 충분하다.
|
||||||
|
*/
|
||||||
|
const [firstEvidence] = within(main).getAllByText(evidence, { exact: false });
|
||||||
|
expect(firstEvidence).toBeVisible();
|
||||||
|
const breadcrumb = within(main).getByRole("navigation", { name: "문서 경로" });
|
||||||
|
expect(breadcrumb).toHaveTextContent(`${kind}/${topic}/${project}`);
|
||||||
|
/*
|
||||||
|
글자만 보고 있었더니 주제 링크가 어디로 가는지는 아무도 묻지 않았다. 그 링크는
|
||||||
|
`/topics/:slug` 를 가리켰고, 그 화면은 세 개의 주제를 하드코딩해 두고 있어 실제 주제는
|
||||||
|
무엇이든 404 였다 — 게시한 모든 문서가 죽은 링크를 하나씩 달고 있었다.
|
||||||
|
*/
|
||||||
|
expect(within(breadcrumb).getByRole("link", { name: topic })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
`/explore?topic=${topicSlugs[topic]}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (path === "/cases/collection-fetch-join-pagination") {
|
/*
|
||||||
expect(within(main).getByText(`게시 ${published} · 마지막 검증 2026.08.11`)).toBeVisible();
|
Case 는 한 배치를 쓴다. 예전에는 픽스처 문서 하나만 `.case-meta` 를 받고 나머지 Case 는
|
||||||
|
`.public-document-header dl` 로 떨어졌으므로 여기도 경로로 갈랐다. 이제 유형으로 가른다.
|
||||||
|
*/
|
||||||
|
if (kind === "Case") {
|
||||||
|
expect(container.querySelector(".case-meta")).toHaveTextContent(`게시 ${published}`);
|
||||||
} else {
|
} else {
|
||||||
const metadata = container.querySelector(".public-document-header dl");
|
const metadata = container.querySelector(".public-document-header dl");
|
||||||
expect(metadata).toHaveTextContent(`유형${kind}`);
|
expect(metadata).toHaveTextContent(`유형${kind}`);
|
||||||
@@ -258,12 +282,12 @@ describe("TechLog canonical Public documents", () => {
|
|||||||
expect(image).toHaveAttribute("loading", "lazy");
|
expect(image).toHaveAttribute("loading", "lazy");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the generic Case markup and omits source relations for the canonical empty state", async () => {
|
it("uses the one Case layout and omits source relations for the canonical empty state", async () => {
|
||||||
const generic = await renderDocumentRoute(
|
const generic = await renderDocumentRoute(
|
||||||
"TECH_LOG_CASE",
|
"TECH_LOG_CASE",
|
||||||
"/cases/redis-adapter-ttl-boundary",
|
"/cases/redis-adapter-ttl-boundary",
|
||||||
);
|
);
|
||||||
expect(screen.getByRole("main")).toHaveClass("shell", "public-document-page");
|
expect(screen.getByRole("main")).toHaveClass("case-page");
|
||||||
expect(generic.container.querySelector("#ownership")).toHaveTextContent(
|
expect(generic.container.querySelector("#ownership")).toHaveTextContent(
|
||||||
"정책과 저장 명령의 주인을 구분하기",
|
"정책과 저장 명령의 주인을 구분하기",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -252,35 +252,26 @@ describe("TechLog project screens", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps project activity ordered and preserves self-fragment and record links", async () => {
|
/*
|
||||||
|
활동은 로그다 — 언제 무엇을 올렸는지만 적는다. 각 줄에 그 기록으로 가는 링크가 있으면 같은
|
||||||
|
글에 닿는 길이 둘이 되고, 읽는 사람은 "기록"과 "활동"이 어떻게 다른지 매번 다시 판단해야 한다.
|
||||||
|
글을 읽는 자리는 기록 화면 하나로 둔다.
|
||||||
|
*/
|
||||||
|
it("keeps project activity ordered and leaves reading to the records screen", async () => {
|
||||||
const { container } = await renderPublicRoute(
|
const { container } = await renderPublicRoute(
|
||||||
"TECH_LOG_PROJECT_ACTIVITY",
|
"TECH_LOG_PROJECT_ACTIVITY",
|
||||||
"/projects/backend-skeleton/activity",
|
"/projects/backend-skeleton/activity",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
const items = Array.from(
|
||||||
Array.from(container.querySelectorAll(".project-activity-list > li > article"), (item) => ({
|
container.querySelectorAll(".project-activity-list > li > article"),
|
||||||
id: item.id,
|
);
|
||||||
href: item.querySelector("a")?.getAttribute("href"),
|
expect(items.map((item) => item.id)).toEqual([
|
||||||
label: item.querySelector("a")?.textContent,
|
"fetch-join-case-published",
|
||||||
})),
|
"storage-contract",
|
||||||
).toEqual([
|
"redis-case-published",
|
||||||
{
|
|
||||||
id: "fetch-join-case-published",
|
|
||||||
href: "/cases/collection-fetch-join-pagination",
|
|
||||||
label: "연결된 공개 기록 읽기",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "storage-contract",
|
|
||||||
href: "/projects/backend-skeleton/activity#storage-contract",
|
|
||||||
label: "이 활동 위치 열기",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "redis-case-published",
|
|
||||||
href: "/cases/redis-adapter-ttl-boundary",
|
|
||||||
label: "연결된 공개 기록 읽기",
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
expect(items.flatMap((item) => Array.from(item.querySelectorAll("a")))).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { strict as assert } from "node:assert";
|
||||||
|
import { test } from "vitest";
|
||||||
|
|
||||||
|
import type { components } from "../../../src/features/tech-log/contracts/public/generated.ts";
|
||||||
|
import { createHttpPublicContentGateway } from "../../../src/features/tech-log/adapters/http/http-public-content-gateway.ts";
|
||||||
|
|
||||||
|
type QuestionDetail = components["schemas"]["QuestionDetailResponse"];
|
||||||
|
|
||||||
|
/*
|
||||||
|
Open Question 을 게시했는데 공개 상세가 「요청을 처리하지 못했습니다」만 띄웠다.
|
||||||
|
|
||||||
|
게이트웨이가 `points` 를 `{group, items}` 배열로 읽고 `.filter` 를 불렀는데, 계약의
|
||||||
|
`QuestionPointGroup` 은 그룹 이름을 키로 갖는 **객체**다. 객체에는 `.filter` 가 없으므로
|
||||||
|
매핑이 통째로 터졌다. `as` 캐스트가 그 어긋남을 타입 검사에서 가렸다.
|
||||||
|
|
||||||
|
탐색 목록은 이 칸들을 빈 배열로 두고 만들기 때문에 목록에서는 멀쩡히 보였다 — 그래서
|
||||||
|
"게시했는데 public 에 안 뜬다" 로만 드러났고, 어느 층이 깨졌는지는 보이지 않았다.
|
||||||
|
|
||||||
|
그래서 여기서는 계약 모양 그대로의 응답을 진짜 게이트웨이에 통과시키고, 화면이 읽는 네 칸이
|
||||||
|
실제로 채워져 나오는지 묻는다. 모양만 검사하면 이 사고는 다시 지나간다.
|
||||||
|
*/
|
||||||
|
function gatewayReturning(value: unknown) {
|
||||||
|
return createHttpPublicContentGateway({
|
||||||
|
operations: {
|
||||||
|
execute: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
kind: "SUCCESS" as const,
|
||||||
|
value,
|
||||||
|
metadata: { status: 200 },
|
||||||
|
effect: "NOT_APPLICABLE" as const,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const DETAIL = {
|
||||||
|
canonicalPath: "/questions/refresh-rotation-replica-contention",
|
||||||
|
indexable: true,
|
||||||
|
question: {
|
||||||
|
question: "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가",
|
||||||
|
summary: "두 replica가 같은 refresh token으로 동시에 갱신할 수 있다.",
|
||||||
|
context: "",
|
||||||
|
importance: "",
|
||||||
|
status: "OPEN",
|
||||||
|
nextVerification: "저장소를 공유한 뒤에 재현한다.",
|
||||||
|
points: {
|
||||||
|
facts: ["realm은 refresh token rotation과 재사용 허용 0회를 쓴다."],
|
||||||
|
assumptions: ["운영에서는 replica가 둘 이상이고 저장소를 공유한다."],
|
||||||
|
unknowns: ["같은 refresh token으로 동시에 갱신하면 어떻게 되는지."],
|
||||||
|
constraints: ["이미 발급된 access token은 만료 전까지 계속 통한다."],
|
||||||
|
},
|
||||||
|
updates: [],
|
||||||
|
openedAt: "2026-08-24T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-08-26T13:41:53.974512Z",
|
||||||
|
},
|
||||||
|
relations: {
|
||||||
|
primaryProject: {
|
||||||
|
type: "PROJECT",
|
||||||
|
title: "KeyCloak Patterns",
|
||||||
|
path: "/projects/keycloak-patterns",
|
||||||
|
summary: "Keycloak을 쓰면서 실제로 부딪힌 인증 경계를 기록합니다",
|
||||||
|
},
|
||||||
|
resultCase: {
|
||||||
|
type: "CASE",
|
||||||
|
title: "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출",
|
||||||
|
path: "/cases/split-custody-access-token",
|
||||||
|
summary: "토큰 교환과 토큰 관리의 책임이 서버로 이전했다.",
|
||||||
|
},
|
||||||
|
derivedReferences: [],
|
||||||
|
},
|
||||||
|
} satisfies QuestionDetail;
|
||||||
|
|
||||||
|
test("a published Open Question maps its four point groups into the screen's fields", async () => {
|
||||||
|
const gateway = gatewayReturning(DETAIL);
|
||||||
|
|
||||||
|
const record = await gateway.getRecord("QUESTION", "refresh-rotation-replica-contention");
|
||||||
|
|
||||||
|
assert.ok(record, "게시된 질문은 상세로 돌아와야 한다");
|
||||||
|
assert.equal(record.kind, "QUESTION");
|
||||||
|
assert.equal(record.title, DETAIL.question.question);
|
||||||
|
assert.equal(record.questionStatus, "OPEN");
|
||||||
|
assert.deepEqual([...record.facts], DETAIL.question.points.facts);
|
||||||
|
assert.deepEqual([...record.assumptions], DETAIL.question.points.assumptions);
|
||||||
|
assert.deepEqual([...record.unknowns], DETAIL.question.points.unknowns);
|
||||||
|
assert.deepEqual([...record.constraints], DETAIL.question.points.constraints);
|
||||||
|
assert.equal(record.nextValidation, DETAIL.question.nextVerification);
|
||||||
|
// 질문 상세는 프로젝트를 `relations` 에 담는다 — `question` 에서 찾으면 머리말의 프로젝트
|
||||||
|
// 칸이 늘 비어 있다.
|
||||||
|
assert.equal(record.projectTitle, "KeyCloak Patterns");
|
||||||
|
assert.equal(record.projectSlug, "keycloak-patterns");
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
관계의 이유 자리에 영문 키가 그대로 나오고 있었다 — 매퍼가 찾던 그룹 이름이 계약에 없는
|
||||||
|
것들이었기 때문이다. 그리고 `primaryProject` 는 관계가 아니라 이 질문이 속한 프로젝트다.
|
||||||
|
*/
|
||||||
|
test("a question's relations read their reason from the contract's own group names", async () => {
|
||||||
|
const gateway = gatewayReturning(DETAIL);
|
||||||
|
|
||||||
|
const record = await gateway.getRecord("QUESTION", "refresh-rotation-replica-contention");
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
[...record!.relations].map((relation) => ({ reason: relation.reason, path: relation.path })),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
reason: "이 질문에서 나온 기록",
|
||||||
|
path: "/cases/split-custody-access-token",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
네 그룹은 계약이 필수로 두고 있다. 이름이 하나라도 바뀌면 위 `satisfies` 가 먼저 깨지고,
|
||||||
|
화면이 읽는 이름과의 대응도 여기서 끊긴다.
|
||||||
|
*/
|
||||||
|
test("the contract names the four point groups the screen renders", () => {
|
||||||
|
const points: components["schemas"]["QuestionPointGroup"] = {
|
||||||
|
facts: [],
|
||||||
|
assumptions: [],
|
||||||
|
unknowns: [],
|
||||||
|
constraints: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual(Object.keys(points).sort(), [
|
||||||
|
"assumptions",
|
||||||
|
"constraints",
|
||||||
|
"facts",
|
||||||
|
"unknowns",
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { strict as assert } from "node:assert";
|
||||||
|
import { test } from "vitest";
|
||||||
|
|
||||||
|
import type { components } from "../../../src/features/tech-log/contracts/public/generated.ts";
|
||||||
|
|
||||||
|
type ReferenceBody =
|
||||||
|
components["schemas"]["ReferenceDetailResponse"]["reference"];
|
||||||
|
|
||||||
|
/*
|
||||||
|
이 파일은 사고 하나에서 나왔다.
|
||||||
|
|
||||||
|
공개 Reference 화면이 통째로 비어 있었다. Studio 에서는 같은 글이 다 보였으므로 "공개 쪽만 안
|
||||||
|
나온다" 로 드러났다. 원인은 게이트웨이가 읽던 이름이 계약에 없는 것들이었다는 것이다 —
|
||||||
|
`purposeSummary`, `applyWhenMarkdown`, `exceptionsMarkdown`, `examplesMarkdown`. 전부 undefined 로
|
||||||
|
떨어졌고 타입 검사는 `as string` 단언 때문에 아무 말도 하지 않았다.
|
||||||
|
|
||||||
|
그래서 여기서 확인하는 것은 값이 아니라 **이름**이다. 계약이 그 칸을 갖고 있는가, 그리고 화면이
|
||||||
|
기대하는 모양인가.
|
||||||
|
*/
|
||||||
|
test("the public Reference contract keeps the fields the screen reads", () => {
|
||||||
|
const reference = {
|
||||||
|
title: "",
|
||||||
|
// 제목 바로 아래에 오는 것은 문서의 요약이다. scopeSummary 는 그 아래 "이 기준을 쓰는 이유"다.
|
||||||
|
summary: "",
|
||||||
|
scopeSummary: "",
|
||||||
|
appliesTo: [],
|
||||||
|
excludedScope: [],
|
||||||
|
rules: [{ title: "", body: "" }],
|
||||||
|
examples: [],
|
||||||
|
freshnessStatus: "CURRENT",
|
||||||
|
content: "",
|
||||||
|
contentFormat: "MARKDOWN",
|
||||||
|
contentFormatVersion: 1,
|
||||||
|
tags: [],
|
||||||
|
publishedAt: "",
|
||||||
|
updatedAt: "",
|
||||||
|
} satisfies ReferenceBody;
|
||||||
|
|
||||||
|
// 화면이 읽는 이름 그대로. 하나라도 계약에서 사라지면 위의 `satisfies` 가 먼저 깨진다.
|
||||||
|
assert.deepEqual(Object.keys(reference).sort(), [
|
||||||
|
"appliesTo",
|
||||||
|
"content",
|
||||||
|
"contentFormat",
|
||||||
|
"contentFormatVersion",
|
||||||
|
"examples",
|
||||||
|
"excludedScope",
|
||||||
|
"freshnessStatus",
|
||||||
|
"publishedAt",
|
||||||
|
"rules",
|
||||||
|
"scopeSummary",
|
||||||
|
"summary",
|
||||||
|
"tags",
|
||||||
|
"title",
|
||||||
|
"updatedAt",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
Reference 의 본문은 `content` 마크다운이 아니라 규칙과 예시에 있다. Studio 의 편집기가 제목과
|
||||||
|
본문을 따로 받고 `body_markdown` 은 비워 두기 때문이다 — 그래서 `content` 를 잘라 규칙을 만들려
|
||||||
|
하면 언제나 빈 목록이 된다.
|
||||||
|
*/
|
||||||
|
test("a rule carries its own title and body, not a slice of markdown", () => {
|
||||||
|
const rule: NonNullable<ReferenceBody["rules"]>[number] = {
|
||||||
|
title: "Authorization Endpoint에는 client_secret을 보내지 않는다",
|
||||||
|
body: "이 요청은 브라우저의 full-page navigation으로 나간다.",
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(typeof rule.title, "string");
|
||||||
|
assert.equal(typeof rule.body, "string");
|
||||||
|
});
|
||||||
@@ -386,7 +386,12 @@ describe("shared Public record renderer", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses generic Case markup and does not present generatedAt as publication", () => {
|
/*
|
||||||
|
Case 렌더러가 둘이었을 때 이 테스트는 "축약본" 쪽을 지켰다. 어느 쪽을 쓸지는 슬러그 비교가
|
||||||
|
정했고, 설계 픽스처 문서 하나만 breadcrumb 과 목차가 있는 배치를 받았다 — 실제로 작성한 Case 는
|
||||||
|
전부 축약본이었고 오른쪽 목차가 어디에도 나오지 않았다. 배치는 이제 하나다.
|
||||||
|
*/
|
||||||
|
it("uses the one Case layout and does not present generatedAt as publication", () => {
|
||||||
renderInRouter(
|
renderInRouter(
|
||||||
<PublicRecordRenderer
|
<PublicRecordRenderer
|
||||||
{...renderDependencies}
|
{...renderDependencies}
|
||||||
@@ -402,14 +407,17 @@ describe("shared Public record renderer", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const main = screen.getByRole("main");
|
const main = screen.getByRole("main");
|
||||||
expect(main).toHaveClass("shell", "public-document-page");
|
expect(main).toHaveClass("case-page");
|
||||||
expect(main).toHaveAttribute("id", "main-content");
|
expect(main).toHaveAttribute("id", "main-content");
|
||||||
expect(screen.getByText("게시 전")).toBeVisible();
|
// 완성된 배치에서는 "게시 전" 이 기록 줄의 한 문장 안에 들어간다 — 홀로 선 노드가 아니다.
|
||||||
|
expect(main).toHaveTextContent("게시 전");
|
||||||
expect(main).not.toHaveTextContent("2035.05.06");
|
expect(main).not.toHaveTextContent("2035.05.06");
|
||||||
expect(screen.getByRole("region", { name: "문제와 결론" })).toHaveTextContent(
|
expect(screen.getByRole("region", { name: "문제와 결론" })).toHaveTextContent(
|
||||||
"문제문제결론결론",
|
"문제문제결론결론",
|
||||||
);
|
);
|
||||||
expect(screen.getByText("일반 본문")).toBeVisible();
|
expect(screen.getByText("일반 본문")).toBeVisible();
|
||||||
|
// 제목이 없는 본문에는 목차를 그리지 않는다 — 빈 레일만 남는다.
|
||||||
|
expect(screen.queryByLabelText("문서 목차")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders supplied Reference and Question preview fields and empty copy", () => {
|
it("renders supplied Reference and Question preview fields and empty copy", () => {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user