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
7 changes: 7 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,18 @@
"mcp__playwright__browser_close",
"mcp__playwright__browser_console_messages",
"mcp__playwright__browser_evaluate",
"mcp__playwright__browser_fill_form",
"mcp__playwright__browser_hover",
"mcp__playwright__browser_navigate",
"mcp__playwright__browser_navigate_back",
"mcp__playwright__browser_network_requests",
"mcp__playwright__browser_press_key",
"mcp__playwright__browser_resize",
"mcp__playwright__browser_run_code",
"mcp__playwright__browser_snapshot",
"mcp__playwright__browser_tabs",
"mcp__playwright__browser_take_screenshot",
"mcp__playwright__browser_type",
"mcp__playwright__browser_wait_for",
"Bash(cat:*)",
"Bash(echo:*)",
Expand All @@ -26,9 +30,11 @@
"Bash(tree:*)",
"Bash(wc:*)",
"Bash(xargs:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(gh pr checks:*)",
"Bash(gh pr list:*)",
"Bash(gh pr view:*)",
"Bash(gh run list:*)",
"Bash(gh run view:*)",
"Bash(npm run build)",
Expand All @@ -44,6 +50,7 @@
"Bash(npx @biomejs/biome:*)",
"Bash(npx lefthook:*)",
"Bash(npx playwright:*)",
"Bash(npx vercel:*)",
"Bash(vercel --help)",
"Bash(vercel env --help)",
"Bash(vercel env ls:*)",
Expand Down
6 changes: 3 additions & 3 deletions app/(root)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { cookies } from "next/headers";
import { DesktopTopBar } from "@/components/navigation/desktop-top-bar";
import { DesktopTopBar } from "@/components/navigation/desktop-topbar";
import { LeftSidebar } from "@/components/navigation/left-sidebar";
import { MobileTopBar } from "@/components/navigation/mobile-top-bar";
import { RightSidebar } from "@/components/navigation/right-sidebar";
import { MobileTopBar } from "@/components/navigation/mobile-topbar";
import { RightSidebar } from "@/components/right-sidebar/right-sidebar";
import { SidebarProvider } from "@/components/ui/sidebar";

const RootLayout = async ({ children }: { children: React.ReactNode }) => {
Expand Down
8 changes: 8 additions & 0 deletions app/(root)/question/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
type QuestionPageProps = {
params: Promise<{ id: string }>;
};

export default async function QuestionPage({ params }: QuestionPageProps) {
const { id } = await params;
return <h1 className="text-heading-2xl">Question {id}</h1>;
}
8 changes: 8 additions & 0 deletions app/(root)/tags/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
type TagPageProps = {
params: Promise<{ slug: string }>;
};

export default async function TagPage({ params }: TagPageProps) {
const { slug } = await params;
return <h1 className="text-heading-2xl">Tag: {slug}</h1>;
}
15 changes: 10 additions & 5 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@
--color-sidebar-ring: var(--sidebar-ring);

/* Custom */
--color-mobile-nav: var(--mobile-nav);
--color-overlay: var(--overlay);
}

Expand Down Expand Up @@ -119,13 +118,16 @@
var(--primary-gradient-to) 99.54%
);

/* Custom: Navigation */
--mobile-nav: oklch(1 0 0); /* #FFFFFF */
/* Custom: Overlay */
--overlay: oklch(0 0 0); /* black, both modes */

/* Custom: Assets (theme-aware URLs) */
--logo-full-themed: url("/images/logo-light.svg");
--auth-bg: url("/images/auth-bg-light.webp");

/* Custom: Tag badges */
--tag-bg: oklch(0.9722 0.0034 247.86); /* #F4F6F8 */
--tag-text: oklch(0.6504 0.0475 272.34); /* #858EAD */
}

/* Dark mode overrides — these values take precedence over :root when .dark is active */
Expand Down Expand Up @@ -178,13 +180,16 @@
var(--primary-gradient-to) 99.54%
); /* same as root */

/* Custom: Navigation */
--mobile-nav: oklch(0.1288 0.0406 264.7); /* #07080b */
/* Custom: Overlay */
--overlay: oklch(0 0 0); /* same as root */

/* Custom: Assets (theme-aware URLs) */
--logo-full-themed: url("/images/logo-dark.svg");
--auth-bg: url("/images/auth-bg-dark.webp");

/* Custom: Tag badges */
--tag-bg: oklch(0.2102 0.0185 270.39); /* #151821 */
--tag-text: oklch(0.6547 0.0897 269.9); /* #7B8EC8 */
}

