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",