Skip to content

Commit 65b8554

Browse files
nullcoderClaude
andauthored
feat: implement Footer component (#98)
* feat: implement Footer component (#70) - Add Footer component with branding and navigation links - Include Ghost icon and copyright notice with dynamic year - Implement responsive layout (horizontal desktop, stacked mobile) - Add navigation links for GitHub, Privacy, and Terms - Support optional build/version info display - Create FooterWithBuildInfo variant that reads from env vars - Add comprehensive tests and demo page - Ensure proper semantic HTML and accessibility 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <claude@ghostpaste.dev> * feat: add Footer to layout and update docs --------- Co-authored-by: Claude <claude@ghostpaste.dev>
1 parent 346acab commit 65b8554

File tree

6 files changed

+393
-10
lines changed

6 files changed

+393
-10
lines changed

app/demo/footer/page.tsx

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
"use client";
2+
3+
import * as React from "react";
4+
import { Footer, FooterWithBuildInfo } from "@/components/footer";
5+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
6+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
7+
8+
export default function FooterDemo() {
9+
return (
10+
<div className="min-h-screen">
11+
<div className="container mx-auto py-8">
12+
<h1 className="mb-8 text-3xl font-bold">Footer Component Demo</h1>
13+
14+
<Tabs defaultValue="basic" className="w-full">
15+
<TabsList className="grid w-full grid-cols-3">
16+
<TabsTrigger value="basic">Basic Footer</TabsTrigger>
17+
<TabsTrigger value="build">With Build Info</TabsTrigger>
18+
<TabsTrigger value="styled">Custom Styling</TabsTrigger>
19+
</TabsList>
20+
21+
<TabsContent value="basic" className="space-y-4">
22+
<Card>
23+
<CardHeader>
24+
<CardTitle>Basic Footer</CardTitle>
25+
</CardHeader>
26+
<CardContent>
27+
<p className="text-muted-foreground mb-4 text-sm">
28+
The default footer with branding and navigation links.
29+
</p>
30+
<div className="rounded-lg border">
31+
<Footer />
32+
</div>
33+
</CardContent>
34+
</Card>
35+
</TabsContent>
36+
37+
<TabsContent value="build" className="space-y-4">
38+
<Card>
39+
<CardHeader>
40+
<CardTitle>Footer with Build Information</CardTitle>
41+
</CardHeader>
42+
<CardContent>
43+
<p className="text-muted-foreground mb-4 text-sm">
44+
Footer displaying build ID for version tracking.
45+
</p>
46+
<div className="space-y-4">
47+
<div className="rounded-lg border">
48+
<Footer buildId="v1.2.3-abc123" />
49+
</div>
50+
<div className="rounded-lg border">
51+
<FooterWithBuildInfo />
52+
</div>
53+
</div>
54+
</CardContent>
55+
</Card>
56+
</TabsContent>
57+
58+
<TabsContent value="styled" className="space-y-4">
59+
<Card>
60+
<CardHeader>
61+
<CardTitle>Custom Styled Footer</CardTitle>
62+
</CardHeader>
63+
<CardContent>
64+
<p className="text-muted-foreground mb-4 text-sm">
65+
Footer with custom background and styling.
66+
</p>
67+
<div className="space-y-4">
68+
<div className="rounded-lg border">
69+
<Footer
70+
className="bg-primary/5 border-t-primary/20"
71+
buildId="custom-123"
72+
/>
73+
</div>
74+
<div className="rounded-lg border">
75+
<Footer
76+
className="from-primary/5 to-secondary/5 bg-gradient-to-r"
77+
buildId="gradient-456"
78+
/>
79+
</div>
80+
</div>
81+
</CardContent>
82+
</Card>
83+
</TabsContent>
84+
</Tabs>
85+
86+
<Card className="mt-8">
87+
<CardHeader>
88+
<CardTitle>Responsive Behavior</CardTitle>
89+
</CardHeader>
90+
<CardContent>
91+
<p className="text-muted-foreground mb-4 text-sm">
92+
The footer adapts to different screen sizes. Try resizing your
93+
browser window to see the responsive layout in action.
94+
</p>
95+
<div className="space-y-2 text-sm">
96+
<p>
97+
<strong>Desktop (≥768px):</strong> Horizontal layout with
98+
left-aligned branding and right-aligned navigation
99+
</p>
100+
<p>
101+
<strong>Mobile (&lt;768px):</strong> Stacked layout with
102+
centered content
103+
</p>
104+
</div>
105+
</CardContent>
106+
</Card>
107+
108+
<Card className="mt-8">
109+
<CardHeader>
110+
<CardTitle>Usage Example</CardTitle>
111+
</CardHeader>
112+
<CardContent>
113+
<pre className="bg-muted overflow-x-auto rounded-lg p-4 text-sm">
114+
{`// Basic footer
115+
<Footer />
116+
117+
// Footer with build ID
118+
<Footer buildId="v1.2.3" />
119+
120+
// Footer with environment-based build ID
121+
<FooterWithBuildInfo />
122+
123+
// Footer with custom styling
124+
<Footer
125+
className="bg-primary/5"
126+
buildId="custom-build"
127+
/>`}
128+
</pre>
129+
</CardContent>
130+
</Card>
131+
132+
<Card className="mt-8">
133+
<CardHeader>
134+
<CardTitle>Features</CardTitle>
135+
</CardHeader>
136+
<CardContent className="space-y-2">
137+
<p>
138+
✓ Responsive layout (horizontal on desktop, stacked on mobile)
139+
</p>
140+
<p>✓ Branding with Ghost icon and company name</p>
141+
<p>✓ Copyright notice with current year</p>
142+
<p>✓ Navigation links (GitHub, Privacy, Terms)</p>
143+
<p>✓ Optional build/version display</p>
144+
<p>✓ Proper semantic HTML structure</p>
145+
<p>✓ Accessible navigation with ARIA labels</p>
146+
<p>✓ Theme-aware styling</p>
147+
<p>✓ External links open in new tab with security attributes</p>
148+
</CardContent>
149+
</Card>
150+
</div>
151+
152+
{/* Example of footer at page bottom */}
153+
<div className="mt-auto">
154+
<Footer buildId="demo-build" />
155+
</div>
156+
</div>
157+
);
158+
}

app/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
22
import { Geist, Geist_Mono } from "next/font/google";
33
import { ThemeProvider } from "@/components/theme-provider";
44
import { Header } from "@/components/header";
5+
import { FooterWithBuildInfo } from "@/components/footer";
56
import { ErrorBoundary } from "@/components/error-boundary";
67
import { Toaster } from "sonner";
78
import "./globals.css";
@@ -43,6 +44,7 @@ export default function RootLayout({
4344
<main id="main-content" className="flex-1">
4445
<ErrorBoundary>{children}</ErrorBoundary>
4546
</main>
47+
<FooterWithBuildInfo />
4648
</ErrorBoundary>
4749
<Toaster />
4850
</ThemeProvider>

components/footer.test.tsx

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
3+
import { Footer, FooterWithBuildInfo } from "./footer";
4+
5+
// Mock next/link
6+
vi.mock("next/link", () => ({
7+
default: ({
8+
children,
9+
...props
10+
}: React.PropsWithChildren<
11+
React.AnchorHTMLAttributes<HTMLAnchorElement>
12+
>) => <a {...props}>{children}</a>,
13+
}));
14+
15+
describe("Footer", () => {
16+
it("renders branding elements", () => {
17+
render(<Footer />);
18+
19+
// Check for logo/text
20+
expect(screen.getByText("GhostPaste")).toBeInTheDocument();
21+
22+
// Check for copyright with current year
23+
const currentYear = new Date().getFullYear();
24+
expect(
25+
screen.getByText(
26+
${currentYear} GhostPaste. Zero-knowledge encrypted code sharing.`
27+
)
28+
).toBeInTheDocument();
29+
});
30+
31+
it("renders navigation links with correct attributes", () => {
32+
render(<Footer />);
33+
34+
// Check GitHub link
35+
const githubLink = screen.getByRole("link", { name: "GitHub" });
36+
expect(githubLink).toHaveAttribute(
37+
"href",
38+
"https://github.com/nullcoder/ghostpaste"
39+
);
40+
expect(githubLink).toHaveAttribute("target", "_blank");
41+
expect(githubLink).toHaveAttribute("rel", "noopener noreferrer");
42+
43+
// Check Privacy link
44+
const privacyLink = screen.getByRole("link", { name: "Privacy" });
45+
expect(privacyLink).toHaveAttribute("href", "/privacy");
46+
expect(privacyLink).not.toHaveAttribute("target");
47+
48+
// Check Terms link
49+
const termsLink = screen.getByRole("link", { name: "Terms" });
50+
expect(termsLink).toHaveAttribute("href", "/terms");
51+
expect(termsLink).not.toHaveAttribute("target");
52+
});
53+
54+
it("renders build ID when provided", () => {
55+
render(<Footer buildId="abc123" />);
56+
expect(screen.getByText("Build abc123")).toBeInTheDocument();
57+
});
58+
59+
it("does not render build info when buildId is not provided", () => {
60+
render(<Footer />);
61+
expect(screen.queryByText(/Build/)).not.toBeInTheDocument();
62+
});
63+
64+
it("applies custom className", () => {
65+
const { container } = render(<Footer className="custom-footer" />);
66+
expect(container.querySelector("footer")).toHaveClass("custom-footer");
67+
});
68+
69+
it("has proper semantic structure", () => {
70+
render(<Footer />);
71+
72+
// Check for footer element
73+
expect(screen.getByRole("contentinfo")).toBeInTheDocument();
74+
75+
// Check for navigation element
76+
expect(screen.getByRole("navigation")).toHaveAttribute(
77+
"aria-label",
78+
"Footer navigation"
79+
);
80+
});
81+
82+
it("uses responsive classes for layout", () => {
83+
const { container } = render(<Footer />);
84+
85+
// Check for responsive flex layout
86+
const flexContainer = container.querySelector(
87+
".flex.flex-col.md\\:flex-row"
88+
);
89+
expect(flexContainer).toBeInTheDocument();
90+
91+
// Check for responsive text alignment
92+
const brandingSection = container.querySelector(
93+
".text-center.md\\:text-left"
94+
);
95+
expect(brandingSection).toBeInTheDocument();
96+
});
97+
});
98+
99+
describe("FooterWithBuildInfo", () => {
100+
const originalEnv = process.env;
101+
102+
beforeEach(() => {
103+
vi.resetModules();
104+
process.env = { ...originalEnv };
105+
});
106+
107+
afterEach(() => {
108+
process.env = originalEnv;
109+
});
110+
111+
it("uses NEXT_PUBLIC_BUILD_ID when available", () => {
112+
process.env.NEXT_PUBLIC_BUILD_ID = "custom-build-123";
113+
render(<FooterWithBuildInfo />);
114+
expect(screen.getByText("Build custom-build-123")).toBeInTheDocument();
115+
});
116+
117+
it("uses VERCEL_GIT_COMMIT_SHA when NEXT_PUBLIC_BUILD_ID is not available", () => {
118+
delete process.env.NEXT_PUBLIC_BUILD_ID;
119+
process.env.VERCEL_GIT_COMMIT_SHA = "abcdef1234567890";
120+
render(<FooterWithBuildInfo />);
121+
expect(screen.getByText("Build abcdef1")).toBeInTheDocument();
122+
});
123+
124+
it("does not render build info when no env vars are available", () => {
125+
delete process.env.NEXT_PUBLIC_BUILD_ID;
126+
delete process.env.VERCEL_GIT_COMMIT_SHA;
127+
render(<FooterWithBuildInfo />);
128+
expect(screen.queryByText(/Build/)).not.toBeInTheDocument();
129+
});
130+
});

components/footer.tsx

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import * as React from "react";
2+
import Link from "next/link";
3+
import { Container } from "@/components/ui/container";
4+
import { cn } from "@/lib/utils";
5+
import { Ghost } from "lucide-react";
6+
7+
export interface FooterProps {
8+
/**
9+
* Additional CSS classes
10+
*/
11+
className?: string;
12+
/**
13+
* Build ID to display (optional)
14+
*/
15+
buildId?: string;
16+
}
17+
18+
/**
19+
* Footer component with branding, copyright, and navigation links
20+
*/
21+
export function Footer({ className, buildId }: FooterProps) {
22+
const currentYear = new Date().getFullYear();
23+
24+
return (
25+
<footer
26+
className={cn("bg-background/50 border-t backdrop-blur-sm", className)}
27+
>
28+
<Container>
29+
<div className="py-8 md:py-12">
30+
<div className="flex flex-col gap-6 md:flex-row md:justify-between">
31+
{/* Left section - Branding */}
32+
<div className="space-y-2 text-center md:text-left">
33+
<div className="flex items-center justify-center gap-2 md:justify-start">
34+
<Ghost className="h-6 w-6" aria-hidden="true" />
35+
<span className="text-lg font-semibold">GhostPaste</span>
36+
</div>
37+
<p className="text-muted-foreground text-sm">
38+
© {currentYear} GhostPaste. Zero-knowledge encrypted code
39+
sharing.
40+
</p>
41+
</div>
42+
43+
{/* Right section - Navigation */}
44+
<nav
45+
className="flex items-center justify-center gap-6 text-sm md:justify-end"
46+
aria-label="Footer navigation"
47+
>
48+
<Link
49+
href="https://github.com/nullcoder/ghostpaste"
50+
target="_blank"
51+
rel="noopener noreferrer"
52+
className="text-muted-foreground hover:text-foreground transition-colors hover:underline"
53+
>
54+
GitHub
55+
</Link>
56+
<Link
57+
href="/privacy"
58+
className="text-muted-foreground hover:text-foreground transition-colors hover:underline"
59+
>
60+
Privacy
61+
</Link>
62+
<Link
63+
href="/terms"
64+
className="text-muted-foreground hover:text-foreground transition-colors hover:underline"
65+
>
66+
Terms
67+
</Link>
68+
</nav>
69+
</div>
70+
71+
{/* Optional: Build info */}
72+
{buildId && (
73+
<div className="text-muted-foreground/70 mt-6 text-center text-xs md:text-left">
74+
Build {buildId}
75+
</div>
76+
)}
77+
</div>
78+
</Container>
79+
</footer>
80+
);
81+
}
82+
83+
/**
84+
* Footer with build ID from environment variable
85+
*/
86+
export function FooterWithBuildInfo(props: Omit<FooterProps, "buildId">) {
87+
const buildId =
88+
process.env.NEXT_PUBLIC_BUILD_ID ||
89+
process.env.VERCEL_GIT_COMMIT_SHA?.slice(0, 7);
90+
91+
return <Footer {...props} buildId={buildId} />;
92+
}

0 commit comments

Comments
 (0)