fix: keep the public screens usable on an empty site, and centre the dialogs

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.
This commit is contained in:
DongHyeonka
2026-08-21 00:25:51 +09:00
parent 11c2713139
commit 31dca00857
4 changed files with 131 additions and 6 deletions
@@ -333,10 +333,76 @@ export function createHttpPublicContentGateway(
return items;
}
/**
* 빈 검색어는 검색이 아니라 "카탈로그 전부"라는 뜻이다.
*
* 픽스처가 그렇게 동작했고 화면들이 그 의미에 기대어 쓰고 있다 — 홈 타임라인,
* 프로젝트 목록, 릴리즈 목록, 탐색 필터가 전부 `searchPublicContent("")` 로 카탈로그를
* 받아 간다. 계약에는 그런 의미가 없고 `q` 는 필수라, 그대로 보내면 400
* (`PUBLIC_REQUEST_INVALID`) 이 오고 홈을 포함한 네 화면이 통째로 오류 화면이 된다.
*
* 그래서 빈 검색어는 검색 엔드포인트로 보내지 않고, 계약이 이미 가진 목록
* 엔드포인트에서 조립한다. 검색어가 있으면 그때는 서버 검색을 쓴다 — 클라이언트에서
* 거르면 페이지 밖의 결과를 영영 못 찾는다.
*/
async function searchPublicContent(query: string): Promise<SearchablePublicEntity[]> {
const page = await read<Page>("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<Page>("searchPublicResources", { q: trimmed });
if (page === NOT_FOUND) return [];
return (page.items ?? []).map(searchItemToEntity);
}
const [knowledge, questions, projects, releases] = await Promise.all([
read<Page>("exploreKnowledge", {}),
read<Page>("exploreQuestions", {}),
read<Page>("listPublicProjects", {}),
read<Page>("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({
@@ -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);
@@ -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; }