Skip to content
Closed
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
2 changes: 2 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider";
import { Header } from "@/components/header";
import "./globals.css";

const geistSans = Geist({
Expand Down Expand Up @@ -35,6 +36,7 @@ export default function RootLayout({
enableSystem
disableTransitionOnChange
>
<Header />
{children}
</ThemeProvider>
</body>
Expand Down
5 changes: 0 additions & 5 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
import { ThemeToggle } from "@/components/theme-toggle";
import { R2Test } from "@/components/r2-test";

export default function Home() {
return (
<div className="min-h-screen p-8">
<header className="mb-8 flex items-center justify-between">
<h1 className="text-3xl font-bold">GhostPaste</h1>
<ThemeToggle />
</header>
<main className="mx-auto max-w-4xl">
<div className="bg-card text-card-foreground rounded-lg border p-6 shadow-sm">
<h2 className="mb-4 text-2xl font-semibold">
Expand Down
46 changes: 46 additions & 0 deletions components/header.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Header } from "./header";

// Mock next-themes used by ThemeToggle
vi.mock("next-themes", () => ({
useTheme: () => ({
theme: "light",
setTheme: vi.fn(),
resolvedTheme: "light",
systemTheme: "light",
themes: ["light", "dark"],
forcedTheme: undefined,
}),
}));

describe("Header", () => {
it("renders logo and navigation links", () => {
render(<Header />);
expect(
screen.getByRole("link", { name: /ghostpaste/i })
).toBeInTheDocument();
expect(
screen.getAllByRole("link", { name: /create/i })[0]
).toBeInTheDocument();
});

it("toggles mobile navigation", async () => {
const user = userEvent.setup();
render(<Header />);

const toggle = screen.getByLabelText(/toggle menu/i);
expect(
screen.queryByLabelText("Mobile navigation")
).not.toBeInTheDocument();

await user.click(toggle);
expect(screen.getByLabelText("Mobile navigation")).toBeInTheDocument();

await user.click(toggle);
expect(
screen.queryByLabelText("Mobile navigation")
).not.toBeInTheDocument();
});
});
126 changes: 126 additions & 0 deletions components/header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"use client";

import Link from "next/link";
import { useEffect, useState } from "react";
import { Menu } from "lucide-react";

import { cn } from "@/lib/utils";
import {
NavigationMenu,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
} from "@/components/ui/navigation-menu";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { ThemeToggle } from "@/components/theme-toggle";

export function Header() {
const [mobileOpen, setMobileOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);

useEffect(() => {
function handleScroll() {
setScrolled(window.scrollY > 0);
}
handleScroll();
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);

return (
<header
className={cn(
"bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-40 w-full border-b backdrop-blur",
scrolled && "shadow-sm"
)}
>
<a
href="#main"
className="bg-muted sr-only absolute top-2 left-4 rounded px-2 py-1 text-sm focus:not-sr-only"
>
Skip to main content
</a>
<div className="container mx-auto flex h-14 items-center justify-between px-4">
<Link href="/" className="flex items-center font-semibold">
<span className="mr-1">👻</span>
GhostPaste
</Link>
<NavigationMenu className="hidden sm:block">
<NavigationMenuList className="flex gap-4">
<NavigationMenuItem>
<Link href="/create" legacyBehavior passHref>
<NavigationMenuLink className="text-sm font-medium hover:underline">
Create
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
<NavigationMenuItem>
<Link href="/about" legacyBehavior passHref>
<NavigationMenuLink className="text-sm font-medium hover:underline">
About
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink
href="https://github.com/nullcoder/ghostpaste"
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium hover:underline"
>
GitHub
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
<div className="flex items-center gap-2">
<ThemeToggle />
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetTrigger asChild>
<Button
aria-label="Toggle menu"
variant="ghost"
size="icon"
className="sm:hidden"
>
<Menu className="size-4" />
</Button>
</SheetTrigger>
<SheetContent
side="left"
aria-label="Mobile navigation"
className="sm:hidden"
>
<nav className="mt-4 grid gap-2 text-base">
<Link
href="/create"
onClick={() => setMobileOpen(false)}
className="hover:underline"
>
Create
</Link>
<Link
href="/about"
onClick={() => setMobileOpen(false)}
className="hover:underline"
>
About
</Link>
<a
href="https://github.com/nullcoder/ghostpaste"
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
onClick={() => setMobileOpen(false)}
>
GitHub
</a>
</nav>
</SheetContent>
</Sheet>
</div>
</div>
</header>
);
}
47 changes: 47 additions & 0 deletions components/ui/navigation-menu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

function NavigationMenu({ className, ...props }: React.ComponentProps<"nav">) {
return <nav className={cn(className)} {...props} />;
}

const NavigationMenuList = React.forwardRef<
HTMLUListElement,
React.HTMLAttributes<HTMLUListElement>
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex items-center gap-4", className)}
{...props}
/>
));
NavigationMenuList.displayName = "NavigationMenuList";

const NavigationMenuItem = React.forwardRef<
HTMLLIElement,
React.HTMLAttributes<HTMLLIElement>
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn(className)} {...props} />
));
NavigationMenuItem.displayName = "NavigationMenuItem";

const NavigationMenuLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a">
>(({ className, ...props }, ref) => (
<a
ref={ref}
className={cn("text-sm font-medium hover:underline", className)}
{...props}
/>
));
NavigationMenuLink.displayName = "NavigationMenuLink";

export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuLink,
};
106 changes: 106 additions & 0 deletions components/ui/sheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"use client";

