feat: port TechLog discovery screens

This commit is contained in:
DongHyeonka
2026-08-15 22:29:57 +09:00
parent c9164c1a03
commit ef1d5cc548
12 changed files with 1130 additions and 15 deletions
@@ -0,0 +1,41 @@
export function normalizeFocus(
value: string | null | undefined,
availableKeys: readonly string[],
): string | null {
if (availableKeys.length === 0) return null;
return value && availableKeys.includes(value) ? value : availableKeys[0];
}
/**
* A missing query is the default home URL, while an explicitly empty or
* invalid query must be replaced with the selected fallback.
*/
export function shouldNormalizeFocusUrl(
requestedKey: string | undefined,
normalizedKey: string,
): boolean {
return requestedKey !== undefined && requestedKey !== normalizedKey;
}
export function moveFocus(
current: string,
key: string,
availableKeys: readonly string[],
): string | null {
if (availableKeys.length === 0) return null;
const currentIndex = Math.max(0, availableKeys.indexOf(current));
if (key === "Home") return availableKeys[0];
if (key === "End") return availableKeys[availableKeys.length - 1];
if (key === "ArrowLeft") {
return availableKeys[
(currentIndex - 1 + availableKeys.length) % availableKeys.length
];
}
if (key === "ArrowRight") {
return availableKeys[(currentIndex + 1) % availableKeys.length];
}
return availableKeys[currentIndex];
}
@@ -0,0 +1,75 @@
import { Link, useNavigate } from "react-router-dom";
import type { RecordKind } from "../../../application/ports/public-content-queries.ts";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
export function ExploreFilterForm({
action,
kind,
topic,
project,
showType = true,
}: {
action: string;
kind?: RecordKind;
topic?: string;
project?: string;
showType?: boolean;
}) {
const navigate = useNavigate();
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const publicRecords = publicContent.listRecords();
const topics = [...new Set(publicRecords.map((record) => record.topic))].sort();
const projectPrefix = "/projects/";
const projects = publicContent
.searchPublicContent("")
.filter((entity) => entity.contentType === "PROJECT")
.flatMap((entity) => {
if (!entity.path.startsWith(projectPrefix)) return [];
const item = publicContent.getProject(
decodeURIComponent(entity.path.slice(projectPrefix.length)),
);
return item ? [{ slug: item.slug, title: item.title }] : [];
});
const normalizedTopic = topic?.toLocaleLowerCase("ko-KR");
const selectedTopic = topics.find(
(item) => item.toLocaleLowerCase("ko-KR") === normalizedTopic,
);
const normalizedProject = project?.toLocaleLowerCase("ko-KR");
const selectedProject = projects.find(
(item) =>
item.slug.toLocaleLowerCase("ko-KR") === normalizedProject ||
item.title.toLocaleLowerCase("ko-KR") === normalizedProject,
)?.slug;
const hasActiveFilter = Boolean((showType && kind) || topic || project);
const formKey = [kind, topic, project, showType].join(":");
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
const search = new URLSearchParams();
for (const [key, value] of data) {
if (typeof value === "string") search.append(key, value);
}
void navigate(`${action}?${search.toString()}`);
}
return (
<form
key={formKey}
className="public-filter-form"
action={action}
method="get"
onSubmit={submit}
>
{showType ? (
<label><span></span><select name="type" defaultValue={kind ?? ""}><option value=""></option><option value="CASE">Case</option><option value="REFERENCE">Reference</option><option value="QUESTION">Open Question</option></select></label>
) : null}
<label><span></span><select name="topic" defaultValue={selectedTopic ?? ""}><option value=""></option>{topics.map((item) => <option key={item}>{item}</option>)}</select></label>
<label><span></span><select name="project" defaultValue={selectedProject ?? ""}><option value=""></option>{projects.map((item) => <option value={item.slug} key={item.slug}>{item.title}</option>)}</select></label>
<button type="submit"></button>
{hasActiveFilter ? <Link to={action}> </Link> : null}
</form>
);
}
@@ -0,0 +1,136 @@
import { useEffect, useRef, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import type {
FocusKey,
HomeFocusItem,
} from "../../../application/ports/public-content-queries.ts";
import {
moveFocus,
shouldNormalizeFocusUrl,
} from "../../../domain/public/focus-state.ts";
type HomeFocusProps = {
items: ReadonlyArray<HomeFocusItem>;
initialKey: FocusKey;
requestedKey?: string;
};
export function HomeFocus({
items,
initialKey,
requestedKey,
}: HomeFocusProps) {
const [activeKey, setActiveKey] = useState<FocusKey>(initialKey);
const buttons = useRef<Array<HTMLButtonElement | null>>([]);
const location = useLocation();
const navigate = useNavigate();
const keys = items.map((item) => item.key);
const activeItem = items.find((item) => item.key === activeKey) ?? items[0];
function replaceFocus(key: FocusKey) {
const search = new URLSearchParams(location.search);
search.set("focus", key);
void navigate(
{
pathname: location.pathname,
search: `?${search.toString()}`,
hash: location.hash,
},
{ replace: true },
);
}
useEffect(() => {
if (!shouldNormalizeFocusUrl(requestedKey, activeKey)) return;
replaceFocus(activeKey);
});
function select(key: FocusKey, moveKeyboardFocus = false) {
setActiveKey(key);
replaceFocus(key);
if (moveKeyboardFocus) {
requestAnimationFrame(() => {
buttons.current[keys.indexOf(key)]?.focus();
});
}
}
function onKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
return;
}
event.preventDefault();
const next = moveFocus(activeKey, event.key, keys);
if (next === "current" || next === "question" || next === "decision") {
select(next, true);
}
}
if (!activeItem) return null;
const usesTabs = items.length > 1;
const renderedItems = usesTabs ? items : [activeItem];
return (
<>
{usesTabs ? (
<div className="focus-tabs" role="tablist" aria-label="집중 항목 선택">
{items.map((item, index) => {
const selected = item.key === activeKey;
return (
<button
key={item.key}
ref={(button) => {
buttons.current[index] = button;
}}
id={"focus-tab-" + item.key}
type="button"
role="tab"
aria-selected={selected}
aria-controls={"focus-panel-" + item.key}
tabIndex={selected ? 0 : -1}
onClick={() => select(item.key)}
onKeyDown={onKeyDown}
>
{item.label}
</button>
);
})}
</div>
) : null}
{renderedItems.map((item) => {
const selected = item.key === activeKey;
return (
<article
className="focus-content"
id={"focus-panel-" + item.key}
key={item.key}
role={usesTabs ? "tabpanel" : undefined}
aria-labelledby={usesTabs ? "focus-tab-" + item.key : undefined}
hidden={!selected}
>
<div className="focus-summary">
<h2>{item.title}</h2>
<p>{item.summary}</p>
<Link className="text-link" to={item.targetPath}>
<span aria-hidden="true"></span>
</Link>
</div>
<dl className="focus-details">
{item.details.map((detail) => (
<div key={detail.label}>
<dt>{detail.label}</dt>
<dd>{detail.value}</dd>
</div>
))}
</dl>
</article>
);
})}
</>
);
}
@@ -0,0 +1,75 @@
import { Link } from "react-router-dom";
export type LatestEntry = {
id: string;
typeLabel: string;
title: string;
summary: string;
date: string;
dateTime: string;
topic: string;
project: string;
path: string;
};
type LatestIndexProps = {
entries: ReadonlyArray<LatestEntry>;
status?: "ready" | "error";
};
export function LatestIndex({
entries,
status = "ready",
}: LatestIndexProps) {
return (
<section className="shell latest-section" id="latest" aria-labelledby="latest-title">
<div className="section-heading-row">
<div>
<p className="section-kicker">Index</p>
<h2 id="latest-title"> </h2>
</div>
<Link className="text-link section-action" to="/explore">
</Link>
</div>
{status === "error" ? (
<div className="latest-state latest-state--error" role="alert">
<p> .</p>
<Link className="text-link" to="/#latest">
</Link>
</div>
) : entries.length === 0 ? (
<p className="latest-state latest-state--empty">
.
</p>
) : (
<ol className="latest-list">
{entries.map((entry) => (
<li key={entry.id}>
<Link className="latest-row" to={entry.path}>
<div className="latest-time">
<span>{entry.typeLabel}</span>
<time dateTime={entry.dateTime}>{entry.date}</time>
</div>
<div className="latest-copy">
<h3>{entry.title}</h3>
<p>{entry.summary}</p>
<p className="latest-mobile-meta">
{entry.topic} · {entry.project}
</p>
</div>
<div className="latest-context">
<span>{entry.topic}</span>
<span>{entry.project}</span>
</div>
<span className="latest-arrow" aria-hidden="true"></span>
</Link>
</li>
))}
</ol>
)}
</section>
);
}
@@ -0,0 +1,41 @@
import { Link } from "react-router-dom";
import type { PublicRecord } from "../../../application/ports/public-content-queries.ts";
const kindLabels = {
CASE: "Case",
REFERENCE: "Reference",
QUESTION: "Open Question",
} as const;
export function PublicRecordList({
records,
emptyMessage = "조건에 맞는 공개 기록이 없습니다.",
}: {
records: ReadonlyArray<PublicRecord>;
emptyMessage?: string;
}) {
if (records.length === 0) {
return <p className="public-empty-state">{emptyMessage}</p>;
}
return (
<ol className="public-record-list">
{records.map((record) => (
<li key={record.path}>
<Link to={record.path}>
<span className="record-kind">{kindLabels[record.kind]}</span>
<div>
<strong>{record.title}</strong>
<p>{record.summary}</p>
<span className="record-context">
{record.topic} · {record.projectTitle}
</span>
</div>
<time dateTime={record.publishedAt}>{record.publishedLabel}</time>
</Link>
</li>
))}
</ol>
);
}
@@ -0,0 +1,49 @@
import { Link } from "react-router-dom";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import {
RegisteredNotFoundRoute,
useRouteInput,
} from "../../../../../presentation/routes/route-input.tsx";
import { ExploreFilterForm } from "../components/explore-filter-form.tsx";
import { PublicRecordList } from "../components/public-record-list.tsx";
const kinds = {
cases: { kind: "CASE", title: "Case", description: "문제를 재현하고 관찰한 값에서 설계 결론까지 따라갑니다." },
references: { kind: "REFERENCE", title: "Reference", description: "다시 확인할 수 있는 기술 기준과 적용 범위를 정리합니다." },
questions: { kind: "QUESTION", title: "Open Question", description: "확인한 사실과 미지수, 다음 검증을 공개적으로 추적합니다." },
} as const;
function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function getKindConfig(value: string | undefined) {
if (value === "cases" || value === "references" || value === "questions") {
return kinds[value];
}
return undefined;
}
export function ExploreKindPage() {
const { params, search } = useRouteInput<"TECH_LOG_EXPLORE_KIND">();
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const kind = optionalString(params.kind);
const config = getKindConfig(kind);
if (!config) return <RegisteredNotFoundRoute />;
const topic = optionalString(search.topic);
const project = optionalString(search.project);
const records = publicContent.listRecords({
kind: config.kind,
...(topic ? { topic } : {}),
...(project ? { project } : {}),
});
return <main id="main-content" className="shell public-index-page">
<header className="public-page-header"><p className="section-kicker">Explore</p><h1>{config.title}</h1><p>{config.description}</p></header>
<ExploreFilterForm action={`/explore/${kind}`} topic={topic} project={project} showType={false} />
<div className="public-result-heading"><h2> </h2><p>{records.length} </p></div><PublicRecordList records={records} />
<Link className="text-link public-back-link" to="/explore"> </Link>
</main>;
}
@@ -0,0 +1,33 @@
import type { RecordKind } from "../../../application/ports/public-content-queries.ts";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
import { ExploreFilterForm } from "../components/explore-filter-form.tsx";
import { PublicRecordList } from "../components/public-record-list.tsx";
function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
export function ExplorePage() {
const { search } = useRouteInput<"TECH_LOG_EXPLORE">();
const requestedKind = optionalString(search.type);
const topic = optionalString(search.topic);
const project = optionalString(search.project);
const kind = (["CASE", "REFERENCE", "QUESTION"] as const).find(
(item) => item === requestedKind,
) satisfies RecordKind | undefined;
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const records = publicContent.listRecords({
...(kind ? { kind } : {}),
...(topic ? { topic } : {}),
...(project ? { project } : {}),
});
return <main id="main-content" className="shell public-index-page">
<header className="public-page-header"><p className="section-kicker">Explore</p><h1></h1><p> , .</p></header>
<ExploreFilterForm action="/explore" kind={kind} topic={topic} project={project} />
<div className="public-result-heading"><h2> </h2><p>{records.length} </p></div>
<PublicRecordList records={records} />
</main>;
}
@@ -0,0 +1,182 @@
import { Link } from "react-router-dom";
import type { PublicContentQueries } from "../../../application/ports/public-content-queries.ts";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { publicSiteConfig } from "../../../contracts/public-site-config.ts";
import { normalizeFocus } from "../../../domain/public/focus-state.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
import { FatalErrorState } from "../components/fatal-error-state.tsx";
import { HomeFocus } from "../components/home-focus.tsx";
import {
LatestIndex,
type LatestEntry,
} from "../components/latest-index.tsx";
const exploreEntries = [
{
label: "Case",
description: "문제를 따라가며 검증 과정을 읽습니다",
path: "/explore/cases",
},
{
label: "Reference",
description: "다시 찾을 수 있는 기술 기준을 확인합니다",
path: "/explore/references",
},
{
label: "OpenQuestion",
description: "아직 끝나지 않은 판단과 다음 검증을 봅니다",
path: "/explore/questions",
},
{
label: "Project",
description: "여러 기록을 하나의 시스템 맥락에서 연결합니다",
path: "/projects",
},
] as const;
function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function getLatestEntries(publicContent: PublicContentQueries): LatestEntry[] {
const publicRecords = publicContent.listRecords();
const publicRecordByPath = new Map(
publicRecords.map((record) => [record.path, record]),
);
const searchableEntities = 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))]
: [],
);
const projectTimeline = projectSlugs.flatMap((projectSlug) => {
const project = publicContent.getProject(projectSlug);
if (!project) return [];
return publicContent.getProjectActivity(projectSlug).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,
};
});
});
const releaseTimeline = searchableEntities
.filter((entity) => entity.contentType === "RELEASE")
.flatMap((entity) => {
const prefix = "/releases/";
if (!entity.path.startsWith(prefix)) return [];
const release = publicContent.getRelease(
decodeURIComponent(entity.path.slice(prefix.length)),
);
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",
path: release.path,
},
];
});
return [...projectTimeline, ...releaseTimeline].sort((left, right) =>
right.dateTime.localeCompare(left.dateTime),
);
}
export function HomePage() {
const { search } = useRouteInput<"TECH_LOG_HOME">();
const requestedKey = optionalString(search.focus);
const requestedState = optionalString(search.state);
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const focusItems = publicContent.getHomeFocusItems();
const availableFocusItems = requestedState === "focus-empty" ? [] : focusItems;
const normalizedKey = normalizeFocus(
requestedKey,
availableFocusItems.map((item) => item.key),
);
const initialKey =
normalizedKey === "current" ||
normalizedKey === "question" ||
normalizedKey === "decision"
? normalizedKey
: null;
if (requestedState === "site-error") {
return <FatalErrorState traceId="PREVIEW-HOME-500" />;
}
const latestEntries = getLatestEntries(publicContent);
return (
<main id="main-content">
<section className="shell home-identity" aria-labelledby="home-title">
<h1 id="home-title">{publicSiteConfig.brandTitle}</h1>
<p className="identity-statement">{publicSiteConfig.identityStatement}</p>
</section>
{initialKey ? (
<section className="shell focus-section" aria-labelledby="focus-label">
<p className="section-kicker" id="focus-label">
</p>
<HomeFocus
items={availableFocusItems}
initialKey={initialKey}
requestedKey={requestedKey}
/>
</section>
) : null}
<LatestIndex
entries={requestedState === "latest-empty" ? [] : latestEntries}
status={requestedState === "latest-error" ? "error" : "ready"}
/>
<section className="shell explore-section" aria-labelledby="explore-title">
<div className="section-heading-row explore-heading">
<div>
<p className="section-kicker">Explore</p>
<h2 id="explore-title"> ?</h2>
</div>
</div>
<ul className="explore-list">
{exploreEntries.map((entry) => (
<li key={entry.label}>
<Link to={entry.path}>
<span>{entry.description}</span>
<strong>{entry.label}</strong>
<span aria-hidden="true"></span>
</Link>
</li>
))}
</ul>
</section>
</main>
);
}
@@ -0,0 +1,34 @@
import { Link, useNavigate } from "react-router-dom";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
const labels = { CASE: "Case", REFERENCE: "Reference", QUESTION: "Open Question", PROJECT: "Project", RELEASE: "Release" } as const;
function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
export function SearchPage() {
const navigate = useNavigate();
const { search } = useRouteInput<"TECH_LOG_SEARCH">();
const query = optionalString(search.q)?.trim() ?? "";
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const results = publicContent.searchPublicContent(query);
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
const value = data.get("q");
const nextQuery = typeof value === "string" ? value : "";
void navigate(`/search?q=${encodeURIComponent(nextQuery)}`);
}
return <main id="main-content" className="shell search-page">
<header className="public-page-header"><p className="section-kicker">Search</p><h1></h1><p> , , .</p></header>
<form key={query} className="search-page-form" action="/search" method="get" onSubmit={submit}><label><span className="visually-hidden"></span><input type="search" name="q" defaultValue={query} placeholder="검색어를 입력하세요" /></label><button type="submit"></button></form>
<div className="public-result-heading"><h2>{query ? `${query}” 검색 결과` : "전체 검색 결과"}</h2><p>{results.length} </p></div>
{results.length ? <ol className="search-page-results">{results.map((result) => <li key={result.path}><Link to={result.path}><span>{labels[result.contentType]}</span><div><strong>{result.title}</strong><p>{result.summary}</p>{result.topic || result.project ? <small>{[result.topic, result.project].filter(Boolean).join(" · ")}</small> : null}</div><span aria-hidden="true"></span></Link></li>)}</ol> : <p className="public-empty-state"> .</p>}
</main>;
}