feat: complete TechLog public screens
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
|
||||
import type { Project } from "../../../application/ports/public-content-queries.ts";
|
||||
|
||||
export type ProjectSection = "overview" | "records" | "decisions" | "activity";
|
||||
|
||||
const sections = [
|
||||
{ key: "overview", label: "개요", suffix: "" },
|
||||
{ key: "records", label: "기록", suffix: "/records" },
|
||||
{ key: "decisions", label: "결정", suffix: "/decisions" },
|
||||
{ key: "activity", label: "활동", suffix: "/activity" },
|
||||
] as const;
|
||||
|
||||
export function ProjectNavigation({ project }: { project: Project }) {
|
||||
const { pathname } = useLocation();
|
||||
const basePath = `/projects/${project.slug}`;
|
||||
const current =
|
||||
sections.find((section) => pathname === `${basePath}${section.suffix}`)?.key ??
|
||||
"overview";
|
||||
|
||||
return (
|
||||
<nav className="project-navigation" aria-label="프로젝트 탐색">
|
||||
{sections.map((section) => (
|
||||
<Link
|
||||
key={section.key}
|
||||
to={`${basePath}${section.suffix}`}
|
||||
aria-current={current === section.key ? "page" : undefined}
|
||||
>
|
||||
{section.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import type { Project } from "../../../application/ports/public-content-queries.ts";
|
||||
import { ProjectNavigation } from "./project-navigation.tsx";
|
||||
|
||||
export function ProjectPageHeader({
|
||||
project,
|
||||
title,
|
||||
}: {
|
||||
project: Project;
|
||||
title: string;
|
||||
}) {
|
||||
return (
|
||||
<header className="project-page-header">
|
||||
<p className="project-breadcrumb">
|
||||
<Link to="/projects">Projects</Link>
|
||||
<span aria-hidden="true">/</span>
|
||||
{project.title}
|
||||
</p>
|
||||
<h1>{title}</h1>
|
||||
<p>{project.summary}</p>
|
||||
<ProjectNavigation project={project} />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
||||
import { publicSiteConfig } from "../../../contracts/public-site-config.ts";
|
||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
||||
|
||||
const principles = [
|
||||
{
|
||||
title: "관찰한 사실과 판단을 나눕니다",
|
||||
description:
|
||||
"측정값, 문서 근거, 아직 확인하지 못한 가정을 같은 문장에 섞지 않습니다.",
|
||||
},
|
||||
{
|
||||
title: "결론보다 경계를 남깁니다",
|
||||
description:
|
||||
"어떤 조건에서 선택했고 어디까지 적용할 수 있는지 함께 기록합니다.",
|
||||
},
|
||||
{
|
||||
title: "프로젝트 맥락으로 다시 연결합니다",
|
||||
description:
|
||||
"Case와 Reference, Question, Decision이 따로 흩어지지 않게 실제 작업과 연결합니다.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const currentProjectSlugs = ["backend-skeleton", "auth-lab"] as const;
|
||||
const topics = ["Backend Architecture", "JPA", "Authentication", "Redis"] as const;
|
||||
|
||||
export function ProfilePage() {
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
const currentProjects = currentProjectSlugs.flatMap((slug) => {
|
||||
const project = publicContent.getProject(slug);
|
||||
return project ? [project] : [];
|
||||
});
|
||||
|
||||
return (
|
||||
<main id="main-content" className="shell profile-page">
|
||||
<header className="profile-header">
|
||||
<p className="section-kicker">Profile</p>
|
||||
<h1>{publicSiteConfig.operator}</h1>
|
||||
<p>{publicSiteConfig.identityStatement}</p>
|
||||
</header>
|
||||
<section className="profile-principles" aria-labelledby="principles-title">
|
||||
<div>
|
||||
<p className="section-kicker">Principles</p>
|
||||
<h2 id="principles-title">기록을 운영하는 원칙</h2>
|
||||
</div>
|
||||
<ol>
|
||||
{principles.map((principle, index) => (
|
||||
<li key={principle.title}>
|
||||
<span>{String(index + 1).padStart(2, "0")}</span>
|
||||
<div>
|
||||
<h3>{principle.title}</h3>
|
||||
<p>{principle.description}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
<section className="profile-projects" aria-labelledby="profile-projects-title">
|
||||
<div>
|
||||
<p className="section-kicker">Current</p>
|
||||
<h2 id="profile-projects-title">현재 프로젝트</h2>
|
||||
</div>
|
||||
<ul>
|
||||
{currentProjects.map((project) => (
|
||||
<li key={project.slug}>
|
||||
<Link to={`/projects/${project.slug}`}>
|
||||
<div>
|
||||
<strong>{project.title}</strong>
|
||||
<span>{project.stage}</span>
|
||||
</div>
|
||||
<p>{project.currentGoal}</p>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
<section className="profile-topics" aria-labelledby="profile-topics-title">
|
||||
<p className="section-kicker">Topics</p>
|
||||
<h2 id="profile-topics-title">주요 관심 주제</h2>
|
||||
<ul>
|
||||
{topics.map((topic) => (
|
||||
<li key={topic}>{topic}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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 { ProjectPageHeader } from "../components/project-page-header.tsx";
|
||||
|
||||
export function ProjectActivityPage() {
|
||||
const { params } = useRouteInput<"TECH_LOG_PROJECT_ACTIVITY">();
|
||||
const slug = typeof params.slug === "string" ? params.slug : "";
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
const project = publicContent.getProject(slug);
|
||||
|
||||
if (!project) return <RegisteredNotFoundRoute />;
|
||||
|
||||
const activity = publicContent.getProjectActivity(slug);
|
||||
return (
|
||||
<main id="main-content" className="shell project-page">
|
||||
<ProjectPageHeader project={project} title={`${project.title} 활동`} />
|
||||
<ol className="project-activity-list">
|
||||
{activity.map((item) => (
|
||||
<li key={item.id}>
|
||||
<article id={item.id}>
|
||||
<div>
|
||||
<span>{item.type}</span>
|
||||
<time dateTime={item.dateTime}>{item.date}</time>
|
||||
</div>
|
||||
<h2>{item.title}</h2>
|
||||
<p>{item.summary}</p>
|
||||
<Link to={item.recordPath ?? item.path}>
|
||||
{item.recordPath
|
||||
? "연결된 공개 기록 읽기"
|
||||
: "이 활동 위치 열기"}
|
||||
</Link>
|
||||
</article>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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 { ProjectPageHeader } from "../components/project-page-header.tsx";
|
||||
|
||||
export function ProjectDecisionsPage() {
|
||||
const { params } = useRouteInput<"TECH_LOG_PROJECT_DECISIONS">();
|
||||
const slug = typeof params.slug === "string" ? params.slug : "";
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
const project = publicContent.getProject(slug);
|
||||
|
||||
if (!project) return <RegisteredNotFoundRoute />;
|
||||
|
||||
const decisions = publicContent.getProjectDecisions(slug);
|
||||
return (
|
||||
<main id="main-content" className="shell project-page">
|
||||
<ProjectPageHeader project={project} title={`${project.title} 결정`} />
|
||||
<ol className="project-decision-list">
|
||||
{decisions.map((decision) => (
|
||||
<li key={decision.id}>
|
||||
<article id={decision.id}>
|
||||
<header>
|
||||
<div>
|
||||
<span>{decision.status}</span>
|
||||
<time dateTime={decision.date.replaceAll(".", "-")}>
|
||||
{decision.date}
|
||||
</time>
|
||||
</div>
|
||||
<h2>{decision.title}</h2>
|
||||
<p>{decision.statement}</p>
|
||||
</header>
|
||||
<section>
|
||||
<h3>판단 이유</h3>
|
||||
<p>{decision.rationale}</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>영향</h3>
|
||||
<ul>
|
||||
{decision.consequences.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h3>근거 기록</h3>
|
||||
<ul>
|
||||
{decision.evidence.map((item) => (
|
||||
<li key={item.path}>
|
||||
<Link to={item.path}>{item.title}</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</article>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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 { ProjectPageHeader } from "../components/project-page-header.tsx";
|
||||
|
||||
export function ProjectOverviewPage() {
|
||||
const { params } = useRouteInput<"TECH_LOG_PROJECT">();
|
||||
const slug = typeof params.slug === "string" ? params.slug : "";
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
const project = publicContent.getProject(slug);
|
||||
|
||||
if (!project) return <RegisteredNotFoundRoute />;
|
||||
|
||||
const records = publicContent.getProjectRecords(slug);
|
||||
const decisions = publicContent.getProjectDecisions(slug);
|
||||
const activity = publicContent.getProjectActivity(slug);
|
||||
return (
|
||||
<main id="main-content" className="shell project-page">
|
||||
<ProjectPageHeader project={project} title={project.title} />
|
||||
<section className="project-thesis" aria-labelledby="thesis-title">
|
||||
<p className="section-kicker">Thesis</p>
|
||||
<h2 id="thesis-title">프로젝트가 지키는 기준</h2>
|
||||
<p>{project.thesis}</p>
|
||||
</section>
|
||||
<dl className="project-status-grid">
|
||||
<div>
|
||||
<dt>단계</dt>
|
||||
<dd>{project.stage}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>현재 목표</dt>
|
||||
<dd>{project.currentGoal}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>다음 작업</dt>
|
||||
<dd>{project.nextStep}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<section
|
||||
className="project-overview-section"
|
||||
aria-labelledby="project-scope-title"
|
||||
>
|
||||
<div>
|
||||
<p className="section-kicker">Scope</p>
|
||||
<h2 id="project-scope-title">연결된 작업 맥락</h2>
|
||||
</div>
|
||||
<div className="project-stat-links">
|
||||
<Link to={`/projects/${slug}/records`}>
|
||||
<strong>{records.length}</strong>
|
||||
<span>공개 기록</span>
|
||||
</Link>
|
||||
<Link to={`/projects/${slug}/decisions`}>
|
||||
<strong>{decisions.length}</strong>
|
||||
<span>설계 결정</span>
|
||||
</Link>
|
||||
<Link to={`/projects/${slug}/activity`}>
|
||||
<strong>{activity.length}</strong>
|
||||
<span>활동 기록</span>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
<section className="project-topics" aria-labelledby="project-topics-title">
|
||||
<h2 id="project-topics-title">주요 주제</h2>
|
||||
<ul>
|
||||
{project.topics.map((topic) => (
|
||||
<li key={topic}>{topic}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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 { ProjectPageHeader } from "../components/project-page-header.tsx";
|
||||
import { PublicRecordList } from "../components/public-record-list.tsx";
|
||||
|
||||
export function ProjectRecordsPage() {
|
||||
const { params } = useRouteInput<"TECH_LOG_PROJECT_RECORDS">();
|
||||
const slug = typeof params.slug === "string" ? params.slug : "";
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
const project = publicContent.getProject(slug);
|
||||
|
||||
if (!project) return <RegisteredNotFoundRoute />;
|
||||
|
||||
const records = publicContent.getProjectRecords(slug);
|
||||
return (
|
||||
<main id="main-content" className="shell project-page">
|
||||
<ProjectPageHeader project={project} title={`${project.title} 기록`} />
|
||||
<div className="public-result-heading">
|
||||
<h2>공개 기록</h2>
|
||||
<p>{records.length}개의 공개 기록</p>
|
||||
</div>
|
||||
<PublicRecordList records={records} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
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";
|
||||
|
||||
export function ProjectsPage() {
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
const projects = publicContent
|
||||
.searchPublicContent("")
|
||||
.filter((item) => item.contentType === "PROJECT")
|
||||
.flatMap((item) => {
|
||||
const project = publicContent.getProject(item.path.replace("/projects/", ""));
|
||||
return project ? [project] : [];
|
||||
});
|
||||
|
||||
return (
|
||||
<main
|
||||
id="main-content"
|
||||
className="shell public-index-page project-index-page"
|
||||
>
|
||||
<header className="public-page-header">
|
||||
<p className="section-kicker">Projects</p>
|
||||
<h1>프로젝트</h1>
|
||||
<p>
|
||||
Case와 Reference, Question을 실제 설계 목표와 결정의 흐름으로 묶어
|
||||
봅니다.
|
||||
</p>
|
||||
</header>
|
||||
<ol className="project-index-list">
|
||||
{projects.map((project, index) => (
|
||||
<li key={project.slug}>
|
||||
<Link to={`/projects/${project.slug}`}>
|
||||
<span>{String(index + 1).padStart(2, "0")}</span>
|
||||
<div>
|
||||
<div className="project-index-title">
|
||||
<h2>{project.title}</h2>
|
||||
<span>{project.stage}</span>
|
||||
</div>
|
||||
<p>{project.summary}</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>현재 목표</dt>
|
||||
<dd>{project.currentGoal}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>다음 작업</dt>
|
||||
<dd>{project.nextStep}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
const styles = {
|
||||
error: {
|
||||
fontFamily:
|
||||
'system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"',
|
||||
height: "100vh",
|
||||
textAlign: "center",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
desc: {
|
||||
display: "inline-block",
|
||||
},
|
||||
h1: {
|
||||
display: "inline-block",
|
||||
margin: "0 20px 0 0",
|
||||
padding: "0 23px 0 0",
|
||||
fontSize: 24,
|
||||
fontWeight: 500,
|
||||
verticalAlign: "top",
|
||||
lineHeight: "49px",
|
||||
},
|
||||
h2: {
|
||||
fontSize: 14,
|
||||
fontWeight: 400,
|
||||
lineHeight: "49px",
|
||||
margin: 0,
|
||||
},
|
||||
} as const satisfies Record<string, CSSProperties>;
|
||||
|
||||
export function PublicNotFoundPage() {
|
||||
return (
|
||||
<>
|
||||
<title>404: This page could not be found.</title>
|
||||
<div style={styles.error}>
|
||||
<div>
|
||||
<style>{
|
||||
"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"
|
||||
}</style>
|
||||
<h1 className="next-error-h1" style={styles.h1}>
|
||||
404
|
||||
</h1>
|
||||
<div style={styles.desc}>
|
||||
<h2 style={styles.h2}>This page could not be found.</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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";
|
||||
|
||||
export function ReleasePage() {
|
||||
const { params } = useRouteInput<"TECH_LOG_RELEASE">();
|
||||
const version = typeof params.version === "string" ? params.version : "";
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
const release = publicContent.getRelease(version);
|
||||
|
||||
if (!release) return <RegisteredNotFoundRoute />;
|
||||
|
||||
return (
|
||||
<main id="main-content" className="shell release-page">
|
||||
<header className="release-page-header">
|
||||
<nav aria-label="변경 기록 경로">
|
||||
<Link to="/releases">변경 기록</Link>
|
||||
<span aria-hidden="true">/</span>v{release.version}
|
||||
</nav>
|
||||
<p className="section-kicker">Release v{release.version}</p>
|
||||
<h1>{release.title}</h1>
|
||||
<p>{release.summary}</p>
|
||||
<time dateTime={release.publishedAt}>{release.publishedLabel}</time>
|
||||
</header>
|
||||
<div className="release-document">
|
||||
<section aria-labelledby="release-changes">
|
||||
<p className="release-section-number">01</p>
|
||||
<div>
|
||||
<h2 id="release-changes">무엇이 바뀌었나</h2>
|
||||
<ul>
|
||||
{release.changes.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
<section aria-labelledby="release-reasons">
|
||||
<p className="release-section-number">02</p>
|
||||
<div>
|
||||
<h2 id="release-reasons">왜 바꿨나</h2>
|
||||
<ul>
|
||||
{release.reasons.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
<section aria-labelledby="release-impacts">
|
||||
<p className="release-section-number">03</p>
|
||||
<div>
|
||||
<h2 id="release-impacts">영향</h2>
|
||||
<ul>
|
||||
{release.impacts.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
<section aria-labelledby="release-related">
|
||||
<p className="release-section-number">04</p>
|
||||
<div>
|
||||
<h2 id="release-related">연결된 화면</h2>
|
||||
<ul className="release-related-links">
|
||||
{release.related.map((item) => (
|
||||
<li key={item.path}>
|
||||
<Link to={item.path}>
|
||||
{item.title}
|
||||
<span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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";
|
||||
|
||||
export function ReleasesPage() {
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
const releases = publicContent
|
||||
.searchPublicContent("")
|
||||
.filter((item) => item.contentType === "RELEASE")
|
||||
.flatMap((item) => {
|
||||
const release = publicContent.getRelease(item.path.replace("/releases/", ""));
|
||||
return release ? [release] : [];
|
||||
});
|
||||
|
||||
return (
|
||||
<main
|
||||
id="main-content"
|
||||
className="shell public-index-page release-index-page"
|
||||
>
|
||||
<header className="public-page-header">
|
||||
<p className="section-kicker">Releases</p>
|
||||
<h1>변경 기록</h1>
|
||||
<p>
|
||||
릴리즈 버전별로 무엇을 바꿨고 왜 바꿨는지, 공개 화면에 어떤 영향이
|
||||
생겼는지 남깁니다.
|
||||
</p>
|
||||
</header>
|
||||
<ol className="release-index-list">
|
||||
{releases.map((release) => (
|
||||
<li key={release.version}>
|
||||
<Link to={release.path}>
|
||||
<div>
|
||||
<span className="release-version">v{release.version}</span>
|
||||
<time dateTime={release.publishedAt}>
|
||||
{release.publishedLabel}
|
||||
</time>
|
||||
</div>
|
||||
<div>
|
||||
<h2>{release.title}</h2>
|
||||
<p>{release.summary}</p>
|
||||
</div>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { type ComponentType } from "react";
|
||||
import {
|
||||
createMemoryRouter,
|
||||
Outlet,
|
||||
RouterProvider,
|
||||
} from "react-router-dom";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createTechLogFeatureInstalledInput } from "../../../src/features/tech-log/adapters/create-tech-log-feature-input.ts";
|
||||
import {
|
||||
TECH_LOG_ROUTE_REGISTRY,
|
||||
TECH_LOG_ROUTE_RUNTIME_CONTRACT,
|
||||
} from "../../../src/features/tech-log/contracts/tech-log-route-contract.ts";
|
||||
import { CasePage } from "../../../src/features/tech-log/presentation/public/pages/case-page.tsx";
|
||||
import { ProfilePage } from "../../../src/features/tech-log/presentation/public/pages/profile-page.tsx";
|
||||
import { ProjectActivityPage } from "../../../src/features/tech-log/presentation/public/pages/project-activity-page.tsx";
|
||||
import { ProjectDecisionsPage } from "../../../src/features/tech-log/presentation/public/pages/project-decisions-page.tsx";
|
||||
import { ProjectOverviewPage } from "../../../src/features/tech-log/presentation/public/pages/project-overview-page.tsx";
|
||||
import { ProjectRecordsPage } from "../../../src/features/tech-log/presentation/public/pages/project-records-page.tsx";
|
||||
import { ProjectsPage } from "../../../src/features/tech-log/presentation/public/pages/projects-page.tsx";
|
||||
import { PublicNotFoundPage } from "../../../src/features/tech-log/presentation/public/pages/public-not-found-page.tsx";
|
||||
import { ReleasePage } from "../../../src/features/tech-log/presentation/public/pages/release-page.tsx";
|
||||
import { ReleasesPage } from "../../../src/features/tech-log/presentation/public/pages/releases-page.tsx";
|
||||
import { PublicShell } from "../../../src/features/tech-log/presentation/public/public-shell.tsx";
|
||||
import { TECH_LOG_ROUTE_CODECS } from "../../../src/features/tech-log/presentation/tech-log-route-codecs.ts";
|
||||
import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.tsx";
|
||||
import { createGroupedRouteObjects } from "../../../src/presentation/routes/app-router.tsx";
|
||||
import { PLATFORM_ROUTE_CODECS } from "../../../src/presentation/routes/platform-route-codecs.ts";
|
||||
import { createTestApplication } from "../../helpers/create-test-application.ts";
|
||||
|
||||
const routeCodecs = Object.freeze({
|
||||
...PLATFORM_ROUTE_CODECS,
|
||||
...TECH_LOG_ROUTE_CODECS,
|
||||
});
|
||||
|
||||
const routeComponents = {
|
||||
TECH_LOG_PROJECTS: ProjectsPage,
|
||||
TECH_LOG_PROJECT: ProjectOverviewPage,
|
||||
TECH_LOG_PROJECT_RECORDS: ProjectRecordsPage,
|
||||
TECH_LOG_PROJECT_DECISIONS: ProjectDecisionsPage,
|
||||
TECH_LOG_PROJECT_ACTIVITY: ProjectActivityPage,
|
||||
TECH_LOG_RELEASES: ReleasesPage,
|
||||
TECH_LOG_RELEASE: ReleasePage,
|
||||
TECH_LOG_PROFILE: ProfilePage,
|
||||
TECH_LOG_CASE: CasePage,
|
||||
NOT_FOUND: PublicNotFoundPage,
|
||||
} as const satisfies Record<string, ComponentType>;
|
||||
|
||||
type PublicIndexRouteId = keyof typeof routeComponents;
|
||||
|
||||
function renderPublicRoute(routeId: PublicIndexRouteId, initialEntry: string) {
|
||||
const routeIds = routeId === "NOT_FOUND" ? [routeId] : [routeId, "NOT_FOUND" as const];
|
||||
const registry = Object.fromEntries(
|
||||
routeIds.map((id) => [id, TECH_LOG_ROUTE_REGISTRY[id]]),
|
||||
);
|
||||
const runtime = Object.fromEntries(
|
||||
routeIds.map((id) => [
|
||||
id,
|
||||
{
|
||||
moduleId: TECH_LOG_ROUTE_RUNTIME_CONTRACT[id].moduleId,
|
||||
Component: routeComponents[id],
|
||||
},
|
||||
]),
|
||||
);
|
||||
const router = createMemoryRouter(
|
||||
createGroupedRouteObjects(
|
||||
registry,
|
||||
runtime,
|
||||
{
|
||||
PUBLIC: (
|
||||
<PublicShell>
|
||||
<Outlet />
|
||||
</PublicShell>
|
||||
),
|
||||
STUDIO: <Outlet />,
|
||||
},
|
||||
"task-9-test-build",
|
||||
routeCodecs,
|
||||
),
|
||||
{ initialEntries: [initialEntry] },
|
||||
);
|
||||
const techLog = createTechLogFeatureInstalledInput().input;
|
||||
const view = render(
|
||||
<ApplicationProvider
|
||||
application={createTestApplication({
|
||||
featureInputs: { "tech-log": techLog },
|
||||
})}
|
||||
>
|
||||
<RouterProvider router={router} />
|
||||
</ApplicationProvider>,
|
||||
);
|
||||
return { ...view, router };
|
||||
}
|
||||
|
||||
function projectNavigation() {
|
||||
return screen.getByRole("navigation", { name: "프로젝트 탐색" });
|
||||
}
|
||||
|
||||
describe("TechLog project screens", () => {
|
||||
it("renders the exact ordered project index and project links", () => {
|
||||
const { container } = renderPublicRoute("TECH_LOG_PROJECTS", "/projects");
|
||||
const main = screen.getByRole("main");
|
||||
|
||||
expect(within(main).getByRole("heading", { level: 1, name: "프로젝트" })).toBeVisible();
|
||||
expect(
|
||||
within(main).getByText(
|
||||
"Case와 Reference, Question을 실제 설계 목표와 결정의 흐름으로 묶어 봅니다.",
|
||||
),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
Array.from(container.querySelectorAll(".project-index-list > li > a"), (link) => ({
|
||||
href: link.getAttribute("href"),
|
||||
text: link.textContent,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
href: "/projects/backend-skeleton",
|
||||
text: "01Backend SkeletonDESIGN저장소·Redis·JPA·MongoDB 같은 기술을 붙일 때 애플리케이션 경계를 다시 만들지 않도록 공통 계약을 정리하는 프로젝트입니다.현재 목표Filesystem과 Object Storage를 하나의 StoragePort로 통합다음 작업MinIO Adapter와 공통 계약 테스트 연결↗",
|
||||
},
|
||||
{
|
||||
href: "/projects/auth-lab",
|
||||
text: "02Auth LabVALIDATION브라우저·Edge·Spring 사이에서 Token과 Session의 책임을 나누고 인증 경계를 검증하는 프로젝트입니다.현재 목표oauth2-proxy 뒤의 Spring 신뢰 경계 결정다음 작업JWT 재검증안과 신뢰 헤더안의 위협 모델 비교↗",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
path: "/projects/backend-skeleton",
|
||||
title: "Backend Skeleton",
|
||||
current: "개요",
|
||||
thesis: "기술별 기능을 많이 제공하는 것보다 교체 가능한 경계와 검증 가능한 계약을 먼저 고정합니다.",
|
||||
stats: ["4공개 기록", "2설계 결정", "3활동 기록"],
|
||||
topics: ["Backend Architecture", "JPA", "Redis", "Storage"],
|
||||
},
|
||||
{
|
||||
path: "/projects/auth-lab",
|
||||
title: "Auth Lab",
|
||||
current: "개요",
|
||||
thesis: "인증 방식을 이름으로 비교하지 않고 Code 교환, Token 보관, 요청 검증의 실제 주체를 기준으로 나눕니다.",
|
||||
stats: ["2공개 기록", "1설계 결정", "2활동 기록"],
|
||||
topics: ["Authentication", "OAuth 2.0", "OIDC", "Keycloak"],
|
||||
},
|
||||
])("renders $path overview with source counts and topics", ({ path, title, current, thesis, stats, topics }) => {
|
||||
const { container } = renderPublicRoute("TECH_LOG_PROJECT", path);
|
||||
const main = screen.getByRole("main");
|
||||
|
||||
expect(within(main).getByRole("heading", { level: 1, name: title })).toBeVisible();
|
||||
expect(within(main).getByText(thesis)).toBeVisible();
|
||||
expect(
|
||||
within(projectNavigation()).getByRole("link", { name: current }),
|
||||
).toHaveAttribute("aria-current", "page");
|
||||
expect(
|
||||
Array.from(container.querySelectorAll(".project-stat-links a"), (link) => link.textContent),
|
||||
).toEqual(stats);
|
||||
expect(
|
||||
Array.from(container.querySelectorAll(".project-topics li"), (item) => item.textContent),
|
||||
).toEqual(topics);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["TECH_LOG_PROJECT" as const, "/projects/backend-skeleton", "개요"],
|
||||
["TECH_LOG_PROJECT_RECORDS" as const, "/projects/backend-skeleton/records", "기록"],
|
||||
["TECH_LOG_PROJECT_DECISIONS" as const, "/projects/backend-skeleton/decisions", "결정"],
|
||||
["TECH_LOG_PROJECT_ACTIVITY" as const, "/projects/backend-skeleton/activity", "활동"],
|
||||
])("derives the active project tab from %s location", (routeId, path, activeLabel) => {
|
||||
renderPublicRoute(routeId, path);
|
||||
|
||||
expect(
|
||||
within(projectNavigation())
|
||||
.getAllByRole("link")
|
||||
.map((link) => [link.textContent, link.getAttribute("aria-current")]),
|
||||
).toEqual([
|
||||
["개요", activeLabel === "개요" ? "page" : null],
|
||||
["기록", activeLabel === "기록" ? "page" : null],
|
||||
["결정", activeLabel === "결정" ? "page" : null],
|
||||
["활동", activeLabel === "활동" ? "page" : null],
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
slug: "backend-skeleton",
|
||||
expected: [
|
||||
"/cases/collection-fetch-join-pagination",
|
||||
"/references/jpa-list-fetch-strategy",
|
||||
"/cases/redis-adapter-ttl-boundary",
|
||||
"/questions/collection-fetch-join-with-pagination",
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "auth-lab",
|
||||
expected: [
|
||||
"/references/state-and-nonce-boundary",
|
||||
"/questions/validate-edge-token-again",
|
||||
],
|
||||
},
|
||||
])("keeps $slug records filtered and published in source order", ({ slug, expected }) => {
|
||||
const { container } = renderPublicRoute(
|
||||
"TECH_LOG_PROJECT_RECORDS",
|
||||
`/projects/${slug}/records`,
|
||||
);
|
||||
|
||||
expect(
|
||||
Array.from(container.querySelectorAll(".public-record-list > li > a"), (link) =>
|
||||
link.getAttribute("href"),
|
||||
),
|
||||
).toEqual(expected);
|
||||
expect(screen.getByText(`${expected.length}개의 공개 기록`)).toBeVisible();
|
||||
});
|
||||
|
||||
it("keeps project decisions ordered with stable IDs and evidence links", () => {
|
||||
const { container } = renderPublicRoute(
|
||||
"TECH_LOG_PROJECT_DECISIONS",
|
||||
"/projects/backend-skeleton/decisions",
|
||||
);
|
||||
|
||||
expect(
|
||||
Array.from(container.querySelectorAll(".project-decision-list > li > article"), (item) => ({
|
||||
id: item.id,
|
||||
title: item.querySelector("h2")?.textContent,
|
||||
})),
|
||||
).toEqual([
|
||||
{ id: "storage-port-unification", title: "파일 저장 계약을 하나로 통합합니다" },
|
||||
{ id: "feed-pagination-boundary", title: "목록 페이징과 컬렉션 로딩을 분리합니다" },
|
||||
]);
|
||||
expect(
|
||||
Array.from(container.querySelectorAll(".project-decision-list a"), (link) =>
|
||||
link.getAttribute("href"),
|
||||
),
|
||||
).toEqual([
|
||||
"/projects/backend-skeleton/activity#storage-contract",
|
||||
"/cases/redis-adapter-ttl-boundary",
|
||||
"/cases/collection-fetch-join-pagination",
|
||||
"/references/jpa-list-fetch-strategy",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps project activity ordered and preserves self-fragment and record links", () => {
|
||||
const { container } = renderPublicRoute(
|
||||
"TECH_LOG_PROJECT_ACTIVITY",
|
||||
"/projects/backend-skeleton/activity",
|
||||
);
|
||||
|
||||
expect(
|
||||
Array.from(container.querySelectorAll(".project-activity-list > li > article"), (item) => ({
|
||||
id: item.id,
|
||||
href: item.querySelector("a")?.getAttribute("href"),
|
||||
label: item.querySelector("a")?.textContent,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
id: "fetch-join-case-published",
|
||||
href: "/cases/collection-fetch-join-pagination",
|
||||
label: "연결된 공개 기록 읽기",
|
||||
},
|
||||
{
|
||||
id: "storage-contract",
|
||||
href: "/projects/backend-skeleton/activity#storage-contract",
|
||||
label: "이 활동 위치 열기",
|
||||
},
|
||||
{
|
||||
id: "redis-case-published",
|
||||
href: "/cases/redis-adapter-ttl-boundary",
|
||||
label: "연결된 공개 기록 읽기",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TechLog release and profile screens", () => {
|
||||
it("renders the ordered release index and complete version link", () => {
|
||||
const { container } = renderPublicRoute("TECH_LOG_RELEASES", "/releases");
|
||||
|
||||
expect(screen.getByRole("heading", { level: 1, name: "변경 기록" })).toBeVisible();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"릴리즈 버전별로 무엇을 바꿨고 왜 바꿨는지, 공개 화면에 어떤 영향이 생겼는지 남깁니다.",
|
||||
),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
Array.from(container.querySelectorAll(".release-index-list > li > a"), (link) => ({
|
||||
href: link.getAttribute("href"),
|
||||
version: link.querySelector(".release-version")?.textContent,
|
||||
title: link.querySelector("h2")?.textContent,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
href: "/releases/0.1.0",
|
||||
version: "v0.1.0",
|
||||
title: "TechLog Public·Studio 경계를 확정했습니다",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders exact release sections and ordered cross-links", () => {
|
||||
const { container } = renderPublicRoute("TECH_LOG_RELEASE", "/releases/0.1.0");
|
||||
const main = screen.getByRole("main");
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
level: 1,
|
||||
name: "TechLog Public·Studio 경계를 확정했습니다",
|
||||
}),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
Array.from(container.querySelectorAll(".release-document > section h2"), (heading) =>
|
||||
heading.textContent,
|
||||
),
|
||||
).toEqual(["무엇이 바뀌었나", "왜 바꿨나", "영향", "연결된 화면"]);
|
||||
expect(within(main).getByText("불변 게시 Snapshot", { exact: false })).toBeVisible();
|
||||
expect(
|
||||
within(main).getByText(
|
||||
"Studio는 API 계약을 따르는 세션 전용 프론트엔드 시뮬레이션으로 구성했습니다.",
|
||||
),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
Array.from(container.querySelectorAll(".release-related-links a"), (link) =>
|
||||
link.getAttribute("href"),
|
||||
),
|
||||
).toEqual(["/", "/explore", "/cases/collection-fetch-join-pagination"]);
|
||||
});
|
||||
|
||||
it("renders the grounded profile, principles, project links, and topics", () => {
|
||||
const { container } = renderPublicRoute("TECH_LOG_PROFILE", "/profile");
|
||||
const main = screen.getByRole("main");
|
||||
|
||||
expect(within(main).getByRole("heading", { level: 1, name: "동현" })).toBeVisible();
|
||||
expect(
|
||||
within(main).getByText("문제를 재현하고 검증해 운영 가능한 설계로 연결합니다."),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
Array.from(container.querySelectorAll(".profile-principles h3"), (heading) =>
|
||||
heading.textContent,
|
||||
),
|
||||
).toEqual([
|
||||
"관찰한 사실과 판단을 나눕니다",
|
||||
"결론보다 경계를 남깁니다",
|
||||
"프로젝트 맥락으로 다시 연결합니다",
|
||||
]);
|
||||
expect(
|
||||
Array.from(container.querySelectorAll(".profile-projects a"), (link) =>
|
||||
link.getAttribute("href"),
|
||||
),
|
||||
).toEqual(["/projects/backend-skeleton", "/projects/auth-lab"]);
|
||||
expect(
|
||||
Array.from(container.querySelectorAll(".profile-topics li"), (item) => item.textContent),
|
||||
).toEqual(["Backend Architecture", "JPA", "Authentication", "Redis"]);
|
||||
expect(within(main).queryByText(/이메일|연락처|경력|소속|회사/)).not.toBeInTheDocument();
|
||||
expect(container.querySelector('a[href^="mailto:"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TechLog Public not-found runtime", () => {
|
||||
it.each([
|
||||
["TECH_LOG_PROJECT" as const, "/projects/missing-project"],
|
||||
["TECH_LOG_PROJECT_RECORDS" as const, "/projects/missing-project/records"],
|
||||
["TECH_LOG_RELEASE" as const, "/releases/9.9.9"],
|
||||
["TECH_LOG_CASE" as const, "/cases/not-registered"],
|
||||
["NOT_FOUND" as const, "/definitely-not-a-product-route"],
|
||||
])("renders exact in-shell Public fallback copy for %s", (routeId, path) => {
|
||||
const { container, router } = renderPublicRoute(routeId, path);
|
||||
|
||||
expect(router.state.location.pathname).toBe(path);
|
||||
expect(screen.getByRole("heading", { level: 1, name: "404" })).toHaveClass(
|
||||
"next-error-h1",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 2, name: "This page could not be found." }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "화면을 표시하지 못했습니다." }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(container.querySelector(".site-frame")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user