import * as React from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";

import { cn } from "@/lib/utils";

interface SheetContextValue {
open: boolean;
setOpen: (open: boolean) => void;
}

const SheetContext = React.createContext<SheetContextValue | null>(null);

interface SheetProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
children: React.ReactNode;
}

function Sheet({ open, onOpenChange, children }: SheetProps) {
const [internalOpen, setInternalOpen] = React.useState(false);
const isControlled = open !== undefined;
const currentOpen = isControlled ? open : internalOpen;
const setOpen = isControlled
? (onOpenChange as (o: boolean) => void)
: setInternalOpen;
return (
<SheetContext.Provider value={{ open: currentOpen, setOpen }}>
{children}
</SheetContext.Provider>
);
}

function SheetTrigger({
asChild = false,
children,
...props
}: { asChild?: boolean } & React.ComponentProps<"button">) {
const ctx = React.useContext(SheetContext)!;
const handleClick = () => ctx.setOpen(!ctx.open);
if (asChild && React.isValidElement(children)) {
return React.cloneElement(children as React.ReactElement<any>, {
onClick: handleClick,
});
}
return (
<button onClick={handleClick} {...props}>
{children}
</button>
);
}

interface SheetContentProps extends React.HTMLAttributes<HTMLDivElement> {
side?: "top" | "bottom" | "left" | "right";
}

function SheetContent({
side = "right",
className,
children,
...props
}: SheetContentProps) {
const ctx = React.useContext(SheetContext)!;
React.useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") ctx.setOpen(false);
}
if (ctx.open) document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [ctx]);

if (!ctx.open) return null;
return createPortal(
<div className="fixed inset-0 z-50">
<div
className="absolute inset-0 bg-black/50"
onClick={() => ctx.setOpen(false)}
/>
<div
className={cn(
"bg-background fixed p-6 shadow-lg transition-transform",
side === "left" && "inset-y-0 left-0 w-3/4 max-w-sm border-r",
side === "right" && "inset-y-0 right-0 w-3/4 max-w-sm border-l",
side === "top" && "inset-x-0 top-0 h-3/4 border-b",
side === "bottom" && "inset-x-0 bottom-0 h-3/4 border-t",
className
)}
{...props}
>
{children}
<button
onClick={() => ctx.setOpen(false)}
className="focus:ring-ring absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:outline-none"
>
<X className="size-4" />
<span className="sr-only">Close</span>
</button>
</div>
</div>,
document.body
);
}

export { Sheet, SheetTrigger, SheetContent };
6 changes: 3 additions & 3 deletions docs/PHASE_4_ISSUE_TRACKING.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ All 19 Phase 4 UI component issues have been successfully created on GitHub. Thi

| GitHub # | Component | Priority | Status | Description |
| -------- | ------------- | -------- | ----------- | -------------------------------------------- |
| #53 | Header | HIGH | 🟡 Ready | Main header with navigation and theme toggle |
| #53 | Header | HIGH | 🟢 Complete | Main header with navigation and theme toggle |
| #70 | Footer | LOW | 🟡 Ready | Simple footer with links and copyright |
| #62 | Container | MEDIUM | 🟡 Ready | Reusable container for consistent spacing |
| #57 | Design Tokens | HIGH | 🟢 Complete | Design system tokens for theming |
Expand Down Expand Up @@ -63,7 +63,7 @@ Note: Issue #69 appears to be a duplicate of #59 (both for copy to clipboard fun

### Week 2: Essential Components

5. **#53** - Header (Navigation)
5. **#53** - Header (Navigation) ✅ COMPLETE
6. **#61** - GistViewer (View functionality)
7. **#60** - ShareDialog (Sharing flow)
8. **#58** - ErrorBoundary (Error handling)
Expand Down Expand Up @@ -122,4 +122,4 @@ gh issue edit [number] --add-label "in progress"
gh pr create --title "feat: implement [component]" --body "Closes #[number]"
```

Last Updated: 2025-01-07
Last Updated: 2025-06-07
Loading