From 31dca0085736febcf7806b26b827ea940291a753 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Fri, 21 Aug 2026 00:25:51 +0900 Subject: [PATCH] fix: keep the public screens usable on an empty site, and centre the dialogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The published site showed one line of text — "요청한 문구를 표시할 수 없습니다." — instead of any UI. Two independent faults produced it. The screens ask for the whole catalogue by calling searchPublicContent(""). The fixture answered that with everything it had, and four screens lean on it: the home timeline, the project index, the release index, and the explore filter. The contract has no such meaning — `q` is required, and an empty one answers 400 — so all four turned into error surfaces the moment the source became HTTP. The adapter now assembles that catalogue from the list endpoints the contract does provide, and only sends a real query to the search endpoint. Filtering client-side instead would have been the other option, and it would silently lose every result past the first page. The message that surfaced was missing too. Twenty-two of the thirty-eight failure kinds had no copy, so `errorMessage` fell through to `common.unavailable` — which says nothing about what failed or what to do. That is a systemic gap, not one absent key, so all twenty-two are written, in both catalogues. They are phrased for the reader: what did not happen and what to try, not the internal classification. The Studio dialogs opened against the top-left corner. A modal dialog centres through the UA's `margin: auto`, which is not surviving in this build; the public `.search-dialog` already states `position/inset/margin` explicitly for the same reason. The three that did not — unsaved-changes, asset upload, and the publication flow — now follow it, with a max-height so a long dialog scrolls rather than running off the screen. --- .../http/http-public-content-gateway.ts | 72 ++++++++++++++++++- .../styles/publication-flow.module.css | 7 +- .../tech-log/presentation/styles/studio.css | 8 ++- src/presentation/i18n/catalog.ts | 50 +++++++++++++ 4 files changed, 131 insertions(+), 6 deletions(-) diff --git a/src/features/tech-log/adapters/http/http-public-content-gateway.ts b/src/features/tech-log/adapters/http/http-public-content-gateway.ts index cb8acf3..6298066 100644 --- a/src/features/tech-log/adapters/http/http-public-content-gateway.ts +++ b/src/features/tech-log/adapters/http/http-public-content-gateway.ts @@ -333,10 +333,76 @@ export function createHttpPublicContentGateway( return items; } + /** + * 빈 검색어는 검색이 아니라 "카탈로그 전부"라는 뜻이다. + * + * 픽스처가 그렇게 동작했고 화면들이 그 의미에 기대어 쓰고 있다 — 홈 타임라인, + * 프로젝트 목록, 릴리즈 목록, 탐색 필터가 전부 `searchPublicContent("")` 로 카탈로그를 + * 받아 간다. 계약에는 그런 의미가 없고 `q` 는 필수라, 그대로 보내면 400 + * (`PUBLIC_REQUEST_INVALID`) 이 오고 홈을 포함한 네 화면이 통째로 오류 화면이 된다. + * + * 그래서 빈 검색어는 검색 엔드포인트로 보내지 않고, 계약이 이미 가진 목록 + * 엔드포인트에서 조립한다. 검색어가 있으면 그때는 서버 검색을 쓴다 — 클라이언트에서 + * 거르면 페이지 밖의 결과를 영영 못 찾는다. + */ async function searchPublicContent(query: string): Promise { - const page = await read("searchPublicResources", query ? { q: query } : {}); - if (page === NOT_FOUND) return []; - return (page.items ?? []).map(searchItemToEntity); + const trimmed = query.trim(); + if (trimmed.length > 0) { + const page = await read("searchPublicResources", { q: trimmed }); + if (page === NOT_FOUND) return []; + return (page.items ?? []).map(searchItemToEntity); + } + + const [knowledge, questions, projects, releases] = await Promise.all([ + read("exploreKnowledge", {}), + read("exploreQuestions", {}), + read("listPublicProjects", {}), + read("listPublicReleases", {}), + ]); + const items = (page: Page | typeof NOT_FOUND) => + page === NOT_FOUND ? [] : (page.items ?? []); + + const entities: SearchablePublicEntity[] = []; + for (const item of items(knowledge)) { + const record = knowledgeListItemToRecord(item); + if (record) entities.push(recordToEntity(record)); + } + for (const item of items(questions)) { + entities.push(recordToEntity(questionListItemToRecord(item))); + } + for (const item of items(projects)) { + entities.push( + Object.freeze({ + contentType: "PROJECT", + title: String(item.name ?? ""), + summary: String(item.oneLinePurpose ?? ""), + path: String(item.path ?? `/projects/${String(item.slug ?? "")}`), + }), + ); + } + for (const item of items(releases)) { + entities.push( + Object.freeze({ + contentType: "RELEASE", + title: String(item.title ?? ""), + summary: String(item.summary ?? ""), + path: String(item.path ?? `/releases/${String(item.version ?? "")}`), + }), + ); + } + return entities; + } + + function recordToEntity(record: PublicRecord): SearchablePublicEntity { + return Object.freeze({ + contentType: record.kind, + title: record.title, + summary: record.summary, + path: record.path, + ...(record.topic ? { topic: record.topic } : {}), + ...(record.projectTitle ? { project: record.projectTitle } : {}), + ...(record.publishedAt ? { publishedAt: record.publishedAt } : {}), + }); } return Object.freeze({ diff --git a/src/features/tech-log/presentation/styles/publication-flow.module.css b/src/features/tech-log/presentation/styles/publication-flow.module.css index d077231..8d2420a 100644 --- a/src/features/tech-log/presentation/styles/publication-flow.module.css +++ b/src/features/tech-log/presentation/styles/publication-flow.module.css @@ -357,8 +357,13 @@ } .dialog { + /* studio.css 의 두 다이얼로그와 같은 이유로 명시한다 — UA 기본 margin:auto 에 + 맡기면 좌상단에 붙는다. */ + position: fixed; + inset: 0; + margin: auto; width: min(580px, calc(100% - 32px)); - max-height: calc(100vh - 32px); + max-height: calc(100dvh - 32px); padding: 0; overflow: auto; border: 1px solid var(--line-strong); diff --git a/src/features/tech-log/presentation/styles/studio.css b/src/features/tech-log/presentation/styles/studio.css index f73997e..07867af 100644 --- a/src/features/tech-log/presentation/styles/studio.css +++ b/src/features/tech-log/presentation/styles/studio.css @@ -30,7 +30,11 @@ .studio-app .studio-route-state button { min-height: 44px; margin-top: 18px; padding-inline: 16px; border: 1px solid var(--signal); border-radius: 5px; background: var(--signal); color: #fff; } .studio-app .studio-loading { min-height: 180px; } .studio-app .studio-visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } -.studio-app .studio-unsaved-dialog { width: min(560px, calc(100% - 32px)); padding: 0; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--paper); color: var(--ink); } +/* 모달 dialog 를 화면 가운데에 둔다. UA 기본값(margin:auto)에 맡기면 이 빌드에서는 + 좌상단에 붙는다 — 공개 화면의 .search-dialog 가 같은 이유로 position/inset/margin 을 + 이미 명시하고 있고, 여기도 같은 방식을 쓴다. max-height/overflow 는 내용이 길어졌을 때 + 화면 밖으로 나가지 않게 하는 짝이다. */ +.studio-app .studio-unsaved-dialog { position: fixed; inset: 0; margin: auto; max-height: calc(100dvh - 32px); overflow: auto; width: min(560px, calc(100% - 32px)); padding: 0; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--paper); color: var(--ink); } .studio-app .studio-unsaved-dialog::backdrop { background: rgba(23, 24, 27, 0.48); } .studio-app .studio-dialog-body { padding: 30px; } .studio-app .studio-dialog-body h2 { margin: 0 0 12px; font-size: 26px; } @@ -38,7 +42,7 @@ .studio-app .studio-dialog-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 28px; } .studio-app .studio-dialog-actions button { min-height: 44px; padding-inline: 14px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--paper); color: var(--ink); } .studio-app .studio-dialog-actions button:last-child { border-color: var(--signal); background: var(--signal); color: #fff; } -.studio-app .studio-asset-upload-dialog { width: min(560px, calc(100% - 32px)); padding: 0; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--paper); color: var(--ink); } +.studio-app .studio-asset-upload-dialog { position: fixed; inset: 0; margin: auto; max-height: calc(100dvh - 32px); overflow: auto; width: min(560px, calc(100% - 32px)); padding: 0; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--paper); color: var(--ink); } .studio-app .studio-asset-upload-dialog::backdrop { background: rgba(23, 24, 27, 0.48); } .studio-app .studio-asset-upload-dialog .studio-dialog-status { min-height: 20px; margin: 16px 0 0; color: var(--muted); font-size: 13px; } .studio-app .studio-asset-upload-dialog .studio-field + .studio-field { margin-top: 18px; } diff --git a/src/presentation/i18n/catalog.ts b/src/presentation/i18n/catalog.ts index 22197d8..f1c043f 100644 --- a/src/presentation/i18n/catalog.ts +++ b/src/presentation/i18n/catalog.ts @@ -143,6 +143,32 @@ const PLATFORM_KO_MESSAGES = { "error.asset_mismatch": "화면 자산 구성이 현재 릴리스와 일치하지 않습니다.", "error.render_failure": "화면을 표시하지 못했습니다.", "error.unknown_failure": "예상하지 못한 문제가 발생했습니다.", + // 아래 22개는 문구가 비어 있어 화면에 "요청한 문구를 표시할 수 없습니다."(common.unavailable) + // 가 대신 나가고 있었다 — 실패 종류 38개 중 절반 이상이 그 상태였다. 사용자에게는 + // 원인 분류가 아니라 무엇이 안 됐고 무엇을 하면 되는지가 필요하므로, 기술 용어를 + // 그대로 옮기지 않는다. 구분이 필요한 진단 정보는 로그와 error.code 가 들고 있다. + "error.request_aborted": "요청이 취소되었습니다.", + "error.content_type_mismatch": "서버 응답 형식이 예상과 달라 표시하지 못했습니다.", + "error.malformed_json": "서버 응답을 읽지 못했습니다.", + "error.response_body_limit": "응답이 너무 커서 표시하지 못했습니다.", + "error.envelope_mismatch": "서버 응답 형식이 예상과 달라 표시하지 못했습니다.", + "error.schema_mismatch": "서버 응답 형식이 예상과 달라 표시하지 못했습니다.", + "error.mapping_contract_violation": "서버 응답을 화면에 옮기지 못했습니다.", + "error.result_limit_exceeded": "결과가 너무 많습니다. 조건을 좁혀 주세요.", + "error.scope_generation_changed": "화면이 바뀌어 이전 요청을 버렸습니다. 다시 시도해 주세요.", + "error.identity_intern_limit_exceeded": "한 번에 처리할 수 있는 항목 수를 넘었습니다.", + "error.duplicate_in_flight": "같은 요청이 이미 처리 중입니다.", + "error.pagination_contract_violation": "다음 페이지를 불러오지 못했습니다.", + "error.conflict": "다른 곳에서 먼저 바뀌었습니다. 새로 불러온 뒤 다시 시도해 주세요.", + "error.validation_rejected": "입력 값이 올바르지 않습니다.", + "error.unknown_client_failure": "요청을 처리하지 못했습니다.", + "error.boot_config_failure": "설정을 불러오지 못했습니다.", + "error.release_manifest_failure": "릴리스 정보를 불러오지 못했습니다.", + "error.deploy_mismatch": "배포 버전이 현재 화면과 일치하지 않습니다.", + "error.storage_unavailable": "브라우저 저장소를 사용할 수 없습니다.", + "error.storage_quota_exceeded": "브라우저 저장 공간이 부족합니다.", + "error.telemetry_failure": "사용 기록을 전송하지 못했습니다.", + "error.query_cache_failure": "화면 데이터를 갱신하지 못했습니다.", "boot.failure.title": "애플리케이션을 시작할 수 없습니다.", "boot.field.error": "오류", "boot.field.code": "코드", @@ -301,6 +327,30 @@ const PLATFORM_EN_MESSAGES = { "error.asset_mismatch": "The page assets do not match this release.", "error.render_failure": "The page could not be displayed.", "error.unknown_failure": "An unexpected problem occurred.", + // Mirrors the ko-KR additions: every failure kind needs copy, or the surface + // falls back to `common.unavailable` and tells the reader nothing. + "error.request_aborted": "The request was cancelled.", + "error.content_type_mismatch": "The server replied in an unexpected format.", + "error.malformed_json": "The server reply could not be read.", + "error.response_body_limit": "The reply was too large to display.", + "error.envelope_mismatch": "The server replied in an unexpected format.", + "error.schema_mismatch": "The server replied in an unexpected format.", + "error.mapping_contract_violation": "The reply could not be shown on this screen.", + "error.result_limit_exceeded": "Too many results. Narrow the filters.", + "error.scope_generation_changed": "The screen changed, so the earlier request was dropped. Try again.", + "error.identity_intern_limit_exceeded": "More items than this screen can handle at once.", + "error.duplicate_in_flight": "The same request is already in progress.", + "error.pagination_contract_violation": "The next page could not be loaded.", + "error.conflict": "It changed elsewhere first. Reload and try again.", + "error.validation_rejected": "Some values are not valid.", + "error.unknown_client_failure": "The request could not be completed.", + "error.boot_config_failure": "Configuration could not be loaded.", + "error.release_manifest_failure": "Release information could not be loaded.", + "error.deploy_mismatch": "The deployed version does not match this screen.", + "error.storage_unavailable": "Browser storage is unavailable.", + "error.storage_quota_exceeded": "Browser storage is full.", + "error.telemetry_failure": "Usage data could not be sent.", + "error.query_cache_failure": "Screen data could not be refreshed.", "boot.failure.title": "The application could not start.", "boot.field.error": "Error", "boot.field.code": "Code",