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