This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
|
||||
export function Analytics() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const queryString = searchParams.toString();
|
||||
const visitPath = queryString ? `${pathname}?${queryString}` : pathname;
|
||||
|
||||
useEffect(() => {
|
||||
// Send beacon on mount and path change
|
||||
@@ -13,21 +16,21 @@ export function Analytics() {
|
||||
// Send beacon to THIS website's API, which will relay it to the admin dash
|
||||
await fetch('/api/analytics', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path: pathname, timestamp: Date.now() }),
|
||||
body: JSON.stringify({ path: visitPath }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Analytics-Key': process.env.NEXT_PUBLIC_ANALYTICS_KEY || 'default-analytics-key',
|
||||
},
|
||||
keepalive: true,
|
||||
});
|
||||
} catch (e) {
|
||||
// Fail silently
|
||||
console.error('Analytics fail', e);
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.error('Analytics fail', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
sendBeacon();
|
||||
}, [pathname]);
|
||||
}, [visitPath]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,19 +2,26 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
|
||||
const navItems = [
|
||||
{ href: '/', label: 'Home' },
|
||||
{ href: '/blog', label: 'Writing' },
|
||||
{ href: '/resume', label: 'Resume' },
|
||||
];
|
||||
|
||||
export function Navbar() {
|
||||
const pathname = usePathname();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuId = useId();
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const isActive = (path: string) => pathname?.startsWith(path);
|
||||
|
||||
useEffect(() => {
|
||||
setMenuOpen(false);
|
||||
}, [pathname]);
|
||||
const isActive = (path: string) => path === '/' ? pathname === '/' : pathname?.startsWith(path);
|
||||
const linkClass = (path: string) => `relative py-1 transition-colors after:absolute after:inset-x-0 after:-bottom-0.5 after:h-px after:origin-left after:bg-accent after:transition-transform ${isActive(path)
|
||||
? 'text-ink after:scale-x-100'
|
||||
: 'hover:text-ink after:scale-x-0 hover:after:scale-x-100'
|
||||
}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
@@ -39,55 +46,48 @@ export function Navbar() {
|
||||
return (
|
||||
<nav
|
||||
aria-label="Main navigation"
|
||||
className="fixed left-0 top-0 z-50 w-full border-b border-line bg-paper-overlay"
|
||||
className="fixed left-0 top-0 z-50 w-full border-b border-line bg-paper-overlay shadow-[0_1px_0_rgba(255,255,255,0.26)] backdrop-blur-xl"
|
||||
>
|
||||
<div className="mx-auto flex h-14 max-w-[72rem] items-center justify-between gap-6 px-5 sm:px-6">
|
||||
<Link href="/" className="flex items-baseline gap-2.5 transition-opacity hover:opacity-75">
|
||||
<span className="text-[0.96rem] font-medium tracking-[-0.03em] text-ink">
|
||||
<div className="mx-auto flex h-14 max-w-[72rem] items-center justify-between gap-4 px-5 sm:px-6">
|
||||
<Link href="/" className="flex min-w-0 items-baseline gap-2.5 transition-opacity hover:opacity-75">
|
||||
<span className="truncate text-[0.96rem] font-medium text-ink">
|
||||
Akshay Kolli
|
||||
</span>
|
||||
<span className="hidden font-mono text-[0.65rem] uppercase tracking-[0.14em] text-muted-strong sm:inline">
|
||||
<span className="hidden font-mono text-[0.65rem] uppercase text-muted-strong sm:inline">
|
||||
Research
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-4 text-[0.68rem] font-mono uppercase tracking-[0.14em] text-muted-strong sm:gap-5">
|
||||
<Link
|
||||
href="/"
|
||||
className={`transition-colors ${pathname === '/' ? 'text-ink' : 'hover:text-ink'}`}
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
<Link
|
||||
href="/blog"
|
||||
className={`transition-colors ${isActive('/blog') ? 'text-ink' : 'hover:text-ink'}`}
|
||||
>
|
||||
Writing
|
||||
</Link>
|
||||
<Link
|
||||
href="/resume"
|
||||
className={`transition-colors ${isActive('/resume') ? 'text-ink' : 'hover:text-ink'}`}
|
||||
>
|
||||
Resume
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/akkolli"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="GitHub profile"
|
||||
className="hidden transition-colors hover:text-ink sm:block"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
|
||||
<div className="flex items-center gap-2 text-[0.68rem] font-mono uppercase text-muted-strong sm:gap-5">
|
||||
<div className="hidden items-center gap-5 sm:flex">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
aria-current={isActive(item.href) ? 'page' : undefined}
|
||||
className={linkClass(item.href)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
<a
|
||||
href="https://github.com/akkolli"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="GitHub profile"
|
||||
className="transition-colors hover:text-ink"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
<div ref={menuRef} className="relative sm:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMenuOpen((open) => !open)}
|
||||
aria-label="More navigation items"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-transparent text-muted-strong transition-colors hover:border-line hover:text-ink"
|
||||
aria-controls={menuId}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-transparent text-muted-strong transition-colors hover:border-line hover:bg-accent-soft hover:text-ink"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
@@ -103,18 +103,31 @@ export function Navbar() {
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 top-[calc(100%+0.5rem)] min-w-[9rem] overflow-hidden rounded-md border border-line bg-paper-strong shadow-lg"
|
||||
id={menuId}
|
||||
className="absolute right-0 top-[calc(100%+0.65rem)] min-w-[10.5rem] overflow-hidden rounded-md border border-line bg-paper-strong shadow-[0_18px_46px_rgba(17,19,21,0.13)]"
|
||||
>
|
||||
<a
|
||||
href="https://github.com/akkolli"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
role="menuitem"
|
||||
className="block px-3.5 py-2.5 text-[0.68rem] font-mono uppercase tracking-[0.14em] text-muted-strong transition-colors hover:bg-accent-soft hover:text-ink"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
<nav aria-label="Mobile navigation" className="py-1">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
aria-current={isActive(item.href) ? 'page' : undefined}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className={`block px-3.5 py-2.5 text-[0.68rem] font-mono uppercase transition-colors hover:bg-accent-soft hover:text-ink ${isActive(item.href) ? 'bg-accent-soft text-ink' : 'text-muted-strong'}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
<a
|
||||
href="https://github.com/akkolli"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="GitHub profile"
|
||||
className="block px-3.5 py-2.5 text-[0.68rem] font-mono uppercase text-muted-strong transition-colors hover:bg-accent-soft hover:text-ink"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
|
||||
const STORAGE_KEY = "theme-preference";
|
||||
const THEME_CHANGE_EVENT = "theme-preference-change";
|
||||
|
||||
function getSystemTheme(): Theme {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
window.localStorage.setItem(STORAGE_KEY, theme);
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, theme);
|
||||
} catch {
|
||||
}
|
||||
window.dispatchEvent(new Event(THEME_CHANGE_EVENT));
|
||||
}
|
||||
|
||||
function getCurrentTheme(): Theme {
|
||||
if (typeof window === "undefined") return "light";
|
||||
|
||||
const currentTheme = document.documentElement.dataset.theme;
|
||||
if (currentTheme === "light" || currentTheme === "dark") {
|
||||
return currentTheme;
|
||||
}
|
||||
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
return getSystemTheme();
|
||||
}
|
||||
|
||||
function subscribeToTheme(callback: () => void) {
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handleSystemThemeChange = () => {
|
||||
try {
|
||||
if (!window.localStorage.getItem(STORAGE_KEY)) {
|
||||
document.documentElement.dataset.theme = getSystemTheme();
|
||||
}
|
||||
} catch {
|
||||
document.documentElement.dataset.theme = getSystemTheme();
|
||||
}
|
||||
|
||||
callback();
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener("change", handleSystemThemeChange);
|
||||
window.addEventListener("storage", callback);
|
||||
window.addEventListener(THEME_CHANGE_EVENT, callback);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener("change", handleSystemThemeChange);
|
||||
window.removeEventListener("storage", callback);
|
||||
window.removeEventListener(THEME_CHANGE_EVENT, callback);
|
||||
};
|
||||
}
|
||||
|
||||
export function ThemeToggle() {
|
||||
const theme = useSyncExternalStore(subscribeToTheme, getCurrentTheme, () => "light");
|
||||
|
||||
const toggleTheme = () => {
|
||||
const nextTheme = getCurrentTheme() === "dark" ? "light" : "dark";
|
||||
applyTheme(nextTheme);
|
||||
@@ -29,8 +69,9 @@ export function ThemeToggle() {
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
title="Toggle color theme"
|
||||
aria-label="Toggle color theme"
|
||||
className="theme-toggle inline-flex h-8 w-8 items-center justify-center rounded-full border border-transparent text-muted-strong transition-colors hover:border-line hover:text-ink"
|
||||
aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} theme`}
|
||||
aria-pressed={theme === "dark"}
|
||||
className="theme-toggle inline-flex h-8 w-8 items-center justify-center rounded-full border border-transparent text-muted-strong transition-colors hover:border-line hover:bg-accent-soft hover:text-ink"
|
||||
>
|
||||
<span className="sr-only">Toggle color theme</span>
|
||||
<svg
|
||||
|
||||
@@ -8,16 +8,16 @@ export function MobileTableOfContents({ headings }: { headings: Heading[] }) {
|
||||
if (headings.length === 0) return null;
|
||||
|
||||
return (
|
||||
<details className="mb-8 border-y border-line py-4">
|
||||
<summary className="eyebrow cursor-pointer list-none">
|
||||
Contents
|
||||
<details className="group mb-8 border-y border-line py-4">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-4">
|
||||
<span className="eyebrow">Contents</span>
|
||||
<span aria-hidden className="text-base leading-none text-muted-strong transition-transform group-open:rotate-45">
|
||||
+
|
||||
</span>
|
||||
</summary>
|
||||
<ul className="mt-4 space-y-2">
|
||||
{headings.map((heading) => (
|
||||
<li
|
||||
key={heading.id}
|
||||
style={{ paddingLeft: `${(heading.level - 2) * 12}px` }}
|
||||
>
|
||||
<li key={heading.id} className={heading.level > 2 ? 'pl-4' : undefined}>
|
||||
<a
|
||||
href={`#${heading.id}`}
|
||||
className="block text-[0.94rem] leading-7 text-muted transition-colors hover:text-ink"
|
||||
|
||||
@@ -9,46 +9,30 @@ type Heading = {
|
||||
};
|
||||
|
||||
export function TableOfContents({ headings }: { headings: Heading[] }) {
|
||||
const [activeId, setActiveId] = useState<string>('');
|
||||
const [activeId, setActiveId] = useState<string>(() => headings[0]?.id ?? '');
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const headingElements = headings.map((heading) => ({
|
||||
id: heading.id,
|
||||
element: document.getElementById(heading.id),
|
||||
}));
|
||||
const elements = headings
|
||||
.map((heading) => document.getElementById(heading.id))
|
||||
.filter((element): element is HTMLElement => Boolean(element));
|
||||
|
||||
// Find the first heading that is currently visible or just above the fold
|
||||
// We look for headings that are above the 150px mark
|
||||
let currentActiveId = '';
|
||||
if (elements.length === 0) return;
|
||||
|
||||
for (const { id, element } of headingElements) {
|
||||
if (!element) continue;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
const visible = entries
|
||||
.filter((entry) => entry.isIntersecting)
|
||||
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
|
||||
|
||||
// If the heading is within the top portion of the screen
|
||||
// OR if we haven't found a better one yet, this one 'might' be it
|
||||
// We basically want the *last* heading that has a 'top' value <= some threshold
|
||||
if (rect.top <= 150) {
|
||||
currentActiveId = id;
|
||||
}
|
||||
if (visible[0]?.target.id) {
|
||||
setActiveId(visible[0].target.id);
|
||||
}
|
||||
}, {
|
||||
rootMargin: '-20% 0px -65% 0px',
|
||||
threshold: 0,
|
||||
});
|
||||
|
||||
// If we are at the very bottom, it's likely the last item
|
||||
if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight - 50) {
|
||||
if (headings.length > 0) currentActiveId = headings[headings.length - 1].id;
|
||||
}
|
||||
|
||||
if (currentActiveId) {
|
||||
setActiveId(currentActiveId);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
// Trigger once on mount
|
||||
handleScroll();
|
||||
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
elements.forEach((element) => observer.observe(element));
|
||||
return () => observer.disconnect();
|
||||
}, [headings]);
|
||||
|
||||
if (headings.length === 0) return null;
|
||||
@@ -58,10 +42,7 @@ export function TableOfContents({ headings }: { headings: Heading[] }) {
|
||||
<h4 className="eyebrow mb-4">Contents</h4>
|
||||
<ul className="space-y-2">
|
||||
{Array.isArray(headings) && headings.map((heading) => (
|
||||
<li
|
||||
key={heading.id}
|
||||
style={{ paddingLeft: `${(heading.level - 2) * 10}px` }}
|
||||
>
|
||||
<li key={heading.id} className={heading.level > 2 ? 'pl-3' : undefined}>
|
||||
<a
|
||||
href={`#${heading.id}`}
|
||||
className={`block text-[0.82rem] leading-6 transition-colors ${activeId === heading.id
|
||||
|
||||
Reference in New Issue
Block a user