diff --git a/src/features/tech-log/adapters/static/public-query.ts b/src/features/tech-log/adapters/static/public-query.ts
index 7ed056a..188d9dd 100644
--- a/src/features/tech-log/adapters/static/public-query.ts
+++ b/src/features/tech-log/adapters/static/public-query.ts
@@ -54,6 +54,7 @@ export function listRecords(filters: RecordFilters = {}): PublicRecord[] {
.filter(
(record) =>
!hasTopicFilter ||
+ record.topicSlug.toLocaleLowerCase("ko-KR") === requestedTopic ||
record.topic.toLocaleLowerCase("ko-KR") === requestedTopic,
)
.filter(
diff --git a/src/features/tech-log/presentation/public/components/explore-filter-form.tsx b/src/features/tech-log/presentation/public/components/explore-filter-form.tsx
index 36931c4..611fc35 100644
--- a/src/features/tech-log/presentation/public/components/explore-filter-form.tsx
+++ b/src/features/tech-log/presentation/public/components/explore-filter-form.tsx
@@ -37,7 +37,18 @@ export function ExploreFilterForm({
);
const resolved = await Promise.all(projectSlugs.map((slug) => queries.getProject(slug)));
return {
- topics: [...new Set(records.map((record) => record.topic))].sort(),
+ // 주제는 이름이 아니라 slug 로 거른다 — 프로젝트와 같다. 이름을 실었을 때는
+ // `topic=OAuth/OIDC 인증 경계` 가 나갔고, slug 로 거르는 API 는 0건을 돌려줬다.
+ // 화면에 보일 이름과 보낼 slug 가 다르므로 짝으로 들고 있어야 한다.
+ topics: [
+ ...new Map(
+ records
+ .filter((record) => record.topicSlug)
+ .map((record) => [record.topicSlug, record.topic] as const),
+ ),
+ ]
+ .map(([slug, name]) => ({ slug, name }))
+ .sort((left, right) => left.name.localeCompare(right.name, "ko-KR")),
projects: resolved
.filter((item) => item !== undefined)
.map((item) => ({ slug: item.slug, title: item.title })),
@@ -47,8 +58,10 @@ export function ExploreFilterForm({
const projects = view.data?.projects ?? [];
const normalizedTopic = topic?.toLocaleLowerCase("ko-KR");
const selectedTopic = topics.find(
- (item) => item.toLocaleLowerCase("ko-KR") === normalizedTopic,
- );
+ (item) =>
+ item.slug.toLocaleLowerCase("ko-KR") === normalizedTopic ||
+ item.name.toLocaleLowerCase("ko-KR") === normalizedTopic,
+ )?.slug;
const normalizedProject = project?.toLocaleLowerCase("ko-KR");
const selectedProject = projects.find(
(item) =>
@@ -67,7 +80,7 @@ export function ExploreFilterForm({
const data = new FormData(event.currentTarget);
const search = new URLSearchParams();
for (const [key, value] of data) {
- if (typeof value === "string") search.append(key, value);
+ if (typeof value === "string" && value !== "") search.append(key, value);
}
void navigate(`${action}?${search.toString()}`);
}
@@ -83,7 +96,7 @@ export function ExploreFilterForm({
{showType ? (
) : null}
-
+
{hasActiveFilter ? 필터 초기화 : null}
diff --git a/src/features/tech-log/presentation/public/pages/topic-page.tsx b/src/features/tech-log/presentation/public/pages/topic-page.tsx
index aaa7434..d13fa09 100644
--- a/src/features/tech-log/presentation/public/pages/topic-page.tsx
+++ b/src/features/tech-log/presentation/public/pages/topic-page.tsx
@@ -38,10 +38,11 @@ export function TopicPage() {
const topic = topicConfig(params.slug);
// Hooks run unconditionally, so the unknown-topic case is handled by the
// loader and the not-found route is chosen after it.
+ const slug = typeof params.slug === "string" ? params.slug : "";
const view = usePublicContent(
- ["tech-log", "topic", topic?.title],
+ ["tech-log", "topic", slug],
async (queries) =>
- topic ? { records: await queries.listRecords({ topic: topic.title }) } : { records: [] },
+ topic ? { records: await queries.listRecords({ topic: slug }) } : { records: [] },
);
if (!topic) return ;
if (!view.ready) return view.fallback;
diff --git a/src/features/tech-log/presentation/styles/studio-editor.css b/src/features/tech-log/presentation/styles/studio-editor.css
index 445c4bb..1c00fb8 100644
--- a/src/features/tech-log/presentation/styles/studio-editor.css
+++ b/src/features/tech-log/presentation/styles/studio-editor.css
@@ -8,19 +8,30 @@
편집과 미리보기를 한 화면에 나란히 둔다. 탭이었을 때는 한 번에 하나만 보였고, 고친 결과를
보려면 편집하던 자리를 화면에서 치워야 했다.
*/
-.studio-app .studio-editor-split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 48px; align-items: start; padding-top: 40px; }
+/*
+ 이 화면만 공용 컨테이너보다 넓다. `.studio-main` 은 모든 Studio 화면이 1180px 를 함께
+ 쓰는데, 여기서는 그 폭에 본문 두 벌이 들어가야 해서 한 칸이 566px 로 좁아졌다. 헤더와
+ 다른 화면은 그대로 두고 편집기가 놓인 경우에만 넓힌다.
+*/
+.studio-app:has(.studio-editor-split) .studio-header-inner,
+.studio-app:has(.studio-editor-split) .studio-main { width: min(1600px, calc(100% - 80px)); }
+
+/*
+ 편집과 미리보기를 한 화면에 나란히 둔다. 탭이었을 때는 한 번에 하나만 보였고, 고친 결과를
+ 보려면 편집하던 자리를 화면에서 치워야 했다.
+
+ 두 칸 모두 페이지 스크롤을 그대로 탄다. 미리보기를 붙박이로 두고 자체 스크롤을 주었더니
+ 편집기를 내려도 미리보기는 제자리였고, 보려면 그 안을 따로 굴려야 했다 — 나란히 둔 이유가
+ 둘을 같이 보는 것인데 움직임이 갈라지면 그 이점이 없다.
+*/
+.studio-app .studio-editor-split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 48px; padding-top: 40px; }
/* 반쪽 폭에는 62px 짜리 제목이 앉을 자리가 없다. */
.studio-app .studio-editor-split .studio-editor-heading h1 { font-size: clamp(28px, 3vw, 40px); }
-/*
- 미리보기는 편집 칸과 길이가 다르다. 붙잡아 두지 않으면 본문을 스크롤하는 동안 화면 밖으로
- 나가 버리므로 자기 높이 안에서 따로 스크롤한다. 아래로 비워 두는 만큼이 고정된 상태 바가
- 덮는 자리다.
-*/
-.studio-app .studio-editor-preview { position: sticky; top: 28px; display: flex; flex-direction: column; max-height: calc(100vh - 190px); padding-left: 40px; border-left: 1px solid var(--line); }
+.studio-app .studio-editor-preview { display: flex; flex-direction: column; padding-left: 40px; border-left: 1px solid var(--line); }
.studio-app .studio-editor-preview__heading { padding-bottom: 20px; border-bottom: 1px solid var(--line); }
.studio-app .studio-editor-preview__heading .studio-eyebrow { margin-bottom: 8px; }
-.studio-app .studio-editor-preview__body { flex: 1; min-height: 0; padding-top: 20px; overflow-y: auto; }
+.studio-app .studio-editor-preview__body { padding-top: 20px; }
.studio-app .studio-editor-heading { padding-bottom: 36px; border-bottom: 1px solid var(--line-strong); }
.studio-app .studio-editor-heading h1 { margin: 0; font-size: clamp(38px, 5vw, 62px); line-height: 1.05; letter-spacing: -0.045em; }
.studio-app .studio-editor-heading > p:last-child { max-width: 780px; margin: 18px 0 0; color: var(--muted); font-size: 17px; line-height: 1.65; overflow-wrap: anywhere; }
@@ -152,8 +163,7 @@
@media (max-width: 1024px) {
.studio-app .studio-editor-split { grid-template-columns: minmax(0, 1fr); gap: 40px; }
.studio-app .studio-editor-split .studio-editor-heading h1 { font-size: clamp(38px, 5vw, 62px); }
- .studio-app .studio-editor-preview { position: static; max-height: none; padding-top: 8px; padding-left: 0; border-top: 1px solid var(--line-strong); border-left: 0; }
- .studio-app .studio-editor-preview__body { overflow-y: visible; }
+ .studio-app .studio-editor-preview { padding-top: 8px; padding-left: 0; border-top: 1px solid var(--line-strong); border-left: 0; }
}
@media (max-width: 767px) {
diff --git a/tests/features/tech-log/public-discovery-screens.test.tsx b/tests/features/tech-log/public-discovery-screens.test.tsx
index 49506c7..fef37d4 100644
--- a/tests/features/tech-log/public-discovery-screens.test.tsx
+++ b/tests/features/tech-log/public-discovery-screens.test.tsx
@@ -296,7 +296,7 @@ describe("TechLog explore discovery", () => {
const user = userEvent.setup();
const { router } = await renderDiscoveryRoute(
"TECH_LOG_EXPLORE",
- "/explore?type=CASE&topic=JPA&project=backend-skeleton",
+ "/explore?type=CASE&topic=jpa&project=backend-skeleton",
);
expect(screen.getByRole("heading", { level: 1, name: "탐색" })).toBeVisible();
@@ -306,22 +306,28 @@ describe("TechLog explore discovery", () => {
// 필터의 선택지는 카탈로그가 도착한 뒤 채워지고, select 의 값도 그때 설정된다.
await waitFor(() => {
expect(screen.getByLabelText("유형")).toHaveValue("CASE");
- expect(screen.getByLabelText("주제")).toHaveValue("JPA");
+ expect(screen.getByLabelText("주제")).toHaveValue("jpa");
expect(screen.getByLabelText("프로젝트")).toHaveValue("backend-skeleton");
});
+ // 주제 선택지는 보이는 이름과 보내는 값이 다르다. 값이 이름이면 slug 로 거르는 API 가
+ // 0건을 돌려주고, 화면은 「조건에 맞는 공개 기록이 없습니다」만 남는다 — 운영에서 실제로
+ // 그랬다. 목록에서 유도한 값이라 이 단언이 없으면 조용히 되돌아간다.
+ expect(
+ within(screen.getByLabelText("주제")).getByRole("option", { name: "Authentication" }),
+ ).toHaveValue("authentication");
expect(screen.getByText("1개의 공개 기록")).toBeVisible();
expect(
screen.getByRole("link", { name: /컬렉션 Fetch Join과 페이징은 왜 충돌하는가/ }),
).toHaveAttribute("href", "/cases/collection-fetch-join-pagination");
await user.selectOptions(screen.getByLabelText("유형"), "QUESTION");
- await user.selectOptions(screen.getByLabelText("주제"), "Authentication");
+ await user.selectOptions(screen.getByLabelText("주제"), "authentication");
await user.selectOptions(screen.getByLabelText("프로젝트"), "auth-lab");
await user.click(screen.getByRole("button", { name: "적용" }));
await waitFor(() => {
expect(router.state.location.search).toBe(
- "?project=auth-lab&topic=Authentication&type=QUESTION",
+ "?project=auth-lab&topic=authentication&type=QUESTION",
);
});
expect(screen.getByText("1개의 공개 기록")).toBeVisible();