Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 82 additions & 45 deletions apps/web/src/components/app-header.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
"use client";

// 최상단 헤더 (project 스코프). 로고 / owner / repo 브레드크럼 · 검색 · Feedback · 프로필.
// 데이터는 project layout 이 넘겨준다. 파일 검색 목록은 PR B(레포 트리)에서 연결.
// 검색은 오른쪽 그룹 옆에 붙는다(GitHub 식) — 브레드크럼과 같은 flex 줄이라 겹칠 수가 없다.
// md 아래에서는 브레드크럼이 버튼 하나로 접히고, 누르면 바텀시트가 올라온다(PathSheet).
// 데이터는 project layout 이 넘겨준다.
// 프로필은 드롭다운(user-menu). Feedback 은 구글폼 설문을 새 탭으로 연다.

import { Fragment, useTransition } from "react";
import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { Blocks, Box, Plus } from "lucide-react";
import { Blocks, Box, Plus, Settings, Users } from "lucide-react";
import { switchTeam } from "@/app/team/actions";
import danteLogo from "@/assets/dante-logo.png";
import { FeedbackLink } from "@/components/feedback-link";
import { FileSearch } from "@/components/file-search";
import { HeaderSwitcher, SwitcherRow } from "@/components/header-switcher";
import { TeamSwitcher } from "@/components/team-switcher";
import { PathSheet, type PathLevel } from "@/components/path-sheet";
import { UserMenu, type HeaderUser } from "@/components/user-menu";
import type { ProjectSummary } from "@/lib/projects/queries";
import type { TeamOption } from "@/lib/teams/current";
Expand All @@ -39,6 +43,7 @@ export function AppHeader({
}) {
const router = useRouter();
const pathname = usePathname();
const [, startTransition] = useTransition();
const owner = project.repoOwner;
const owners = [...new Set(projects.map((p) => p.repoOwner))];
const ownerProjects = projects.filter((p) => p.repoOwner === owner);
Expand All @@ -55,6 +60,58 @@ export function AppHeader({
</SwitcherRow>
);

// 경로 한 벌을 만들어 데스크톱 브레드크럼과 모바일 시트가 같이 쓴다.
const levels: PathLevel[] = [
{
// 팀은 쿠키가 아니라 이 프로젝트의 팀이다(lib/teams/current.ts).
kind: "Team",
icon: <Users className="text-muted-foreground size-3.5 shrink-0" />,
value: project.teamId,
items: teams.map((team) => ({ value: team.id, label: team.name })),
findLabel: "Find team…",
onSelect: (id) => startTransition(() => switchTeam(id, "/projects")),
footer: (
<>
<SwitcherRow onClick={() => router.push(`/team/${project.teamId}/settings/general`)}>
<Settings className="size-4" />
Team settings
</SwitcherRow>
<SwitcherRow onClick={() => router.push("/account/settings/teams")}>
<Plus className="size-4" />
New team
</SwitcherRow>
</>
),
},
{
kind: "Organization",
icon: <Blocks className="text-muted-foreground size-3.5 shrink-0" />,
value: owner,
items: owners.map((o) => ({ value: o, label: o })),
findLabel: "Find organization…",
onSelect: (o) => {
const target = projects.find((p) => p.repoOwner === o);
if (target) goto(target.ref);
else router.push("/projects");
},
footer: (
<>
<SwitcherRow onClick={() => router.push("/projects")}>All organizations</SwitcherRow>
{newRow("New organization", "/projects/new")}
</>
),
},
{
kind: "Repository",
icon: <Box className="text-muted-foreground size-3.5 shrink-0" />,
value: project.ref,
items: ownerProjects.map((p) => ({ value: p.ref, label: p.name })),
findLabel: "Find repository…",
onSelect: goto,
footer: newRow("New repository", "/projects/new/github"),
},
];

return (
<header className="bg-sidebar border-sidebar-border fixed inset-x-0 top-0 z-40 flex h-[47px] items-center border-b pr-3">
{/* 로고 = 메인 레일(w-14)과 같은 열 → 첫 구분자가 레일 border-r 선에 맞음 */}
Expand All @@ -64,52 +121,32 @@ export function AppHeader({
</Link>
</div>

<nav aria-label="Breadcrumb" className="flex shrink-0 items-center gap-4">
{/* 팀은 쿠키가 아니라 이 프로젝트의 팀이다(lib/teams/current.ts). */}
<Slash />
<TeamSwitcher teams={teams} value={project.teamId} landing="projects" />

<Slash />
<HeaderSwitcher
kind="Organization"
value={owner}
items={owners.map((o) => ({ value: o, label: o }))}
findLabel="Find organization…"
onSelect={(o) => {
const target = projects.find((p) => p.repoOwner === o);
if (target) goto(target.ref);
else router.push("/projects");
}}
icon={<Blocks className="text-muted-foreground size-3.5 shrink-0" />}
footer={
<>
<SwitcherRow onClick={() => router.push("/projects")}>All organizations</SwitcherRow>
{newRow("New organization", "/projects/new")}
</>
}
/>

<Slash />
<HeaderSwitcher
kind="Repository"
value={project.ref}
items={ownerProjects.map((p) => ({ value: p.ref, label: p.name }))}
findLabel="Find repository…"
onSelect={goto}
icon={<Box className="text-muted-foreground size-3.5 shrink-0" />}
footer={newRow("New repository", "/projects/new/github")}
/>
{/* 좁아지면 앞 세그먼트부터 말줄임된다. 숨기지는 않는다 — 경로는 지금 어디인지를 알려주는 유일한 표시다. */}
<nav aria-label="Breadcrumb" className="hidden min-w-0 shrink items-center gap-4 md:flex">
{levels.map((level, i) => (
<Fragment key={level.kind}>
<Slash />
{/* 레포 이름은 안 줄인다 — 셋 중 제일 중요하다. */}
<HeaderSwitcher
{...level}
className={i === levels.length - 1 ? "shrink-0" : undefined}
/>
</Fragment>
))}
</nav>

{/* 검색창은 브레드크럼 길이와 무관하게 화면 중앙 고정 (레이아웃 시프트 방지).
absolute 라 DOM 순서와 화면 위치가 무관하다 — 탭 순서가 로고·브레드크럼 다음이 되게 뒤에 둔다. */}
<div className="pointer-events-none absolute inset-x-0 flex justify-center px-3">
<div className="pointer-events-auto w-full max-w-[556px]">
<FileSearch projectRef={project.ref} />
</div>
{/* md 아래: 세 단계를 버튼 하나로. 누르면 바텀시트에서 탭으로 고른다. */}
<PathSheet levels={levels} className="ml-2 md:hidden" />

{/* ml-auto 는 검색창이 가져간다 — 오른쪽 그룹에 붙어 고정되니 브레드크럼이 길어져도 안 밀린다.
shrink-0 을 주면 안 된다: 좁아질 때 검색창이 320px 을 붙들고 있어 브레드크럼만 찌그러진다. */}
<div className="ml-auto flex min-w-0 shrink items-center pl-3">
<FileSearch projectRef={project.ref} />
</div>

<div className="ml-auto flex shrink-0 items-center gap-2">
<div className="flex shrink-0 items-center gap-2 pl-3">
{/* 없으면 검색창이 Feedback·프로필과 한 덩어리로 읽힌다. */}
<span aria-hidden="true" className="bg-border mr-1 h-4 w-px" />
<FeedbackLink />
<UserMenu user={user} />
</div>
Expand Down
63 changes: 57 additions & 6 deletions apps/web/src/components/file-search.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
"use client";

// 최상단 헤더의 파일 검색. 포커스 시 Recent(최근 연 파일) → 타이핑하면 매칭 목록.
// ↑↓ 이동 · Enter 열기 · Esc 닫기. 선택 시 폴더 뷰로 이동.
// md 아래에서는 돋보기 아이콘만 남기고, 포커스하면 헤더 폭 전체로 펼친다(GitHub 식).
// 별도 모달 대신 open 상태를 그대로 쓴다 — 포커스 트랩도, 새 컴포넌트도 필요 없다.
// "/" 로 어디서든 포커스(GitHub 식). ↑↓ 이동 · Enter 열기 · Esc 닫기. 선택 시 폴더 뷰로 이동.
// 스크린리더용으로 APG 콤보박스 패턴을 따른다: 포커스는 input 에 두고 aria-activedescendant 로
// 고른 항목을 가리킨다. 결과 수는 늘 렌더되는 sr-only status 로 알린다.

import { useId, useMemo, useRef, useState } from "react";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Search } from "lucide-react";
Expand Down Expand Up @@ -69,6 +71,20 @@ export function FileSearch({ projectRef }: { projectRef: string }) {
};

const inputRef = useRef<HTMLInputElement>(null);

// "/" 로 검색창에 포커스. 글을 쓰던 중이면 가로채지 않는다.
useEffect(() => {
const onSlash = (e: KeyboardEvent) => {
if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey) return;
const el = e.target as HTMLElement | null;
if (el?.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el?.tagName ?? "")) return;
e.preventDefault();
inputRef.current?.focus();
};
window.addEventListener("keydown", onSlash);
return () => window.removeEventListener("keydown", onSlash);
}, []);

const onKeyDown = (e: React.KeyboardEvent) => {
if (!open || results.length === 0) return;
if (e.key === "ArrowDown") {
Expand Down Expand Up @@ -97,8 +113,22 @@ export function FileSearch({ projectRef }: { projectRef: string }) {
: "";

return (
<div className="relative w-full">
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2" />
<div
className={cn(
// relative 는 어느 상태에서도 유지해야 한다 — 돋보기 아이콘과 결과 패널이
// 이 박스를 기준으로 붙는다. static 이 되면 기준이 헤더(fixed)로 올라가
// 아이콘이 로고 위로 가고 패널이 헤더 전체 폭으로 퍼진다.
"relative w-7 md:w-80 md:min-w-40",
open && "absolute inset-x-3 z-50 w-auto md:relative md:inset-auto md:w-80"
)}
>
{/* 접혀 있을 때(md 아래)는 아이콘이 가운데. 펼쳐지면 입력 왼쪽으로 돌아간다. */}
<Search
className={cn(
"text-muted-foreground pointer-events-none absolute top-1/2 size-3.5 -translate-y-1/2",
open ? "left-2.5" : "left-1/2 -translate-x-1/2 md:left-2.5 md:translate-x-0"
)}
/>
<Input
ref={inputRef}
value={query}
Expand All @@ -112,16 +142,37 @@ export function FileSearch({ projectRef }: { projectRef: string }) {
}}
onBlur={() => setOpen(false)}
onKeyDown={onKeyDown}
placeholder="Search files…"
aria-label="Search files"
role="combobox"
aria-autocomplete="list"
aria-expanded={expanded}
aria-controls={listId}
aria-activedescendant={expanded ? optionId(active) : undefined}
className="h-7 pl-8 text-xs focus-visible:ring-0 md:text-xs"
className={cn(
"h-7 w-full pl-8 text-xs focus-visible:ring-0 md:text-xs",
// 접힌 36px 안에서는 좌우 여백을 없애야 아이콘이 가운데에 온다.
!open && "px-0 md:pr-2.5 md:pl-8"
)}
/>

{/* placeholder 속성은 마크업을 못 담는다 — / 를 키캡으로 보이려면 겹쳐 그린다.
접힌 상태(md 아래, 28px)에서는 자리가 없어 감춘다. */}
{!query && (
<span
aria-hidden="true"
className={cn(
"text-muted-foreground pointer-events-none absolute top-1/2 left-8 -translate-y-1/2 items-center gap-1 text-xs",
open ? "flex" : "hidden md:flex"
)}
>
Type
<kbd className="border-border text-foreground/80 rounded border px-1 font-mono text-[10px] leading-4">
/
</kbd>
to search
</span>
)}

<p role="status" className="sr-only">
{announcement}
</p>
Expand Down
Loading
Loading