@layer base {
Expand Down
4 changes: 2 additions & 2 deletions components/navigation/mobile-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
import { Menu, X } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { MobileNavLink } from "@/components/navigation/mobile-nav-link";
import { MobileNavLink } from "@/components/navigation/mobile-navlink";
import { NAV_LINKS } from "@/components/navigation/nav-links.constants";
import { ThemedFullLogo } from "@/components/navigation/themed-full-logo";
import { Button } from "@/components/ui/button";
Expand Down Expand Up @@ -58,7 +58,7 @@ export function MobileNav() {
<SheetContent
side="left"
className={cn(
"flex flex-col gap-6 bg-mobile-nav p-6 shadow-2xl",
"flex flex-col gap-6 bg-sidebar p-6 shadow-2xl",
MOBILE_NAV_MAX_WIDTH,
)}
>
Expand Down
21 changes: 21 additions & 0 deletions components/right-sidebar/question-link.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { ChevronRight } from "lucide-react";
import Link from "next/link";

type QuestionLinkProps = {
id: string;
title: string;
};

export function QuestionLink({ id, title }: QuestionLinkProps) {
return (
<Link
href={`/question/${id}`}
className="group -mx-2 flex items-center gap-2 rounded-md p-2"
>
<span className="flex-1 text-sm leading-snug text-foreground decoration-muted-foreground/50 underline-offset-2 group-hover:underline">
{title}
</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground transition-transform duration-150 group-hover:translate-x-0.5" />
</Link>
);
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
import { QuestionLink } from "@/components/right-sidebar/question-link";
import { TagLink } from "@/components/right-sidebar/tag-link";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
} from "@/components/ui/sidebar";
import { Skeleton } from "@/components/ui/skeleton";
import { getTopQuestions } from "@/lib/data/questions";
import { getPopularTags } from "@/lib/data/tags";

/** Asymmetric padding: more on left, less on right (scrollbar side), reduced bottom */
const GROUP_PADDING = "pt-6 pb-2 pl-6 pr-3";

export function RightSidebar() {
export async function RightSidebar() {
const [topQuestions, popularTags] = await Promise.all([
getTopQuestions(5),
getPopularTags(5),
]);

return (
<aside
aria-label="Top questions and popular tags"
Expand All @@ -28,14 +36,13 @@ export function RightSidebar() {
Top Questions
</h2>
<SidebarGroupContent>
<div className="space-y-6">
{Array.from({ length: 6 }, (_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static skeleton placeholders
<div key={i} className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-5/6" />
</div>
<div className="space-y-1">
{topQuestions.map((question) => (
<QuestionLink
key={question._id}
id={question._id}
title={question.title}
/>
))}
</div>
</SidebarGroupContent>
Expand All @@ -46,13 +53,14 @@ export function RightSidebar() {
Popular Tags
</h2>
<SidebarGroupContent>
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-5/6" />
<div className="space-y-2">
{popularTags.map((tag) => (
<TagLink
key={tag.name}
name={tag.name}
questionCount={tag.questions}
/>
))}
</div>
</SidebarGroupContent>
</SidebarGroup>
Expand Down
23 changes: 23 additions & 0 deletions components/right-sidebar/tag-link.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import Link from "next/link";
import { Badge } from "@/components/ui/badge";

type TagLinkProps = {
name: string;
questionCount?: number;
};

export function TagLink({ name, questionCount }: TagLinkProps) {
return (
<Link
href={`/tags/${name}`}
className="group flex items-center justify-between gap-2 rounded-md p-1 transition-transform duration-150 hover:-translate-y-0.5 hover:scale-[1.005]"
>
<Badge className="rounded-md border-transparent bg-(--tag-bg) px-4 py-2 uppercase text-(--tag-text)">
{name}
</Badge>
{questionCount !== undefined && (
<span className="text-xs text-muted-foreground">{questionCount}</span>
)}
</Link>
);
}
46 changes: 46 additions & 0 deletions components/ui/badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";

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

const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);

function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span";

return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}

export { Badge, badgeVariants };
70 changes: 70 additions & 0 deletions e2e/right-sidebar.desktop.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { expect, test } from "@playwright/test";

test.describe("Right Sidebar - Desktop", () => {
test("displays questions and tags with working navigation and responsive scroll", async ({
page,
}) => {
// Start at short viewport (1280x650) - triggers scrollbar
await page.setViewportSize({ width: 1280, height: 650 });
await page.goto("/");

// Sidebar should be visible at xl breakpoint
const sidebar = page.getByRole("complementary", {
name: "Top questions and popular tags",
});
await expect(sidebar).toBeVisible();

// Count exactly 5 question links and 5 tag links
const questionLinks = sidebar.locator('a[href^="/question/"]');
const tagLinks = sidebar.locator('a[href^="/tags/"]');
await expect(questionLinks).toHaveCount(5);
await expect(tagLinks).toHaveCount(5);

// Verify scrollbar IS present at short viewport
const hasScrollAtShortHeight = await page.evaluate(() => {
const container = document.querySelector(
'#right-sidebar [data-sidebar="content"]',
);
return container
? container.scrollHeight > container.clientHeight
: false;
});
expect(
hasScrollAtShortHeight,
"Sidebar should have vertical scroll at 650px height",
).toBe(true);

// Click first question link and verify URL pattern
await questionLinks.first().click();
await expect(page).toHaveURL(/\/question\/\d+/);

// Navigate back to homepage
await page.goto("/");

// Click first tag link and verify URL pattern
await tagLinks.first().click();
await expect(page).toHaveURL(/\/tags\/[a-z-]+/);

// Navigate back to homepage
await page.goto("/");

// Resize to tall viewport (1280x1000) - no scrollbar needed
await page.setViewportSize({ width: 1280, height: 1000 });
const hasScrollAtTallHeight = await page.evaluate(() => {
const container = document.querySelector(
'#right-sidebar [data-sidebar="content"]',
);
return container
? container.scrollHeight > container.clientHeight
: false;
});
expect(
hasScrollAtTallHeight,
"Sidebar should not have vertical scroll at 1000px height",
).toBe(false);

// Resize to below xl breakpoint (1279px) - sidebar should be hidden
await page.setViewportSize({ width: 1279, height: 650 });
await expect(sidebar).not.toBeVisible();
});
});
19 changes: 19 additions & 0 deletions lib/data/questions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { getTopQuestions } from "@/lib/data/questions";

describe("getTopQuestions", () => {
it("returns 5 questions by default", async () => {
const questions = await getTopQuestions();
expect(questions).toHaveLength(5);
});

it("respects custom limit parameter", async () => {
const questions = await getTopQuestions(3);
expect(questions).toHaveLength(3);
});

it("returns questions with required properties", async () => {
const questions = await getTopQuestions(1);
expect(questions[0]).toHaveProperty("_id");
expect(questions[0]).toHaveProperty("title");
});
});
Loading
Loading