feat: port TechLog discovery screens
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user