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,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>
);
}