Compare commits
10
Commits
c03b0c77b8
...
ab67eb912c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab67eb912c | ||
|
|
e9b866184a | ||
|
|
84d72c4f60 | ||
|
|
6b9dc3f0a2 | ||
|
|
f9d20e8946 | ||
|
|
014f21b9e1 | ||
|
|
16e5b9f4ec | ||
|
|
0eb3c86839 | ||
|
|
c87e0a2338 | ||
|
|
b3aa304975 |
@@ -29,3 +29,4 @@ artifacts/tests/visual/
|
||||
# Git worktrees created inside the repository. A worktree is a checkout, not
|
||||
# source: committing one would nest a second working copy inside this one.
|
||||
.worktrees/
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -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",
|
||||
"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",
|
||||
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASES.md",
|
||||
"schemaId": "markdown",
|
||||
"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",
|
||||
"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-ASSETS-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-RELEASE-EDIT-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-report-json"
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"registrationAllowed": false,
|
||||
"loginTheme": "keycloak",
|
||||
"accessTokenLifespan": 300,
|
||||
"ssoSessionIdleTimeout": 1800,
|
||||
"ssoSessionMaxLifespan": 36000,
|
||||
"ssoSessionIdleTimeout": 28800,
|
||||
"ssoSessionMaxLifespan": 86400,
|
||||
"roles": {
|
||||
"realm": [
|
||||
{ "name": "studio-author", "description": "Tech Log Studio 편집 권한 (studio:read + studio:write)" }
|
||||
|
||||
@@ -594,6 +594,20 @@ const commonSecurityRules = {
|
||||
"CallExpression[callee.object.name='document'][callee.property.name='createElement'][arguments.0.value='script']",
|
||||
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": {
|
||||
"setAlgorithm": "CA_CONTRACT_SET_V1",
|
||||
"setDigest": "sha256:7ee35548d2a84b744f8c17c7b785a79b328dcf756f5c9df8d77d37ff1535b087",
|
||||
"setDigest": "sha256:b729150c56b64ba01d7a71a7442c14bb04d9e573b852d1d1879e748854c20174",
|
||||
"packages": [
|
||||
{
|
||||
"packageId": "@tech-log/management-contract",
|
||||
"version": "1.0.0",
|
||||
"digest": "sha256:d19ae7c4fbcac924a356bbb0cc1a46a4046ecec701158ca0a9b7cc089bbaf878",
|
||||
"digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
|
||||
"runtimeProtocolVersion": 1,
|
||||
"sourceRevision": "b195b29"
|
||||
"sourceRevision": "06ae075"
|
||||
},
|
||||
{
|
||||
"packageId": "@tech-log/public-contract",
|
||||
"version": "2.1.0",
|
||||
"digest": "sha256:37e6f804165de3e492e975075bea563ee41ae74076222a3562d3452631bfdb2b",
|
||||
"digest": "sha256:702d6666a8feba9899c7eb7c2a94a0880bcb23b178c7ed2009c6e69d9a1c848c",
|
||||
"runtimeProtocolVersion": 1,
|
||||
"sourceRevision": "b195b29"
|
||||
"sourceRevision": "06ae075"
|
||||
},
|
||||
{
|
||||
"packageId": "@tech-log/studio-contract",
|
||||
"version": "3.0.0",
|
||||
"digest": "sha256:674327a82951fd4a1bc2594c858072dfcd0b9abe7c63198283da5d8c92a04326",
|
||||
"version": "3.1.0",
|
||||
"digest": "sha256:6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f",
|
||||
"runtimeProtocolVersion": 1,
|
||||
"sourceRevision": "b195b29"
|
||||
"sourceRevision": "06ae075"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -468,7 +468,12 @@ const CANONICAL_GATE_SHAPE_SHA256 =
|
||||
// TECH_LOG_STUDIO_RELEASES evidence artifact, by the same method — 8c73d447…
|
||||
// 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.
|
||||
"187dbd9676b7b3444409b5af4143d49927b1eacc67f57a099f4a3765bb64d865";
|
||||
// 프로젝트 편집 화면: FE-GATE-009 가 TECH_LOG_STUDIO_PROJECT_EDIT 증거 아티팩트를
|
||||
// 얻어 다시 계산했다. 같은 방법이다 — 187dbd96… 을 이전 gates.json 에서 먼저 재현해,
|
||||
// 이 값을 만든 계산이 상수가 고정될 때 쓰인 그 계산임을 확인했다.
|
||||
// 릴리즈 편집 화면: 같은 방법으로 다시 계산했다. f9e7e521… 을 이전 gates.json 에서 먼저
|
||||
// 재현했다.
|
||||
"fb138e7c51fdf969f755cd8ff32cf627f1750b966d212c33ee996c8c578db0e3";
|
||||
|
||||
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
|
||||
const normalized = gates.map(
|
||||
@@ -523,8 +528,13 @@ function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[]
|
||||
// Alignment follow-up, item 2 added the TechLog junit report.
|
||||
// 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.
|
||||
if (contract.artifacts.length !== 132) {
|
||||
failures.push(`artifact authority baseline must contain exactly 132 artifacts; received ${contract.artifacts.length}`);
|
||||
// The project edit route did the same: it is what finally lets a project carry
|
||||
// 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) {
|
||||
failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`);
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type {
|
||||
CreateDraftResponse,
|
||||
HomeFocusRequest,
|
||||
HomeFocusResponse,
|
||||
ProjectActivityRequest,
|
||||
ProjectActivityResponse,
|
||||
UpdateProjectActivityRequest,
|
||||
ProjectEditResponse,
|
||||
ProjectIndexPage,
|
||||
ProjectUpdateRequest,
|
||||
@@ -85,6 +90,28 @@ export function createHttpManagementGateway(
|
||||
deleteRelease: async (id: string, expectedVersion: number) => {
|
||||
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) =>
|
||||
run<PublishResponse>("publishRelease", { id, expectedVersion }),
|
||||
archiveRelease: (id: string, expectedVersion: number) =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
HomeFocusItem,
|
||||
LatestRecordEntry,
|
||||
ProjectActivity,
|
||||
ProjectDecision,
|
||||
Project,
|
||||
@@ -320,6 +321,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[]> {
|
||||
const home = await read<Readonly<{ focus?: Readonly<Record<string, never>> }>>(
|
||||
"getPublicHome",
|
||||
@@ -463,6 +491,7 @@ export function createHttpPublicContentGateway(
|
||||
getProjectDecisions,
|
||||
getProjectActivity,
|
||||
getHomeFocusItems,
|
||||
getLatestEntries,
|
||||
searchPublicContent,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type HomeFocusItem,
|
||||
} from "./public-content.ts";
|
||||
import type {
|
||||
LatestRecordEntry,
|
||||
PublicContentQueries,
|
||||
PublicTopic,
|
||||
} from "../../application/ports/public-content-queries.ts";
|
||||
@@ -119,6 +120,43 @@ export function listTopics(): PublicTopic[] {
|
||||
.map((entry) => Object.freeze(entry));
|
||||
}
|
||||
|
||||
/**
|
||||
* 픽스처판 "최근 기록". HTTP 어댑터가 서버에서 읽어 오는 것과 같은 의미를 픽스처에서 만든다 —
|
||||
* 프로젝트 활동과 릴리스가 원천이다.
|
||||
*/
|
||||
export function getLatestEntries(): LatestRecordEntry[] {
|
||||
const recordByPath = new Map(publicRecords.map((record) => [record.path, record]));
|
||||
const activities = projects.flatMap((project) =>
|
||||
project.activity.map((activity) => {
|
||||
const record = recordByPath.get(activity.recordPath ?? activity.path);
|
||||
const published = activity.type === "PUBLICATION" && record;
|
||||
return {
|
||||
id: activity.id,
|
||||
entryType: (published ? record.kind : "PROJECT_ACTIVITY") as LatestRecordEntry["entryType"],
|
||||
title: published ? record.title : activity.title,
|
||||
summary: activity.summary,
|
||||
path: activity.path,
|
||||
publishedAt: activity.dateTime,
|
||||
topic: record?.topic ?? project.topics[0] ?? "",
|
||||
project: project.title,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const releaseEntries = releases.map((release) => ({
|
||||
id: `release-${release.version}`,
|
||||
entryType: "RELEASE" as const,
|
||||
title: release.title,
|
||||
summary: release.summary,
|
||||
path: release.path,
|
||||
publishedAt: release.publishedAt,
|
||||
topic: "TechLog",
|
||||
project: "TechLog",
|
||||
}));
|
||||
return [...activities, ...releaseEntries].sort((left, right) =>
|
||||
right.publishedAt.localeCompare(left.publishedAt),
|
||||
);
|
||||
}
|
||||
|
||||
export function getHomeFocusItems(): HomeFocusItem[] {
|
||||
const project = getProject("backend-skeleton");
|
||||
const question = getRecord("QUESTION", "validate-edge-token-again");
|
||||
@@ -264,6 +302,9 @@ export const publicContentQueries = Object.freeze({
|
||||
async listTopics() {
|
||||
return listTopics();
|
||||
},
|
||||
async getLatestEntries() {
|
||||
return getLatestEntries();
|
||||
},
|
||||
async getHomeFocusItems() {
|
||||
return getHomeFocusItems();
|
||||
},
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type {
|
||||
CreateDraftResponse,
|
||||
HomeFocusRequest,
|
||||
HomeFocusResponse,
|
||||
ProjectActivityRequest,
|
||||
ProjectActivityResponse,
|
||||
UpdateProjectActivityRequest,
|
||||
ProjectEditResponse,
|
||||
ProjectIndexPage,
|
||||
ProjectUpdateRequest,
|
||||
@@ -27,6 +32,33 @@ export type ManagementGateway = Readonly<{
|
||||
createProject(title: string): Promise<CreateDraftResponse>;
|
||||
updateProject(id: string, body: ProjectUpdateRequest): Promise<ProjectEditResponse>;
|
||||
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>;
|
||||
getRelease(id: string): Promise<ReleaseEditResponse>;
|
||||
createRelease(title: string): Promise<CreateDraftResponse>;
|
||||
|
||||
@@ -164,6 +164,24 @@ export type PublicTopic = {
|
||||
recordCount: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 홈의 "최근 기록" 한 줄. 서버가 공개 투영에서 직접 고른다.
|
||||
*
|
||||
* 화면이 프로젝트를 하나씩 돌며 조립하던 때에는, 게시된 문서라도 그 문서가 매달린 프로젝트가
|
||||
* 공개되어 있지 않으면 목록에서 통째로 빠졌다 — 실제로 게시한 Case 는 안 보이고 릴리스만
|
||||
* 남았다. 무엇이 최근인지는 공개 투영 하나가 알고 있으므로 거기서 그대로 읽는다.
|
||||
*/
|
||||
export type LatestRecordEntry = {
|
||||
id: string;
|
||||
entryType: "CASE" | "REFERENCE" | "PROJECT_ACTIVITY" | "RELEASE";
|
||||
title: string;
|
||||
summary: string;
|
||||
path: string;
|
||||
publishedAt: string;
|
||||
topic: string;
|
||||
project: string;
|
||||
};
|
||||
|
||||
export type FocusKey = "current" | "question" | "decision";
|
||||
|
||||
export type HomeFocusItem = {
|
||||
@@ -227,6 +245,7 @@ export type PublicContentQueries = Readonly<{
|
||||
* 있었고, Studio 에서 주제를 만들어도 바뀌지 않았다.
|
||||
*/
|
||||
listTopics(): Promise<PublicTopic[]>;
|
||||
getLatestEntries(): Promise<LatestRecordEntry[]>;
|
||||
getHomeFocusItems(): Promise<HomeFocusItem[]>;
|
||||
searchPublicContent(query: string): Promise<SearchablePublicEntity[]>;
|
||||
}>;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"packageId": "@tech-log/management-contract",
|
||||
"version": "1.0.0",
|
||||
"digest": "sha256:d19ae7c4fbcac924a356bbb0cc1a46a4046ecec701158ca0a9b7cc089bbaf878",
|
||||
"sourceRevision": "b195b29",
|
||||
"digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
|
||||
"sourceRevision": "06ae075",
|
||||
"operationIds": [
|
||||
"createCaseDraft",
|
||||
"getCaseForEdit",
|
||||
@@ -83,6 +83,7 @@
|
||||
"updateHomeFocus",
|
||||
"listStudioProjectActivities",
|
||||
"createProjectActivity",
|
||||
"updateProjectActivity"
|
||||
"updateProjectActivity",
|
||||
"deleteProjectActivity"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -15,3 +15,8 @@ export type ReleaseIndexItem = Schemas["ReleaseIndexItem"];
|
||||
export type ReleaseIndexPage = Schemas["ReleaseIndexPage"];
|
||||
export type ReleaseUpdateRequest = Schemas["ReleaseUpdateRequest"];
|
||||
export type PublishResponse = Schemas["PublishResponse"];
|
||||
export type HomeFocusRequest = Schemas["HomeFocusRequest"];
|
||||
export type HomeFocusResponse = Schemas["HomeFocusResponse"];
|
||||
export type ProjectActivityRequest = Schemas["ProjectActivityRequest"];
|
||||
export type UpdateProjectActivityRequest = Schemas["UpdateProjectActivityRequest"];
|
||||
export type ProjectActivityResponse = Schemas["ProjectActivityResponse"];
|
||||
|
||||
@@ -841,7 +841,7 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/studio/home-focus": {
|
||||
"/api/v1/studio/home/focus": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
@@ -883,7 +883,12 @@ export interface paths {
|
||||
get?: never;
|
||||
put: operations["updateProjectActivity"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
/** @description 프로젝트 활동 한 줄을 지운다. 계약에는 만들기와 고치기만 있었고 지우기가 없어서, 잘못
|
||||
* 적은 줄이 공개 타임라인에 영구히 남았다.
|
||||
*
|
||||
* `AUTO` 로 기록된 줄은 게시 같은 실제 사건의 흔적이므로 지우지 않는다 — 지우면 무슨 일이
|
||||
* 있었는지가 사라진다. 손으로 적은 `MANUAL` 줄만 지울 수 있다. */
|
||||
delete: operations["deleteProjectActivity"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
@@ -963,6 +968,24 @@ export interface components {
|
||||
data: components["schemas"]["ReleaseEditResponse"];
|
||||
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: {
|
||||
/** @constant */
|
||||
success: true;
|
||||
@@ -5519,7 +5542,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PublishResponse"];
|
||||
"application/json": components["schemas"]["PublishResponseEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Bad Request */
|
||||
@@ -5528,7 +5551,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
@@ -5537,7 +5560,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
@@ -5546,7 +5569,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Not Found */
|
||||
@@ -5555,7 +5578,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Conflict */
|
||||
@@ -5564,7 +5587,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unprocessable Content */
|
||||
@@ -5573,7 +5596,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Internal Server Error */
|
||||
@@ -5582,7 +5605,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -5610,7 +5633,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProjectEditResponse"];
|
||||
"application/json": components["schemas"]["ProjectEditResponseEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Bad Request */
|
||||
@@ -5619,7 +5642,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
@@ -5628,7 +5651,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
@@ -5637,7 +5660,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Not Found */
|
||||
@@ -5646,7 +5669,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Conflict */
|
||||
@@ -5655,7 +5678,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unprocessable Content */
|
||||
@@ -5664,7 +5687,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Internal Server Error */
|
||||
@@ -5673,7 +5696,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -8117,7 +8140,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HomeFocusResponse"];
|
||||
"application/json": components["schemas"]["HomeFocusResponseEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Bad Request */
|
||||
@@ -8126,7 +8149,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
@@ -8135,7 +8158,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
@@ -8144,7 +8167,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Not Found */
|
||||
@@ -8153,7 +8176,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Internal Server Error */
|
||||
@@ -8162,7 +8185,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -8188,7 +8211,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HomeFocusResponse"];
|
||||
"application/json": components["schemas"]["HomeFocusResponseEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Bad Request */
|
||||
@@ -8197,7 +8220,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
@@ -8206,7 +8229,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
@@ -8215,7 +8238,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Not Found */
|
||||
@@ -8224,7 +8247,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Conflict */
|
||||
@@ -8233,7 +8256,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unprocessable Content */
|
||||
@@ -8242,7 +8265,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Internal Server Error */
|
||||
@@ -8251,7 +8274,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -8273,7 +8296,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProjectActivityResponse"][];
|
||||
"application/json": components["schemas"]["ProjectActivityListEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 401 */
|
||||
@@ -8282,7 +8305,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 403 */
|
||||
@@ -8291,7 +8314,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 404 */
|
||||
@@ -8300,7 +8323,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 500 */
|
||||
@@ -8309,7 +8332,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -8337,7 +8360,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProjectActivityResponse"];
|
||||
"application/json": components["schemas"]["ProjectActivityResponseEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 400 */
|
||||
@@ -8346,7 +8369,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 401 */
|
||||
@@ -8355,7 +8378,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 403 */
|
||||
@@ -8364,7 +8387,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 404 */
|
||||
@@ -8373,7 +8396,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 409 */
|
||||
@@ -8382,7 +8405,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 422 */
|
||||
@@ -8391,7 +8414,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 500 */
|
||||
@@ -8400,7 +8423,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -8429,7 +8452,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProjectActivityResponse"];
|
||||
"application/json": components["schemas"]["ProjectActivityResponseEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 400 */
|
||||
@@ -8438,7 +8461,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 401 */
|
||||
@@ -8447,7 +8470,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 403 */
|
||||
@@ -8456,7 +8479,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 404 */
|
||||
@@ -8465,7 +8488,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 409 */
|
||||
@@ -8474,7 +8497,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 422 */
|
||||
@@ -8483,7 +8506,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 500 */
|
||||
@@ -8492,7 +8515,97 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
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:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PublishResponse'
|
||||
$ref: '#/components/schemas/PublishResponseEnvelope'
|
||||
'400':
|
||||
description: Bad Request
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'401':
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'403':
|
||||
description: Forbidden
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'404':
|
||||
description: Not Found
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'409':
|
||||
description: Conflict
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'422':
|
||||
description: Unprocessable Content
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'500':
|
||||
description: Internal Server Error
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -3085,49 +3085,49 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProjectEditResponse'
|
||||
$ref: '#/components/schemas/ProjectEditResponseEnvelope'
|
||||
'400':
|
||||
description: Bad Request
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'401':
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'403':
|
||||
description: Forbidden
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'404':
|
||||
description: Not Found
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'409':
|
||||
description: Conflict
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'422':
|
||||
description: Unprocessable Content
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'500':
|
||||
description: Internal Server Error
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -4973,7 +4973,7 @@ paths:
|
||||
$ref: '#/components/schemas/ExpectedVersionRequest'
|
||||
security:
|
||||
- sessionCookie: []
|
||||
/api/v1/studio/home-focus:
|
||||
/api/v1/studio/home/focus:
|
||||
get:
|
||||
operationId: getHomeFocus
|
||||
tags:
|
||||
@@ -4985,37 +4985,37 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HomeFocusResponse'
|
||||
$ref: '#/components/schemas/HomeFocusResponseEnvelope'
|
||||
'400':
|
||||
description: Bad Request
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'401':
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'403':
|
||||
description: Forbidden
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'404':
|
||||
description: Not Found
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'500':
|
||||
description: Internal Server Error
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
security:
|
||||
- sessionCookie: []
|
||||
put:
|
||||
@@ -5030,49 +5030,49 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HomeFocusResponse'
|
||||
$ref: '#/components/schemas/HomeFocusResponseEnvelope'
|
||||
'400':
|
||||
description: Bad Request
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'401':
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'403':
|
||||
description: Forbidden
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'404':
|
||||
description: Not Found
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'409':
|
||||
description: Conflict
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'422':
|
||||
description: Unprocessable Content
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'500':
|
||||
description: Internal Server Error
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -5101,33 +5101,31 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ProjectActivityResponse'
|
||||
$ref: '#/components/schemas/ProjectActivityListEnvelope'
|
||||
'401':
|
||||
description: '401'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'403':
|
||||
description: '403'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'404':
|
||||
description: '404'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'500':
|
||||
description: '500'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
post:
|
||||
operationId: createProjectActivity
|
||||
tags:
|
||||
@@ -5154,49 +5152,49 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProjectActivityResponse'
|
||||
$ref: '#/components/schemas/ProjectActivityResponseEnvelope'
|
||||
'400':
|
||||
description: '400'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'401':
|
||||
description: '401'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'403':
|
||||
description: '403'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'404':
|
||||
description: '404'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'409':
|
||||
description: '409'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'422':
|
||||
description: '422'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'500':
|
||||
description: '500'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
/api/v1/studio/projects/{id}/activities/{activityId}:
|
||||
put:
|
||||
operationId: updateProjectActivity
|
||||
@@ -5230,49 +5228,126 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProjectActivityResponse'
|
||||
$ref: '#/components/schemas/ProjectActivityResponseEnvelope'
|
||||
'400':
|
||||
description: '400'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'401':
|
||||
description: '401'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'403':
|
||||
description: '403'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'404':
|
||||
description: '404'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'409':
|
||||
description: '409'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'422':
|
||||
description: '422'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'500':
|
||||
description: '500'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
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:
|
||||
securitySchemes:
|
||||
sessionCookie:
|
||||
@@ -5510,6 +5585,53 @@ components:
|
||||
$ref: '#/components/schemas/ReleaseEditResponse'
|
||||
meta:
|
||||
$ref: '#/components/schemas/ResponseMeta'
|
||||
HomeFocusResponseEnvelope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- success
|
||||
- data
|
||||
- meta
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
const: true
|
||||
data:
|
||||
$ref: '#/components/schemas/HomeFocusResponse'
|
||||
meta:
|
||||
$ref: '#/components/schemas/ResponseMeta'
|
||||
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:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"packageId": "@tech-log/public-contract",
|
||||
"version": "2.1.0",
|
||||
"digest": "sha256:37e6f804165de3e492e975075bea563ee41ae74076222a3562d3452631bfdb2b",
|
||||
"sourceRevision": "b195b29",
|
||||
"digest": "sha256:702d6666a8feba9899c7eb7c2a94a0880bcb23b178c7ed2009c6e69d9a1c848c",
|
||||
"sourceRevision": "06ae075",
|
||||
"operationIds": [
|
||||
"getPublicSite",
|
||||
"getPublicHome",
|
||||
|
||||
@@ -670,6 +670,9 @@ export interface components {
|
||||
nextStep?: string;
|
||||
systemOverviewMarkdown?: string;
|
||||
technologies?: string[];
|
||||
/** @description 이 프로젝트가 다루는 주제. 프로젝트 화면의 "주요 주제" 가 이 목록을 그린다.
|
||||
* 화면은 처음부터 이 값을 읽고 있었지만 계약에 자리가 없어 늘 비어 있었다. */
|
||||
topics?: components["schemas"]["TopicSummary"][];
|
||||
/** Format: date-time */
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
@@ -1572,6 +1572,13 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
topics:
|
||||
type: array
|
||||
description: |-
|
||||
이 프로젝트가 다루는 주제. 프로젝트 화면의 "주요 주제" 가 이 목록을 그린다.
|
||||
화면은 처음부터 이 값을 읽고 있었지만 계약에 자리가 없어 늘 비어 있었다.
|
||||
items:
|
||||
$ref: '#/components/schemas/TopicSummary'
|
||||
updatedAt: *id003
|
||||
featuredDecision:
|
||||
$ref: '#/components/schemas/RelatedEntry'
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"packageId": "@tech-log/studio-contract",
|
||||
"version": "3.0.0",
|
||||
"digest": "sha256:674327a82951fd4a1bc2594c858072dfcd0b9abe7c63198283da5d8c92a04326",
|
||||
"sourceRevision": "b195b29",
|
||||
"version": "3.1.0",
|
||||
"digest": "sha256:6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f",
|
||||
"sourceRevision": "06ae075",
|
||||
"operationIds": [
|
||||
"getStudioSession",
|
||||
"getStudioDashboard",
|
||||
|
||||
@@ -1074,7 +1074,32 @@ export interface components {
|
||||
height: number | null;
|
||||
decorative: boolean;
|
||||
};
|
||||
CaseRenderBlock: components["schemas"]["HeadingBlock"] | components["schemas"]["ParagraphBlock"] | components["schemas"]["BlockquoteBlock"] | components["schemas"]["UnorderedListBlock"] | components["schemas"]["OrderedListBlock"] | components["schemas"]["CodeBlock"] | components["schemas"]["DataTableBlock"] | components["schemas"]["CalloutBlock"] | components["schemas"]["EvidenceFigureBlock"];
|
||||
/** @description `---` 로 쓴 구분선이다. 담을 내용이 없으므로 `type` 뿐이다.
|
||||
* */
|
||||
ThematicBreakBlock: {
|
||||
/**
|
||||
* @description discriminator enum property added by openapi-typescript
|
||||
* @enum {string}
|
||||
*/
|
||||
type: "THEMATIC_BREAK";
|
||||
};
|
||||
/** @description `` 로 쓴 그림이다.
|
||||
*
|
||||
* `EvidenceFigureBlock` 과 나누는 기준은 출처다. evidence 는 assetKey 로 가리켜 게시
|
||||
* 시점에 고정되고 확대 보기를 갖지만, 이쪽은 작성자가 적은 경로를 그대로 쓴다. 경로 규칙은
|
||||
* 링크와 같다 — 외부 스킴과 `javascript:` 는 거절한다.
|
||||
* */
|
||||
ImageBlock: {
|
||||
/**
|
||||
* @description discriminator enum property added by openapi-typescript
|
||||
* @enum {string}
|
||||
*/
|
||||
type: "IMAGE";
|
||||
src: string;
|
||||
alt: string;
|
||||
title: string | null;
|
||||
};
|
||||
CaseRenderBlock: components["schemas"]["HeadingBlock"] | components["schemas"]["ParagraphBlock"] | components["schemas"]["BlockquoteBlock"] | components["schemas"]["UnorderedListBlock"] | components["schemas"]["OrderedListBlock"] | components["schemas"]["CodeBlock"] | components["schemas"]["DataTableBlock"] | components["schemas"]["CalloutBlock"] | components["schemas"]["EvidenceFigureBlock"] | components["schemas"]["ThematicBreakBlock"] | components["schemas"]["ImageBlock"];
|
||||
CasePublicRenderModel: components["schemas"]["PublicRenderModelBase"] & {
|
||||
/** @enum {string} */
|
||||
kind: "CASE";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Tech Log Studio API
|
||||
version: 3.0.0
|
||||
version: 3.1.0
|
||||
description: |
|
||||
Tech Log Studio orchestration 계약이다.
|
||||
|
||||
@@ -1321,7 +1321,10 @@ components:
|
||||
properties:
|
||||
type: { type: string, enum: [HEADING] }
|
||||
id: { type: string, minLength: 1, maxLength: 200 }
|
||||
level: { type: integer, minimum: 2, maximum: 4 }
|
||||
# 작성자가 쓴 그대로 담는다. 서버 렌더러는 이 값을 2..4 로 좁혀 문서 안 제목 위계를
|
||||
# 지키므로(BlockRenderer), 계약이 1..6 을 거절할 이유가 없다 — 거절하면 `#` 로 시작한
|
||||
# 평범한 Markdown 이 통째로 렌더링되지 않는다.
|
||||
level: { type: integer, minimum: 1, maximum: 6 }
|
||||
content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
|
||||
ParagraphBlock:
|
||||
type: object
|
||||
@@ -1452,6 +1455,29 @@ components:
|
||||
width: { type: [integer, "null"], minimum: 1 }
|
||||
height: { type: [integer, "null"], minimum: 1 }
|
||||
decorative: { type: boolean }
|
||||
ThematicBreakBlock:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
description: |
|
||||
`---` 로 쓴 구분선이다. 담을 내용이 없으므로 `type` 뿐이다.
|
||||
required: [type]
|
||||
properties:
|
||||
type: { type: string, enum: [THEMATIC_BREAK] }
|
||||
ImageBlock:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
description: |
|
||||
`` 로 쓴 그림이다.
|
||||
|
||||
`EvidenceFigureBlock` 과 나누는 기준은 출처다. evidence 는 assetKey 로 가리켜 게시
|
||||
시점에 고정되고 확대 보기를 갖지만, 이쪽은 작성자가 적은 경로를 그대로 쓴다. 경로 규칙은
|
||||
링크와 같다 — 외부 스킴과 `javascript:` 는 거절한다.
|
||||
required: [type, src, alt, title]
|
||||
properties:
|
||||
type: { type: string, enum: [IMAGE] }
|
||||
src: { type: string, minLength: 1, maxLength: 500 }
|
||||
alt: { type: string, maxLength: 300 }
|
||||
title: { type: [string, "null"], maxLength: 300 }
|
||||
CaseRenderBlock:
|
||||
oneOf:
|
||||
- { $ref: "#/components/schemas/HeadingBlock" }
|
||||
@@ -1463,6 +1489,8 @@ components:
|
||||
- { $ref: "#/components/schemas/DataTableBlock" }
|
||||
- { $ref: "#/components/schemas/CalloutBlock" }
|
||||
- { $ref: "#/components/schemas/EvidenceFigureBlock" }
|
||||
- { $ref: "#/components/schemas/ThematicBreakBlock" }
|
||||
- { $ref: "#/components/schemas/ImageBlock" }
|
||||
discriminator:
|
||||
propertyName: type
|
||||
mapping:
|
||||
@@ -1475,6 +1503,8 @@ components:
|
||||
DATA_TABLE: "#/components/schemas/DataTableBlock"
|
||||
CALLOUT: "#/components/schemas/CalloutBlock"
|
||||
EVIDENCE_FIGURE: "#/components/schemas/EvidenceFigureBlock"
|
||||
THEMATIC_BREAK: "#/components/schemas/ThematicBreakBlock"
|
||||
IMAGE: "#/components/schemas/ImageBlock"
|
||||
CasePublicRenderModel:
|
||||
unevaluatedProperties: false
|
||||
allOf:
|
||||
|
||||
@@ -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(
|
||||
"publishRelease",
|
||||
"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_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_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_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: "NOT_FOUND", path: "*", layoutGroup: "PUBLIC", paramsSchema: "NotFoundSplat", searchSchema: null, title: "페이지를 찾을 수 없습니다.", navigationLabel: null, navigationOrder: null }),
|
||||
] as const;
|
||||
|
||||
@@ -180,14 +180,27 @@ function normalizeDirectives(source: string): string {
|
||||
return line;
|
||||
}
|
||||
|
||||
/*
|
||||
`:::name key="value"` 를 remark-directive 가 읽는 `:::name{key="value"}` 로 바꾼다.
|
||||
|
||||
이름과 나머지 사이의 경계를 `\s` 로 못 박는 것이 중요하다. 예전에는 이름을
|
||||
`[a-z0-9-]*` 로 두고 나머지가 `{` 로 시작하지 않기만 요구했는데, 정규식이 되돌아가며
|
||||
이름의 마지막 글자를 나머지 쪽으로 넘겨 그 조건을 피해 갔다 — `:::note` 는 이름 `not`
|
||||
에 본문 `e` 가 되어 `:::not{e}` 로, 이미 중괄호를 쓴 `:::table{id="t"}` 는
|
||||
`:::tabl{e{id="t"}}` 로 망가졌다. 그래서 속성 없는 디렉티브는 이름이 통째로 바뀌고,
|
||||
중괄호 형태는 아예 해석되지 않았다.
|
||||
*/
|
||||
return line.replace(
|
||||
/^(:::[a-z][a-z0-9-]*)([^\n{][^\n]*)(\n?)$/i,
|
||||
/^(:::[a-z][a-z0-9-]*)(\s+[^\n]*?)(\n?)$/i,
|
||||
(
|
||||
_match,
|
||||
marker: string,
|
||||
attributes: string,
|
||||
newline: string,
|
||||
) => `${marker}{${attributes.trim()}}${newline}`,
|
||||
) => {
|
||||
const trimmed = attributes.trim();
|
||||
return trimmed ? `${marker}{${trimmed}}${newline}` : `${marker}${newline}`;
|
||||
},
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
@@ -199,6 +212,14 @@ function assertNever(value: never): never {
|
||||
|
||||
const trustedRelativeLinkOrigin = "https://techlog.invalid";
|
||||
|
||||
/** 서버가 아는 callout 이름과 화면에 붙일 말. 이름이 tone 을 겸하므로 속성을 받지 않는다. */
|
||||
const CALLOUT_LABELS: Readonly<Record<string, string>> = {
|
||||
note: "참고",
|
||||
tip: "도움말",
|
||||
warning: "주의",
|
||||
danger: "위험",
|
||||
};
|
||||
|
||||
function hasAsciiControlCharacter(value: string): boolean {
|
||||
return Array.from(value).some((character) => {
|
||||
const codePoint = character.codePointAt(0) ?? 0;
|
||||
@@ -326,8 +347,8 @@ function headingBlock(
|
||||
node: Heading,
|
||||
usedIds: Set<string>,
|
||||
): components["schemas"]["HeadingBlock"] {
|
||||
if (node.depth < 2 || node.depth > 4) {
|
||||
invalid(node, "only heading levels 2 through 4 are supported");
|
||||
if (node.depth < 1 || node.depth > 6) {
|
||||
invalid(node, "only heading levels 1 through 6 are supported");
|
||||
}
|
||||
|
||||
const children = [...node.children];
|
||||
@@ -417,15 +438,34 @@ function tableCellContent(cell: TableCell): Inline[] {
|
||||
return inlineFromNodes(cell.children);
|
||||
}
|
||||
|
||||
/**
|
||||
* `:::table` 없이 쓴 GFM 표에 붙일 값.
|
||||
*
|
||||
* <p>서버 렌더러는 파이프 표를 그대로 읽는다 — `:::table` 이라는 directive 자체를 모른다.
|
||||
* 여기서만 감싸기를 요구하면 같은 본문이 Studio 와 공개 화면에서 다르게 읽히므로, 감싸지 않은
|
||||
* 표도 받는다. `id` 는 자리 순서로 만들고 `caption` 은 비운다. 설명이 필요하면 `:::table` 로
|
||||
* 감싸 `caption` 을 주면 된다.
|
||||
*/
|
||||
function bareTableAttributes(index: number): Record<string, string> {
|
||||
return { id: `table-${index}`, caption: "", rowHeaderColumn: "none" };
|
||||
}
|
||||
|
||||
function tableBlock(
|
||||
node: ContainerDirective,
|
||||
node: ContainerDirective | Table,
|
||||
usedIds: Set<string>,
|
||||
bareIndex?: number,
|
||||
): components["schemas"]["DataTableBlock"] {
|
||||
const attributes = attributesOf(node, ["id", "caption", "rowHeaderColumn"]);
|
||||
if (node.children.length !== 1 || node.children[0].type !== "table") {
|
||||
invalid(node, "table directive must contain exactly one GFM table");
|
||||
const bare = node.type === "table";
|
||||
const attributes = bare
|
||||
? bareTableAttributes(bareIndex!)
|
||||
: attributesOf(node as ContainerDirective, ["id", "caption", "rowHeaderColumn"]);
|
||||
if (!bare) {
|
||||
const container = node as ContainerDirective;
|
||||
if (container.children.length !== 1 || container.children[0].type !== "table") {
|
||||
invalid(node, "table directive must contain exactly one GFM table");
|
||||
}
|
||||
}
|
||||
const table = node.children[0] as Table;
|
||||
const table = (bare ? node : (node as ContainerDirective).children[0]) as Table;
|
||||
if (table.children.length === 0) invalid(table, "table header is required");
|
||||
|
||||
const id = attributes.id;
|
||||
@@ -506,6 +546,25 @@ function directiveBlock(
|
||||
content: inlineFromNodes((node.children[0] as Paragraph).children),
|
||||
};
|
||||
}
|
||||
/*
|
||||
서버 렌더러가 아는 callout 이름이다(`note`/`tip` 은 정보, `warning`/`danger` 는 경고).
|
||||
`:::callout tone="..."` 만 받으면 서버가 정상으로 읽는 본문을 여기서 거절하게 되므로 둘 다
|
||||
받는다. 이름이 곧 tone 이라 속성이 없고, 라벨은 이름에서 만든다.
|
||||
*/
|
||||
case "note":
|
||||
case "tip":
|
||||
case "warning":
|
||||
case "danger": {
|
||||
if (node.children.length !== 1 || node.children[0].type !== "paragraph") {
|
||||
invalid(node, `${node.name} directive must contain exactly one paragraph`);
|
||||
}
|
||||
return {
|
||||
type: "CALLOUT",
|
||||
tone: node.name === "note" || node.name === "tip" ? "info" : "warning",
|
||||
label: CALLOUT_LABELS[node.name],
|
||||
content: inlineFromNodes((node.children[0] as Paragraph).children),
|
||||
};
|
||||
}
|
||||
case "evidence": {
|
||||
const attributes = attributesOf(node, ["key", "alt", "caption", "zoom"]);
|
||||
if (node.children.length !== 0) {
|
||||
@@ -533,10 +592,34 @@ function directiveBlock(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 문단 하나에 그림만 있으면 그림 블록으로 읽는다.
|
||||
*
|
||||
* <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(
|
||||
node: Paragraph,
|
||||
): components["schemas"]["ParagraphBlock"] {
|
||||
return { type: "PARAGRAPH", content: inlineFromNodes(node.children) };
|
||||
): components["schemas"]["ParagraphBlock"] | components["schemas"]["ImageBlock"] {
|
||||
return imageBlockOf(node) ?? { type: "PARAGRAPH", content: inlineFromNodes(node.children) };
|
||||
}
|
||||
|
||||
export function parseCaseContent(source: string): CaseAuthoringBlock[] {
|
||||
@@ -564,6 +647,7 @@ export function parseCaseContent(source: string): CaseAuthoringBlock[] {
|
||||
listItemCount += 1;
|
||||
return `list-item-${listItemCount}`;
|
||||
};
|
||||
let bareTableCount = 0;
|
||||
|
||||
return tree.children.map((node: Content): CaseAuthoringBlock => {
|
||||
switch (node.type) {
|
||||
@@ -585,11 +669,17 @@ export function parseCaseContent(source: string): CaseAuthoringBlock[] {
|
||||
return codeBlock(node);
|
||||
case "containerDirective":
|
||||
return directiveBlock(node, usedIds);
|
||||
case "html":
|
||||
case "table": {
|
||||
// 서버 렌더러는 파이프 표를 그대로 읽는다. 여기서 거절하면 같은 본문이 Studio 와
|
||||
// 공개 화면에서 다르게 읽힌다.
|
||||
bareTableCount += 1;
|
||||
return tableBlock(node, usedIds, bareTableCount);
|
||||
}
|
||||
case "thematicBreak":
|
||||
return { type: "THEMATIC_BREAK" };
|
||||
case "html":
|
||||
case "definition":
|
||||
case "yaml":
|
||||
case "table":
|
||||
case "footnoteDefinition":
|
||||
case "leafDirective":
|
||||
return invalid(node, `unsupported block syntax: ${node.type}`);
|
||||
|
||||
@@ -188,6 +188,14 @@ function serializeBlock(block: CaseRenderBlock): string {
|
||||
`:::evidence key=${quoteAttribute(block.key)} alt=${quoteAttribute(block.alt)} caption=${quoteAttribute(block.caption)} zoom=${quoteAttribute(String(block.zoom))}`,
|
||||
":::",
|
||||
].join("\n");
|
||||
case "THEMATIC_BREAK":
|
||||
return "---";
|
||||
case "IMAGE":
|
||||
// 제목은 Markdown 이 따옴표로 감싼다. 없으면 붙이지 않아야 다시 읽었을 때 빈 제목이 되지
|
||||
// 않는다.
|
||||
return block.title === null
|
||||
? ``
|
||||
: `}")`;
|
||||
default:
|
||||
return assertNever(block);
|
||||
}
|
||||
|
||||
@@ -11,3 +11,28 @@ export function createLocalId(
|
||||
const entropy = Math.floor(random() * 1_000_000_000).toString().padStart(9, "0");
|
||||
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)}`;
|
||||
}
|
||||
|
||||
@@ -39,56 +39,37 @@ function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버가 고른 최근 기록에 릴리스를 얹는다.
|
||||
*
|
||||
* 예전에는 이 함수가 공개된 프로젝트를 하나씩 돌며 활동을 모아 타임라인을 만들었다. 그래서 게시된
|
||||
* 문서라도 그 문서가 매달린 프로젝트가 공개되어 있지 않으면 홈에서 통째로 사라졌다 — 실제로 Case 를
|
||||
* 게시했는데 홈에는 릴리스 한 줄만 남았다. 무엇이 최근인지는 공개 투영이 이미 알고 있으므로 그것을
|
||||
* 그대로 읽는다. 프로젝트마다 요청을 하나씩 보내던 N+1 도 같이 사라진다.
|
||||
*
|
||||
* 릴리스는 Publication 파이프라인을 거치지 않아 그 투영에 행이 없다. 그래서 릴리스만 따로 읽어
|
||||
* 시간순으로 합친다.
|
||||
*/
|
||||
async function getLatestEntries(
|
||||
publicContent: PublicContentQueries,
|
||||
): Promise<LatestEntry[]> {
|
||||
const publicRecords = await publicContent.listRecords();
|
||||
const publicRecordByPath = new Map(
|
||||
publicRecords.map((record) => [record.path, record]),
|
||||
);
|
||||
const searchableEntities = await publicContent.searchPublicContent("");
|
||||
const projectPrefix = "/projects/";
|
||||
const projectSlugs = searchableEntities
|
||||
.filter((entity) => entity.contentType === "PROJECT")
|
||||
.flatMap((entity) =>
|
||||
entity.path.startsWith(projectPrefix)
|
||||
? [decodeURIComponent(entity.path.slice(projectPrefix.length))]
|
||||
: [],
|
||||
);
|
||||
// One project at a time would serialise a request per project; issuing them
|
||||
// together keeps the timeline's cost at its slowest project rather than their
|
||||
// sum. The flatten below restores the original single-list shape.
|
||||
const projectTimeline = (
|
||||
await Promise.all(
|
||||
projectSlugs.map(async (projectSlug) => {
|
||||
const project = await publicContent.getProject(projectSlug);
|
||||
if (!project) return [];
|
||||
const activities = await publicContent.getProjectActivity(projectSlug);
|
||||
return activities.map((activity) => {
|
||||
const record = publicRecordByPath.get(
|
||||
activity.recordPath ?? activity.path,
|
||||
);
|
||||
return {
|
||||
id: activity.id,
|
||||
typeLabel:
|
||||
activity.type === "PUBLICATION" && record
|
||||
? record.kind
|
||||
: "PROJECT ACTIVITY",
|
||||
title:
|
||||
activity.type === "PUBLICATION" && record
|
||||
? record.title
|
||||
: activity.title,
|
||||
summary: activity.summary,
|
||||
date: activity.date,
|
||||
dateTime: activity.dateTime,
|
||||
topic: record?.topic ?? project.topics[0] ?? "",
|
||||
project: project.title,
|
||||
path: activity.path,
|
||||
};
|
||||
});
|
||||
}),
|
||||
)
|
||||
).flat();
|
||||
const [records, searchableEntities] = await Promise.all([
|
||||
publicContent.getLatestEntries(),
|
||||
publicContent.searchPublicContent(""),
|
||||
]);
|
||||
|
||||
const recordTimeline: LatestEntry[] = records.map((entry) => ({
|
||||
id: entry.id,
|
||||
typeLabel: entry.entryType === "PROJECT_ACTIVITY" ? "PROJECT ACTIVITY" : entry.entryType,
|
||||
title: entry.title,
|
||||
summary: entry.summary,
|
||||
date: dateLabel(entry.publishedAt),
|
||||
dateTime: entry.publishedAt,
|
||||
topic: entry.topic,
|
||||
project: entry.project,
|
||||
path: entry.path,
|
||||
}));
|
||||
|
||||
const releaseTimeline = (
|
||||
await Promise.all(
|
||||
searchableEntities
|
||||
@@ -102,14 +83,14 @@ async function getLatestEntries(
|
||||
if (!release) return [];
|
||||
return [
|
||||
{
|
||||
id: `release-${release.version}`,
|
||||
typeLabel: "RELEASE",
|
||||
title: release.title,
|
||||
summary: release.summary,
|
||||
date: release.publishedLabel,
|
||||
dateTime: release.publishedAt,
|
||||
topic: "TechLog",
|
||||
project: "TechLog",
|
||||
id: `release-${release.version}`,
|
||||
typeLabel: "RELEASE",
|
||||
title: release.title,
|
||||
summary: release.summary,
|
||||
date: release.publishedLabel,
|
||||
dateTime: release.publishedAt,
|
||||
topic: "TechLog",
|
||||
project: "TechLog",
|
||||
path: release.path,
|
||||
},
|
||||
];
|
||||
@@ -117,11 +98,20 @@ async function getLatestEntries(
|
||||
)
|
||||
).flat();
|
||||
|
||||
return [...projectTimeline, ...releaseTimeline].sort((left, right) =>
|
||||
return [...recordTimeline, ...releaseTimeline].sort((left, right) =>
|
||||
right.dateTime.localeCompare(left.dateTime),
|
||||
);
|
||||
}
|
||||
|
||||
/** 목록의 날짜 칸은 공개 화면 어디서나 같은 형식이다. */
|
||||
function dateLabel(isoTimestamp: string): string {
|
||||
const parsed = new Date(isoTimestamp);
|
||||
if (Number.isNaN(parsed.getTime())) return "";
|
||||
return `${parsed.getFullYear()}.${String(parsed.getMonth() + 1).padStart(2, "0")}.${String(
|
||||
parsed.getDate(),
|
||||
).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
const { search } = useRouteInput<"TECH_LOG_HOME">();
|
||||
const requestedKey = optionalString(search.focus);
|
||||
|
||||
@@ -99,6 +99,19 @@ function renderBlock(
|
||||
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:
|
||||
return assertNever(block);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ type DocumentTocProps = {
|
||||
|
||||
export function DocumentToc({ headings, variant }: DocumentTocProps) {
|
||||
const [currentId, setCurrentId] = useState(headings[0]?.id ?? "");
|
||||
const empty = headings.length === 0;
|
||||
const detailsRef = useRef<HTMLDetailsElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -41,6 +42,12 @@ export function DocumentToc({ headings, variant }: DocumentTocProps) {
|
||||
if (detailsRef.current) detailsRef.current.open = false;
|
||||
}
|
||||
|
||||
/*
|
||||
제목이 없는 문서에는 목차를 그리지 않는다. 예전에는 이 컴포넌트가 픽스처 문서 하나에서만
|
||||
쓰여 빈 경우를 만날 일이 없었지만, 이제 모든 Case 가 지나므로 빈 레일이 남을 수 있다.
|
||||
*/
|
||||
if (empty) return null;
|
||||
|
||||
if (variant === "mobile") {
|
||||
const current =
|
||||
headings.find((heading) => heading.id === currentId) ?? headings[0];
|
||||
|
||||
+14
-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,
|
||||
embedded,
|
||||
resolveEvidenceAsset,
|
||||
@@ -233,7 +194,7 @@ function FetchJoinCase({
|
||||
<dd><PlainText text={model.environment} /></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>데이터셋</dt>
|
||||
<dt>검증 데이터</dt>
|
||||
<dd><PlainText text={model.reproduction.replace(/^Dataset:\s*/, "")} /></dd>
|
||||
</div>
|
||||
<div>
|
||||
@@ -519,16 +480,8 @@ export function PublicRecordRenderer({
|
||||
} & RenderDependencies) {
|
||||
switch (model.kind) {
|
||||
case "CASE":
|
||||
return model.publicPath ===
|
||||
"/cases/collection-fetch-join-pagination" ? (
|
||||
<FetchJoinCase
|
||||
model={model}
|
||||
embedded={embedded}
|
||||
resolveEvidenceAsset={resolveEvidenceAsset}
|
||||
resolvePublishedLabel={resolvePublishedLabel}
|
||||
/>
|
||||
) : (
|
||||
<GenericCase
|
||||
return (
|
||||
<CaseDocument
|
||||
model={model}
|
||||
embedded={embedded}
|
||||
resolveEvidenceAsset={resolveEvidenceAsset}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
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;
|
||||
void Promise.all([
|
||||
managementGateway.getHomeFocus(),
|
||||
managementGateway.listProjects(0, 50),
|
||||
gateway.getCatalog({ type: "RELATION", limit: 100 }),
|
||||
]).then(
|
||||
([current, projectPage, catalog]) => {
|
||||
if (cancelled) return;
|
||||
setFocus(current);
|
||||
setProjects(projectPage.items ?? []);
|
||||
setRelations(catalog.items ?? []);
|
||||
setProjectId(current.currentProjectId ?? "");
|
||||
setQuestionId(current.openQuestionId ?? "");
|
||||
setDecisionId(current.recentDecisionId ?? "");
|
||||
setError("");
|
||||
},
|
||||
() => {
|
||||
if (!cancelled) setError("홈 설정을 불러오지 못했습니다.");
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [gateway, managementGateway, generation]);
|
||||
|
||||
const questions = relations.filter((entry) => entry.kind === "QUESTION");
|
||||
const decisions = relations.filter((entry) => entry.kind === "PROJECT_DECISION");
|
||||
|
||||
const save = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (pending || !focus) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
const saved = await managementGateway.updateHomeFocus({
|
||||
expectedVersion: focus.version,
|
||||
// 빈 문자열은 "고르지 않음" 이다. 계약은 uuid 만 받으므로 보내지 않는다.
|
||||
currentProjectId: projectId || undefined,
|
||||
openQuestionId: questionId || undefined,
|
||||
recentDecisionId: decisionId || undefined,
|
||||
});
|
||||
setFocus(saved);
|
||||
setRequestAnnouncement("홈에 표시할 항목을 저장했습니다.");
|
||||
reload();
|
||||
} catch (failure) {
|
||||
setError(managementFailureMessage(failure, "홈 설정을 저장하지 못했습니다."));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const chosenProject = projects.find((project) => project.id === projectId);
|
||||
const projectHidden = chosenProject && chosenProject.targetVisibility === "PRIVATE";
|
||||
const nothingChosen = !projectId && !questionId && !decisionId;
|
||||
|
||||
return (
|
||||
<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 { createLocalId } from "../../../domain/studio/local-id.ts";
|
||||
import { createNewItemId } from "../../../domain/studio/local-id.ts";
|
||||
|
||||
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>
|
||||
))}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,676 @@
|
||||
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 { 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 [newActivity, setNewActivity] = useState({
|
||||
activityType: "MILESTONE_REACHED",
|
||||
title: "",
|
||||
summary: "",
|
||||
visibility: "PUBLIC",
|
||||
relatedId: "",
|
||||
occurredAt: toLocalInput(new Date().toISOString()),
|
||||
});
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [generation, setGeneration] = useState(0);
|
||||
|
||||
const reload = useCallback(() => setGeneration((value) => value + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void Promise.all([
|
||||
managementGateway.getProject(projectId),
|
||||
managementGateway.listProjectActivities(projectId),
|
||||
managementGateway.listTopics(),
|
||||
gateway.getCatalog({ type: "RELATION", limit: 100 }),
|
||||
]).then(
|
||||
([loaded, activityList, topicList, catalog]) => {
|
||||
if (cancelled) return;
|
||||
setProject(loaded);
|
||||
setDraft(toDraft(loaded));
|
||||
setActivities(activityList);
|
||||
setTopics(topicList.filter((topic) => topic.status !== "ARCHIVED"));
|
||||
setRecords(catalog.items ?? []);
|
||||
setError("");
|
||||
},
|
||||
() => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const addActivity = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (pending || !project) return;
|
||||
const title = newActivity.title.trim();
|
||||
if (!title) {
|
||||
setError("활동에는 제목이 필요합니다.");
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
const related = records.find((entry) => entry.id === newActivity.relatedId);
|
||||
await managementGateway.createProjectActivity(project.id, {
|
||||
expectedProjectVersion: project.version,
|
||||
activityType: newActivity.activityType,
|
||||
title,
|
||||
summary: newActivity.summary,
|
||||
visibility: newActivity.visibility === "PUBLIC" ? "PUBLIC" : "PRIVATE",
|
||||
/*
|
||||
공개 화면의 "이 활동 위치 열기" 는 이 자리를 따라간다. 비워 두면 그 링크는 갈 곳이 없다 —
|
||||
예전에는 고를 방법 자체가 없어 모든 활동의 그 버튼이 눌리지 않았다.
|
||||
*/
|
||||
relatedResourceType: related?.kind ?? undefined,
|
||||
relatedResourceId: related?.id ?? undefined,
|
||||
occurredAt: fromLocalInput(newActivity.occurredAt),
|
||||
});
|
||||
setNewActivity({
|
||||
activityType: "MILESTONE_REACHED",
|
||||
title: "",
|
||||
summary: "",
|
||||
visibility: "PUBLIC",
|
||||
relatedId: "",
|
||||
occurredAt: toLocalInput(new Date().toISOString()),
|
||||
});
|
||||
setRequestAnnouncement(`활동 ${title} 을(를) 추가했습니다.`);
|
||||
reload();
|
||||
} catch (failure) {
|
||||
setError(managementFailureMessage(failure, "활동을 추가하지 못했습니다."));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveActivity = async (
|
||||
activity: ProjectActivityResponse,
|
||||
changes: Partial<Pick<ProjectActivityResponse, "title" | "summary" | "visibility" | "occurredAt">>,
|
||||
) => {
|
||||
if (pending || !project) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
const saved = await managementGateway.updateProjectActivity(project.id, activity.id, {
|
||||
expectedVersion: activity.version,
|
||||
title: changes.title ?? activity.title,
|
||||
summary: changes.summary ?? activity.summary ?? "",
|
||||
visibility: (changes.visibility ?? activity.visibility) === "PUBLIC" ? "PUBLIC" : "PRIVATE",
|
||||
occurredAt: changes.occurredAt ?? activity.occurredAt,
|
||||
});
|
||||
setActivities((current) => current.map((item) => (item.id === saved.id ? saved : item)));
|
||||
setRequestAnnouncement("활동을 저장했습니다.");
|
||||
} catch (failure) {
|
||||
setError(managementFailureMessage(failure, "활동을 저장하지 못했습니다."));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeActivity = async (activity: ProjectActivityResponse) => {
|
||||
if (pending || !project) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
await managementGateway.deleteProjectActivity(project.id, activity.id, activity.version);
|
||||
setActivities((current) => current.filter((item) => item.id !== activity.id));
|
||||
setRequestAnnouncement(`활동 ${activity.title} 을(를) 삭제했습니다.`);
|
||||
} catch (failure) {
|
||||
setError(managementFailureMessage(failure, "활동을 삭제하지 못했습니다."));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 })}
|
||||
/>
|
||||
</label>
|
||||
<label className="studio-field studio-field--wide">
|
||||
<span>경계</span>
|
||||
<textarea
|
||||
className="studio-markdown"
|
||||
value={draft.boundaryMarkdown}
|
||||
onChange={(event) => update({ boundaryMarkdown: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="studio-field studio-field--wide">
|
||||
<span>시스템 개요</span>
|
||||
<textarea
|
||||
className="studio-markdown"
|
||||
value={draft.systemOverviewMarkdown}
|
||||
onChange={(event) =>
|
||||
update({ systemOverviewMarkdown: event.currentTarget.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="studio-editor-footer">
|
||||
<button className="studio-primary-button" type="submit" 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>
|
||||
|
||||
<form className="studio-field-grid" onSubmit={addActivity}>
|
||||
<label className="studio-field">
|
||||
<span>유형</span>
|
||||
<select
|
||||
value={newActivity.activityType}
|
||||
onChange={(event) => {
|
||||
// setState 업데이터는 다음 렌더에 실행된다. 그때 `event.currentTarget` 은 이미
|
||||
// null 이므로, 값은 핸들러가 도는 동안 꺼내 둬야 한다.
|
||||
const value = event.currentTarget.value;
|
||||
setNewActivity((current) => ({ ...current, activityType: value }));
|
||||
}}
|
||||
>
|
||||
{ACTIVITY_TYPES.map((type) => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="studio-field">
|
||||
<span>일시</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={newActivity.occurredAt}
|
||||
onChange={(event) => {
|
||||
// setState 업데이터는 다음 렌더에 실행된다. 그때 `event.currentTarget` 은 이미
|
||||
// null 이므로, 값은 핸들러가 도는 동안 꺼내 둬야 한다.
|
||||
const value = event.currentTarget.value;
|
||||
setNewActivity((current) => ({ ...current, occurredAt: value }));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="studio-field studio-field--wide">
|
||||
<span>제목</span>
|
||||
<input
|
||||
value={newActivity.title}
|
||||
maxLength={200}
|
||||
onChange={(event) => {
|
||||
// setState 업데이터는 다음 렌더에 실행된다. 그때 `event.currentTarget` 은 이미
|
||||
// null 이므로, 값은 핸들러가 도는 동안 꺼내 둬야 한다.
|
||||
const value = event.currentTarget.value;
|
||||
setNewActivity((current) => ({ ...current, title: value }));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="studio-field studio-field--wide">
|
||||
<span>요약</span>
|
||||
<input
|
||||
value={newActivity.summary}
|
||||
maxLength={300}
|
||||
onChange={(event) => {
|
||||
// setState 업데이터는 다음 렌더에 실행된다. 그때 `event.currentTarget` 은 이미
|
||||
// null 이므로, 값은 핸들러가 도는 동안 꺼내 둬야 한다.
|
||||
const value = event.currentTarget.value;
|
||||
setNewActivity((current) => ({ ...current, summary: value }));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="studio-field">
|
||||
<span>연결할 기록</span>
|
||||
<select
|
||||
value={newActivity.relatedId}
|
||||
onChange={(event) => {
|
||||
// setState 업데이터는 다음 렌더에 실행된다. 그때 `event.currentTarget` 은 이미
|
||||
// null 이므로, 값은 핸들러가 도는 동안 꺼내 둬야 한다.
|
||||
const value = event.currentTarget.value;
|
||||
setNewActivity((current) => ({ ...current, relatedId: value }));
|
||||
}}
|
||||
>
|
||||
<option value="">연결하지 않음</option>
|
||||
{records.map((entry) => (
|
||||
<option key={entry.id} value={entry.id}>
|
||||
{entry.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<small>
|
||||
공개 화면의 “이 활동 위치 열기”가 이 기록으로 갑니다.
|
||||
</small>
|
||||
</label>
|
||||
<label className="studio-field">
|
||||
<span>노출</span>
|
||||
<select
|
||||
value={newActivity.visibility}
|
||||
onChange={(event) => {
|
||||
// setState 업데이터는 다음 렌더에 실행된다. 그때 `event.currentTarget` 은 이미
|
||||
// null 이므로, 값은 핸들러가 도는 동안 꺼내 둬야 한다.
|
||||
const value = event.currentTarget.value;
|
||||
setNewActivity((current) => ({ ...current, visibility: value }));
|
||||
}}
|
||||
>
|
||||
<option value="PUBLIC">공개</option>
|
||||
<option value="PRIVATE">비공개</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="studio-field studio-field--wide">
|
||||
<button className="studio-secondary-button" type="submit" disabled={pending}>
|
||||
활동 추가
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{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.summary || "요약 없음"}
|
||||
{activity.relatedResourceId ? " · 기록 연결됨" : " · 연결된 기록 없음"}
|
||||
</p>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>일시</dt>
|
||||
<dd>
|
||||
<input
|
||||
type="datetime-local"
|
||||
aria-label={`${activity.title} 일시`}
|
||||
defaultValue={toLocalInput(activity.occurredAt)}
|
||||
onBlur={(event) =>
|
||||
void saveActivity(activity, {
|
||||
occurredAt: fromLocalInput(event.currentTarget.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>노출</dt>
|
||||
<dd>
|
||||
<select
|
||||
aria-label={`${activity.title} 노출`}
|
||||
value={activity.visibility}
|
||||
onChange={(event) =>
|
||||
void saveActivity(activity, {
|
||||
visibility: event.currentTarget.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="PUBLIC">공개</option>
|
||||
<option value="PRIVATE">비공개</option>
|
||||
</select>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>기록</dt>
|
||||
<dd>{activity.origin === "MANUAL" ? "손으로 적음" : "자동 기록"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<button
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
disabled={pending || activity.origin !== "MANUAL"}
|
||||
title={
|
||||
activity.origin === "MANUAL"
|
||||
? undefined
|
||||
: "게시 같은 실제 사건이 남긴 줄은 지울 수 없습니다."
|
||||
}
|
||||
onClick={() => void removeActivity(activity)}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { 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>
|
||||
<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>)}
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { 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>
|
||||
<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>)}
|
||||
<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>
|
||||
<OrderedTextList label="적용 조건" items={draft.applyWhen} onChange={(applyWhen) => update({ applyWhen })} />
|
||||
<FieldNotice issues={issues} path="/applyWhen" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 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>
|
||||
))}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
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 { 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);
|
||||
}
|
||||
};
|
||||
|
||||
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"
|
||||
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,
|
||||
} 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";
|
||||
|
||||
/**
|
||||
@@ -81,13 +82,13 @@ const STATUS_LABELS: Readonly<Record<string, string>> = {
|
||||
export function ReleaseManager() {
|
||||
const { managementGateway, setRequestAnnouncement } = useStudio();
|
||||
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 [error, setError] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [generation, setGeneration] = useState(0);
|
||||
const [newTitle, setNewTitle] = useState("");
|
||||
/* 방금 만든 릴리즈를 목록에서 짚어 준다 — 목록이 길면 어느 것이 새것인지 알기 어렵다. */
|
||||
const [createdId, setCreatedId] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(() => setGeneration((value) => value + 1), []);
|
||||
|
||||
@@ -112,29 +113,6 @@ export function ReleaseManager() {
|
||||
};
|
||||
}, [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) => {
|
||||
event.preventDefault();
|
||||
if (pending) return;
|
||||
@@ -148,8 +126,10 @@ export function ReleaseManager() {
|
||||
try {
|
||||
const created = await managementGateway.createRelease(title);
|
||||
setNewTitle("");
|
||||
setRequestAnnouncement(`릴리즈 ${title} 초안을 만들었습니다.`);
|
||||
setSelectedId(created.id);
|
||||
setRequestAnnouncement(
|
||||
`릴리즈 ${title} 초안을 만들었습니다. 목록에서 편집을 눌러 내용을 채웁니다.`,
|
||||
);
|
||||
setCreatedId(created.id);
|
||||
reload();
|
||||
} catch (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) => {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
await managementGateway.deleteRelease(release.id, release.version);
|
||||
if (selectedId === release.id) setSelectedId(null);
|
||||
setRequestAnnouncement(`릴리즈 ${release.versionLabel} 을(를) 삭제했습니다.`);
|
||||
reload();
|
||||
} 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 (
|
||||
<div className="studio-page studio-documents-page">
|
||||
<header className="studio-page-top">
|
||||
@@ -292,137 +199,53 @@ export function ReleaseManager() {
|
||||
<article
|
||||
key={release.id}
|
||||
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">
|
||||
<h2>{release.title}</h2>
|
||||
<p>
|
||||
{release.versionLabel}
|
||||
{release.releasedOn ? ` · ${release.releasedOn}` : ""}
|
||||
</p>
|
||||
<p>{release.publication.canonicalPath ?? "아직 공개하지 않음"}</p>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>버전</dt>
|
||||
<dd>{release.versionLabel}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>상태</dt>
|
||||
<dd>{STATUS_LABELS[release.workflowStatus] ?? release.workflowStatus}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>공개일</dt>
|
||||
<dd>{release.releasedOn || "미정"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<button
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => setSelectedId(selectedId === release.id ? null : release.id)}
|
||||
>
|
||||
{selectedId === release.id ? "닫기" : "편집"}
|
||||
</button>
|
||||
<button
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => void remove(release)}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
<div className="studio-row-actions">
|
||||
<GuardedStudioLink
|
||||
className="studio-secondary-button"
|
||||
href={`/studio/releases/${release.id}`}
|
||||
>
|
||||
편집
|
||||
</GuardedStudioLink>
|
||||
<button
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => void remove(release)}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { StudioDashboard as StudioDashboardData } from "../../../contracts/
|
||||
import type { components } from "../../../contracts/studio/generated.ts";
|
||||
import { useStudio } from "../use-studio.ts";
|
||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||
import { HomeFocusEditor } from "./home-focus-editor.tsx";
|
||||
|
||||
type DocumentSummary = components["schemas"]["DocumentSummary"];
|
||||
|
||||
@@ -158,6 +159,7 @@ export function StudioDashboard() {
|
||||
href="/studio/documents"
|
||||
empty="게시 준비가 끝난 문서가 없습니다."
|
||||
/>
|
||||
<HomeFocusEditor />
|
||||
<section className="studio-work-section">
|
||||
<div className="studio-section-title">
|
||||
<h2>최근 게시</h2>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||
|
||||
import type { ProjectIndexItem, TopicEdit } from "../../../contracts/management/contract.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 { useStudio } from "../use-studio.ts";
|
||||
|
||||
@@ -15,6 +16,23 @@ import { useStudio } from "../use-studio.ts";
|
||||
* <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() {
|
||||
const { managementGateway, setRequestAnnouncement } = useStudio();
|
||||
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 (
|
||||
<div className="studio-page studio-documents-page">
|
||||
<header className="studio-page-top">
|
||||
@@ -269,27 +318,54 @@ export function TaxonomyManager() {
|
||||
<article className="studio-document-row" key={project.id}>
|
||||
<p className="studio-row-label">PROJECT</p>
|
||||
<div className="studio-document-title">
|
||||
<h2>{project.name}</h2>
|
||||
<h2>
|
||||
<GuardedStudioLink href={`/studio/projects/${project.id}`}>
|
||||
{project.name}
|
||||
</GuardedStudioLink>
|
||||
</h2>
|
||||
<p>{project.currentObjective ?? "목표 미지정"}</p>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>단계</dt>
|
||||
<dd>{project.phase}</dd>
|
||||
<dd>{phaseLabel[project.phase] ?? project.phase}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
</dl>
|
||||
<button
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => void removeProject(project)}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
<div className="studio-row-actions">
|
||||
<button
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => void togglePublish(project)}
|
||||
>
|
||||
{project.targetVisibility === "PRIVATE" ? "게시" : "게시 취소"}
|
||||
</button>
|
||||
<button
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => void removeProject(project)}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
--code-canvas: #15181d;
|
||||
--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
|
||||
viewport bottom during loading and then dropped out of view when the content
|
||||
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 > .ui-page {
|
||||
/* Takes the slack so the footer stays put whether the route rendered a long
|
||||
@@ -1329,11 +1430,18 @@ dialog::backdrop {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 모달 dialog 를 화면 가운데에 둔다. UA 기본값(margin:auto)에 맡기면 이 빌드에서는 좌상단에
|
||||
붙는다 — .search-dialog 와 Studio 의 .studio-unsaved-dialog 가 같은 이유로 position/inset/
|
||||
margin 을 명시한다. 여기만 빠져 있어 "크게 보기" 가 왼쪽 위에 열렸다. */
|
||||
.figure-dialog {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
width: min(1180px, calc(100% - 48px));
|
||||
max-width: none;
|
||||
max-height: calc(100dvh - 48px);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 9px;
|
||||
background: var(--paper);
|
||||
@@ -2174,6 +2282,20 @@ dialog::backdrop {
|
||||
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-document-header h1 {
|
||||
margin: 15px 0 18px;
|
||||
@@ -2297,7 +2419,7 @@ dialog::backdrop {
|
||||
.reference-purpose,
|
||||
.document-relations {
|
||||
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 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 + 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;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,31 @@
|
||||
.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 > 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-resolution-fields { min-width: 0; margin: 34px 0 0; padding: 0; border: 0; }
|
||||
.studio-app .studio-ordered-list legend,
|
||||
|
||||
@@ -107,6 +107,15 @@
|
||||
.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-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 h2 { margin: 0; font-size: 25px; }
|
||||
.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 span,
|
||||
.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 .studio-primary-button { margin: 0; }
|
||||
.studio-app .studio-create-footer { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 20px; margin-top: 28px; }
|
||||
/*
|
||||
`.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-primary-button:disabled { opacity: 0.55; cursor: wait; }
|
||||
|
||||
|
||||
@@ -216,6 +216,13 @@ export const TECH_LOG_ROUTE_RUNTIME = Object.freeze({
|
||||
"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",
|
||||
routeModule(
|
||||
@@ -223,6 +230,13 @@ export const TECH_LOG_ROUTE_RUNTIME = Object.freeze({
|
||||
"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",
|
||||
routeModule(
|
||||
|
||||
@@ -87,11 +87,16 @@ const PLATFORM_KO_MESSAGES = {
|
||||
"route.auth.integration.description":
|
||||
"외부 인증 소유자가 연결되면 이 보호 라우트를 사용할 수 있습니다.",
|
||||
"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.required.description":
|
||||
"이 화면은 인증 연동 지점을 확인하기 위한 보호 라우트입니다.",
|
||||
"기록을 쓰고 게시하려면 로그인이 필요합니다. 로그인하면 방금 열려던 화면으로 돌아옵니다.",
|
||||
"route.documentTitle": "{title} · {appName}",
|
||||
"chunk.checking": "새 릴리스 정보를 확인하고 있습니다.",
|
||||
"chunk.reloadOnce": "새 버전으로 한 번만 전환합니다.",
|
||||
@@ -271,11 +276,13 @@ const PLATFORM_EN_MESSAGES = {
|
||||
"route.auth.integration.description":
|
||||
"This protected route is available after an external authentication owner is connected.",
|
||||
"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":
|
||||
"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":
|
||||
"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}",
|
||||
"chunk.checking": "Checking the new release information.",
|
||||
"chunk.reloadOnce": "Switching to the new version once.",
|
||||
|
||||
@@ -19,6 +19,7 @@ export type MessageParameters = Readonly<{
|
||||
"action.alertCloseNamed": { title: string };
|
||||
"route.loadingNamed": { title: string };
|
||||
"route.documentTitle": { title: string; appName: string };
|
||||
"route.auth.returnTo": { path: string };
|
||||
"template.supportReference": { reference: string };
|
||||
"form.remaining": { count: number };
|
||||
"boot.supportReference": { reference: string };
|
||||
|
||||
@@ -241,35 +241,48 @@ function ProtectedRoute({
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<section className="ui-page" data-surface="authentication-required">
|
||||
<PageHeader
|
||||
title={
|
||||
recovering
|
||||
? message("route.auth.recovering.title")
|
||||
: message("route.auth.required.title")
|
||||
}
|
||||
description={
|
||||
recovering
|
||||
? message("route.auth.recovering.description")
|
||||
: message("route.auth.required.description")
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
disabled={pending}
|
||||
onClick={() => void continueSession()}
|
||||
>
|
||||
{pending
|
||||
? message("common.processing")
|
||||
: recovering
|
||||
? message("action.recoverSession")
|
||||
: message("action.signIn")}
|
||||
</Button>
|
||||
{failed ? (
|
||||
<p className="ui-terminal-error" role="alert">
|
||||
{message("shell.session.actionFailed")}
|
||||
</p>
|
||||
) : null}
|
||||
<section className="ui-page auth-gate" data-surface="authentication-required">
|
||||
<div className="auth-gate__card">
|
||||
<PageHeader
|
||||
eyebrow={message("route.auth.eyebrow")}
|
||||
title={
|
||||
recovering
|
||||
? message("route.auth.recovering.title")
|
||||
: message("route.auth.required.title")
|
||||
}
|
||||
description={
|
||||
recovering
|
||||
? message("route.auth.recovering.description")
|
||||
: message("route.auth.required.description")
|
||||
}
|
||||
/>
|
||||
<div className="auth-gate__actions">
|
||||
<Button disabled={pending} onClick={() => void continueSession()}>
|
||||
{pending
|
||||
? message("common.processing")
|
||||
: recovering
|
||||
? message("action.recoverSession")
|
||||
: message("action.signIn")}
|
||||
</Button>
|
||||
<p className="auth-gate__return">
|
||||
{message("route.auth.returnTo", {
|
||||
path: `${location.pathname}${location.search}`,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
{failed ? (
|
||||
<p className="ui-terminal-error" role="alert">
|
||||
{message("shell.session.actionFailed")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
const rejected = [
|
||||
"<script>alert(1)</script>",
|
||||
@@ -74,11 +104,12 @@ describe("Content Format v1", () => {
|
||||
"[x](</safe\u001fpath>)",
|
||||
"[x](</safe\u007fpath>)",
|
||||
"- outer\n - nested",
|
||||
"# level one",
|
||||
"- [ ] task",
|
||||
"> > nested",
|
||||
':::unknown key="value"\ntext\n:::',
|
||||
':::evidence key="https://example.com/x.png" alt="x" caption="x" zoom="true"\n:::',
|
||||
")",
|
||||
"",
|
||||
];
|
||||
|
||||
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", () => {
|
||||
assert.equal(canonicalSource.packageId, "@tech-log/studio-contract");
|
||||
assert.equal(canonicalSource.version, "3.0.0");
|
||||
assert.equal(canonicalSource.version, "3.1.0");
|
||||
// revision은 생성 시점에 기록된다. canonical 저장소는 활발히 편집 중이므로
|
||||
// 특정 값을 박아두면 계약이 그대로인데도 테스트가 깨진다. 형식만 고정한다.
|
||||
assert.match(canonicalSource.sourceRevision, /^[0-9a-f]{7,64}$/);
|
||||
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
derivePreviewState,
|
||||
deriveValidationState,
|
||||
} from "../../../src/features/tech-log/domain/studio/document-state.ts";
|
||||
import { createLocalId } from "../../../src/features/tech-log/domain/studio/local-id.ts";
|
||||
import {
|
||||
createLocalId,
|
||||
createNewItemId,
|
||||
} from "../../../src/features/tech-log/domain/studio/local-id.ts";
|
||||
|
||||
const now = "2026-08-14T12:00:00.000Z";
|
||||
const validUntil = "2026-08-14T12:30:00.000Z";
|
||||
@@ -317,3 +320,32 @@ test("local Studio IDs are deterministic without Web Crypto and prefer UUIDs whe
|
||||
"save-fixture-uuid",
|
||||
);
|
||||
});
|
||||
|
||||
/*
|
||||
이 두 함수가 나뉜 이유는 하나는 화면 안에만 머물고 다른 하나는 계약을 건너가기 때문이다.
|
||||
|
||||
한때 새 관계·규칙·선택지의 id 를 `createLocalId` 로 만들었고, 그래서 `relation-<uuid>` 같은
|
||||
값이 요청 본문에 실렸다. 계약은 그 자리에 uuid 를 요구하므로 서버는 파싱조차 못 하고 400 을
|
||||
돌려주었다 — 편집기에는 "saveStudioDocument broke its contract." 한 줄만 남고 저장은 통째로
|
||||
실패했다. 목록에 고를 대상이 하나도 없던 시절에는 아무도 이 경로를 지나지 않아 드러나지 않았다.
|
||||
*/
|
||||
const UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
test("new list item IDs are bare UUIDs, because they cross the contract", () => {
|
||||
assert.equal(
|
||||
createNewItemId({ randomUUID: () => "3f1a2b4c-5d6e-4f70-8a91-b2c3d4e5f607" }),
|
||||
"3f1a2b4c-5d6e-4f70-8a91-b2c3d4e5f607",
|
||||
);
|
||||
// Web Crypto 가 없는 환경에서도 형식은 uuid 여야 한다 — 값은 서버가 새로 부여하지만, 형식이
|
||||
// 어긋나면 요청 자체가 거절된다.
|
||||
assert.match(createNewItemId(null, () => 0.5), UUID_SHAPE);
|
||||
assert.match(createNewItemId(null, () => 0), UUID_SHAPE);
|
||||
assert.match(createNewItemId(null, () => 0.999), UUID_SHAPE);
|
||||
});
|
||||
|
||||
test("prefixed local IDs never satisfy the contract's UUID shape", () => {
|
||||
assert.equal(
|
||||
UUID_SHAPE.test(createLocalId("relation", { randomUUID: () => "3f1a2b4c-5d6e-4f70-8a91-b2c3d4e5f607" })),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -802,8 +802,10 @@ describe("candidate archive and provider upload boundaries", () => {
|
||||
// shorter than one `systemctl show`, so the tree has to be sampled, not
|
||||
// sampled once at whatever moment the assertions happen to arrive.
|
||||
const tree = recordProviderProcessTree(execution.child.pid!);
|
||||
const unit = await waitForProviderUnit("vulnerability", execution.child.pid);
|
||||
const unit = await waitForProviderUnit("vulnerability", execution.child.pid, tree.sample);
|
||||
tree.sample();
|
||||
const properties = showProviderUnit(unit);
|
||||
tree.sample();
|
||||
expect(properties).toMatchObject({
|
||||
ActiveState: "active",
|
||||
CPUQuotaPerSecUSec: "1s",
|
||||
@@ -827,7 +829,22 @@ describe("candidate archive and provider upload boundaries", () => {
|
||||
const supervisorArguments = showProcessArguments(execution.child.pid);
|
||||
expect(supervisorArguments).not.toMatch(new RegExp(`${command}|${credential}`, "u"));
|
||||
const reportIdentity = await lstat(fixture.reportPath);
|
||||
const result = await execution.completion;
|
||||
/*
|
||||
기다리는 동안 직접 표본을 남긴다. 5ms 타이머에만 맡기면 다른 테스트 파일이 함께 도는 동안
|
||||
이벤트 루프가 밀려 표본이 통째로 비는 일이 있었다 — 관측 대상이 1.5초를 사는데도
|
||||
`directChildArguments` 가 빈 배열로 나왔다. 기다림 자체를 표본 채취의 박자로 쓰면 부하와
|
||||
무관해진다.
|
||||
*/
|
||||
const result = await (async () => {
|
||||
let settled: Awaited<typeof execution.completion> | undefined;
|
||||
const completion = execution.completion.then((value) => (settled = value));
|
||||
while (settled === undefined) {
|
||||
tree.sample();
|
||||
await Promise.race([completion, delay(5)]);
|
||||
}
|
||||
tree.sample();
|
||||
return settled;
|
||||
})();
|
||||
tree.stop();
|
||||
expect(result.code, result.stderr).toBe(0);
|
||||
expect(result.stdout).not.toContain(credential);
|
||||
@@ -849,7 +866,16 @@ describe("candidate archive and provider upload boundaries", () => {
|
||||
expect(observedArguments.join("\n")).not.toContain(credential);
|
||||
expect(directChildArguments.filter((arguments_) => arguments_.includes("/usr/bin/systemd-run"))).toHaveLength(1);
|
||||
expect(directChildArguments.filter((arguments_) => arguments_.includes("provider-raw-guardian.ts"))).toHaveLength(1);
|
||||
expect(processArguments.filter((arguments_) => arguments_.includes("provider-scope-wrapper.ts"))).toHaveLength(1);
|
||||
/*
|
||||
scope 안의 프로세스는 "몇 개인가" 로 고정할 수 없다 — 같은 argv 를 가진 pid 가 fork 로 둘이
|
||||
될 수 있고, 바로 아래 bwrap/prlimit 단언이 같은 이유로 존재만 본다. 실행 구조가 하나임은
|
||||
직전 두 줄이 직속 자식으로 이미 고정한다(systemd-run 1개, raw-guardian 1개) — 그쪽이
|
||||
중복 실행을 잡는 자리다. 여기서는 wrapper 가 scope 안에 있었는지만 본다.
|
||||
*/
|
||||
expect(
|
||||
processArguments.filter((arguments_) => arguments_.includes("provider-scope-wrapper.ts")).length,
|
||||
`scope wrapper never observed in the scope; recorded: ${processArguments.join(" | ")}`,
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
processArguments.filter((arguments_) => /\/usr\/bin\/(?:bwrap|prlimit)/u.test(arguments_)).length,
|
||||
`sandbox never observed in the scope; recorded: ${processArguments.join(" | ")}`,
|
||||
@@ -1722,11 +1748,15 @@ function providerSupervisorEnvironment(
|
||||
async function waitForProviderUnit(
|
||||
kind: "vulnerability" | "provenance",
|
||||
supervisorPid: number | undefined,
|
||||
/** 블로킹 조회 앞뒤로 부른다 — 그 사이에만 살아 있는 프로세스를 놓치지 않기 위한 것이다. */
|
||||
onPoll?: () => void,
|
||||
): Promise<string> {
|
||||
if (!supervisorPid) throw new Error("provider supervisor did not expose its PID");
|
||||
const pattern = `ca-provider-${kind}-${supervisorPid}-*.scope`;
|
||||
for (let attempt = 0; attempt < 120; attempt += 1) {
|
||||
onPoll?.();
|
||||
const units = listProviderUnits(pattern);
|
||||
onPoll?.();
|
||||
if (units.length === 1) return units[0]!;
|
||||
if (units.length > 1) throw new Error(`provider unit identity is ambiguous: ${units.join(", ")}`);
|
||||
await delay(25);
|
||||
@@ -2025,6 +2055,13 @@ function recordProviderProcessTree(supervisorPid: number) {
|
||||
timer.unref();
|
||||
sample();
|
||||
return Object.freeze({
|
||||
/*
|
||||
타이머만으로는 부족하다. 이 테스트는 `systemctl` 을 `spawnSync` 로 부르며 기다리는데, 그
|
||||
동안 이벤트 루프가 멈춰 5ms 간격이 굶는다 — bubblewrap 은 그 한 틈보다 짧게 살 수 있고,
|
||||
그러면 관측하려던 바로 그 프로세스가 기록에서 빠진다. 그래서 블로킹 호출을 하는 쪽이
|
||||
직접 이 함수를 불러 같은 박자로 표본을 남긴다.
|
||||
*/
|
||||
sample,
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearInterval(timer);
|
||||
|
||||
@@ -200,8 +200,8 @@ describe("CI gate contract", () => {
|
||||
// than taking either side's number.
|
||||
// Task 11 added TECH_LOG_STUDIO_ASSETS's manual accessibility evidence.
|
||||
// Alignment follow-up item 2 added the TechLog junit report.
|
||||
expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(111);
|
||||
expect(contract.artifacts).toHaveLength(132);
|
||||
expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(113);
|
||||
expect(contract.artifacts).toHaveLength(134);
|
||||
expect(contract.stages).toHaveLength(5);
|
||||
expect(contract.retention.classes).toHaveLength(5);
|
||||
expect(index.gates.get("FE-GATE-015")?.commandIds).toHaveLength(2);
|
||||
|
||||
@@ -192,7 +192,7 @@ describe("selective Task 3 contract closure", () => {
|
||||
expect(canonical.gates).toHaveLength(27);
|
||||
expect(canonical.commands).toHaveLength(85);
|
||||
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(97);
|
||||
expect(canonical.artifacts).toHaveLength(132);
|
||||
expect(canonical.artifacts).toHaveLength(134);
|
||||
expect(canonical.stages).toHaveLength(5);
|
||||
expect(canonical.retention.classes).toHaveLength(5);
|
||||
|
||||
|
||||
@@ -48,9 +48,11 @@ describe("TechLog production serving contract", () => {
|
||||
"^/studio/documents/[^/]+/publish$",
|
||||
"^/studio/documents/[^/]+/validation$",
|
||||
"^/studio/documents/new$",
|
||||
"^/studio/projects/[^/]+$",
|
||||
"^/studio/publications$",
|
||||
"^/studio/publications/[^/]+/preview$",
|
||||
"^/studio/releases$",
|
||||
"^/studio/releases/[^/]+$",
|
||||
"^/studio/taxonomy$",
|
||||
]);
|
||||
expect(contract.notFound).toEqual({
|
||||
|
||||
@@ -32,7 +32,9 @@ const techLogRouteChunks = Object.freeze({
|
||||
"/studio/pages/publication-preview-page.tsx": "route-tech-log-studio-publication-preview",
|
||||
"/studio/pages/assets-page.tsx": "route-tech-log-studio-assets",
|
||||
"/studio/pages/taxonomy-page.tsx": "route-tech-log-studio-taxonomy",
|
||||
"/studio/pages/project-edit-page.tsx": "route-tech-log-studio-project-edit",
|
||||
"/studio/pages/releases-page.tsx": "route-tech-log-studio-releases",
|
||||
"/studio/pages/release-edit-page.tsx": "route-tech-log-studio-release-edit",
|
||||
"/studio/pages/studio-not-found-page.tsx": "route-tech-log-studio-not-found",
|
||||
"/public/pages/public-not-found-page.tsx": "route-not-found",
|
||||
} as const);
|
||||
|
||||
Reference in New Issue
Block a user