feat: port TechLog shells and styles

This commit is contained in:
DongHyeonka
2026-08-15 21:20:47 +09:00
parent 01316763e3
commit 627df884dd
14 changed files with 4124 additions and 8 deletions
@@ -0,0 +1,37 @@
import { Link } from "react-router-dom";
type FatalErrorStateProps = {
traceId: string;
onRetry?: () => void;
retryHref?: string;
};
export function FatalErrorState({
traceId,
onRetry,
retryHref = "/",
}: FatalErrorStateProps) {
return (
<main id="main-content" className="shell fatal-state">
<p className="context-label">Service error</p>
<h1> .</h1>
<p>
. Trace ID를
.
</p>
<p className="trace-id">
<span>Trace ID</span>
<code>{traceId}</code>
</p>
{onRetry ? (
<button className="primary-link" type="button" onClick={onRetry}>
</button>
) : (
<Link className="primary-link" to={retryHref}>
</Link>
)}
</main>
);
}
@@ -0,0 +1,119 @@
import { useId, useRef, useState } from "react";
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";
type SearchDialogProps = {
className?: string;
onBeforeOpen?: () => void;
};
export function SearchDialog({
className = "",
onBeforeOpen,
}: SearchDialogProps) {
const dialogId = useId();
const dialogRef = useRef<HTMLDialogElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLocaleLowerCase("ko-KR");
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const results = publicContent.searchPublicContent(normalizedQuery);
function open() {
onBeforeOpen?.();
dialogRef.current?.showModal();
requestAnimationFrame(() => inputRef.current?.focus());
}
function close() {
dialogRef.current?.close();
}
return (
<>
<button
ref={triggerRef}
className={className}
type="button"
aria-haspopup="dialog"
aria-controls={dialogId}
aria-label="TechLog 검색 열기"
onClick={open}
>
</button>
<dialog
ref={dialogRef}
className="search-dialog"
id={dialogId}
aria-labelledby={dialogId + "-title"}
onClose={() => triggerRef.current?.focus()}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
close();
}
}}
onClick={(event) => {
if (event.target === event.currentTarget) close();
}}
>
<div className="search-dialog-inner">
<header>
<div>
<p className="context-label">Public records</p>
<h2 id={dialogId + "-title"}>TechLog </h2>
</div>
<button type="button" className="dialog-close" onClick={close}>
</button>
</header>
<label className="search-field">
<span className="visually-hidden"></span>
<span aria-hidden="true"></span>
<input
ref={inputRef}
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="제목, 요약, 주제, 프로젝트 검색"
autoComplete="off"
/>
</label>
<p className="search-count" aria-live="polite">
{results.length}
</p>
{results.length > 0 ? (
<>
<ul className="search-results">
{results.map((entry) => (
<li key={entry.path}>
<Link to={entry.path} onClick={close}>
<span>{entry.contentType}</span>
<strong>{entry.title}</strong>
<p>{entry.summary}</p>
</Link>
</li>
))}
</ul>
{normalizedQuery ? (
<Link
className="search-all-link"
to={`/search?q=${encodeURIComponent(query.trim())}`}
onClick={close}
>
<span aria-hidden="true"></span>
</Link>
) : null}
</>
) : (
<p className="search-empty"> .</p>
)}
</div>
</dialog>
</>
);
}
@@ -0,0 +1,88 @@
import { useRef } from "react";
import { Link, useLocation } from "react-router-dom";
import { publicSiteConfig } from "../../../contracts/public-site-config.ts";
import { SearchDialog } from "./search-dialog.tsx";
const navigation = [
{ label: "탐색", path: "/explore" },
{ label: "프로젝트", path: "/projects" },
{ label: "변경 기록", path: "/releases" },
{ label: "프로필", path: "/profile" },
] as const;
export function SiteHeader({ currentPath }: { currentPath?: string }) {
const location = useLocation();
const activePath = currentPath ?? location.pathname;
const menuRef = useRef<HTMLDetailsElement>(null);
const summaryRef = useRef<HTMLElement>(null);
function isCurrent(path: string) {
return (
activePath === path ||
(path !== "/" && activePath.startsWith(path + "/"))
);
}
function closeMenu(restoreFocus = false) {
if (!menuRef.current) return;
menuRef.current.open = false;
if (restoreFocus) summaryRef.current?.focus();
}
return (
<header className="site-header">
<div className="shell header-inner">
<div className="header-brand-zone">
<Link className="wordmark" to="/" aria-label="TechLog 홈">
{publicSiteConfig.brandTitle}
</Link>
</div>
<div className="header-menu-zone">
<nav className="desktop-nav" aria-label="주요 탐색">
{navigation.map((item) => (
<Link
key={item.path}
className="nav-link"
to={item.path}
aria-current={isCurrent(item.path) ? "page" : undefined}
>
{item.label}
</Link>
))}
</nav>
<div className="header-search-zone">
<SearchDialog
className="search-button"
onBeforeOpen={() => closeMenu()}
/>
</div>
<details
ref={menuRef}
className="mobile-nav"
onKeyDown={(event) => {
if (event.key !== "Escape") return;
event.preventDefault();
closeMenu(true);
}}
>
<summary ref={summaryRef}></summary>
<nav aria-label="모바일 주요 탐색">
{navigation.map((item) => (
<Link
key={item.path}
to={item.path}
aria-current={isCurrent(item.path) ? "page" : undefined}
onClick={() => closeMenu()}
>
{item.label}
</Link>
))}
</nav>
</details>
</div>
</div>
</header>
);
}