feat: complete TechLog public screens

This commit is contained in:
DongHyeonka
2026-08-15 23:19:04 +09:00
parent 4283e40bb2
commit 2b6fa42620
12 changed files with 987 additions and 0 deletions
@@ -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>
);
}