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 app/api/ucb-libraries/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
import { getUCBLibraryHours } from "@/lib/ucb-library-hours";

export async function GET() {
const data = await getUCBLibraryHours();
return NextResponse.json(data);
}
178 changes: 178 additions & 0 deletions app/berkeley-libraries/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import type { Metadata } from "next";
import Link from "next/link";
import { getUCBLibraryHours } from "@/lib/ucb-library-hours";
import type { UCBLibrary } from "@/lib/ucb-library-hours";

export const metadata: Metadata = {
title: "UC Berkeley Library Hours",
description:
"Live open/closed status for UC Berkeley libraries, from lib.berkeley.edu (refreshed at most every 15 minutes).",
openGraph: {
title: "UC Berkeley Library Hours · Kai Chen",
description: "Library availability scraped from the official Berkeley hours page.",
url: "https://kaichen.dev/berkeley-libraries",
},
};

function groupByStatus(libraries: UCBLibrary[]) {
const open = libraries.filter((l) => l.status === "Open");
const closed = libraries.filter((l) => l.status === "Closed");
const unknown = libraries.filter((l) => l.status === "Unknown");
return { open, closed, unknown };
}

function LibraryList({
items,
emptyLabel,
tone,
}: {
items: UCBLibrary[];
emptyLabel: string;
tone: "open" | "closed" | "unknown";
}) {
const dot =
tone === "open"
? "🟢"
: tone === "closed"
? "🔴"
: "⚪";

if (items.length === 0) {
return (
<p
style={{ fontFamily: "'Bitter'", fontWeight: 400, fontSize: 15 }}
className="text-zinc-400 dark:text-zinc-600"
>
{emptyLabel}
</p>
);
}

return (
<ul className="space-y-2.5">
{items.map((lib) => (
<li key={lib.name} className="flex flex-col sm:flex-row sm:items-baseline sm:gap-3 gap-0.5">
<span className="shrink-0" aria-hidden>
{dot}
</span>
<div className="min-w-0 flex-1">
<a
href={lib.url}
target="_blank"
rel="noopener noreferrer"
style={{ fontFamily: "'Bitter'", fontWeight: 600, fontSize: 16 }}
className="text-zinc-800 dark:text-zinc-200 underline underline-offset-2 decoration-zinc-300 dark:decoration-zinc-600 hover:text-[#C4894F] dark:hover:text-[#D9A870] transition-colors"
>
{lib.name}
</a>
{lib.hours ? (
<span
style={{ fontFamily: "'Nunito'", fontWeight: 400, fontSize: 14 }}
className="text-zinc-500 dark:text-zinc-500 sm:ml-2 block sm:inline"
>
{lib.hours}
</span>
) : null}
</div>
</li>
))}
</ul>
);
}

export default async function BerkeleyLibrariesPage() {
const result = await getUCBLibraryHours();

return (
<div className="max-w-[1180px] mx-auto px-4 md:px-12 py-16 space-y-10">
<div className="fade-up" style={{ animationDelay: "0ms" }}>
<div className="mb-5">
<Link
href="/"
style={{ fontFamily: "'Nunito'", fontWeight: 400, fontSize: "0.8rem" }}
className="inline-flex items-center gap-1.5 border border-[#C4894F] dark:border-[#D9A870] text-[#C4894F] dark:text-[#D9A870] hover:bg-[#C4894F] dark:hover:bg-[#D9A870] hover:text-white dark:hover:text-zinc-900 rounded-full px-3 py-1 transition-colors duration-150"
>
<span>←</span>
<span>Home</span>
</Link>
</div>
<h1
style={{ fontFamily: "'Nunito'", fontWeight: 300, letterSpacing: "-0.02em", lineHeight: 1.1 }}
className="text-zinc-900 dark:text-zinc-100 text-[32px] md:text-[42px]"
>
UC Berkeley Library Hours
</h1>
<p
style={{ fontFamily: "'Bitter'", fontWeight: 400, fontSize: 16, lineHeight: 1.75 }}
className="text-zinc-600 dark:text-zinc-400 mt-4 max-w-2xl"
>
Parsed from the official{" "}
<a
href="https://www.lib.berkeley.edu/hours"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 decoration-zinc-300 dark:decoration-zinc-600 hover:text-[#C4894F] dark:hover:text-[#D9A870]"
>
library hours
</a>{" "}
page. Data is cached for up to 15 minutes to avoid hammering their servers. Overnight and
special hours may only appear on each library&apos;s detail page — check the official site
when in doubt.
</p>
</div>

{!result.ok ? (
<div
className="mag-card fade-up"
style={{ animationDelay: "40ms" }}
>
<div className="mag-label">Could not load</div>
<p style={{ fontFamily: "'Bitter'", fontWeight: 400, fontSize: 15 }} className="text-zinc-700 dark:text-zinc-300">
{result.error}
</p>
<p
style={{ fontFamily: "'Nunito'", fontWeight: 400, fontSize: 13 }}
className="text-zinc-400 dark:text-zinc-600 mt-2"
>
Last attempt: {result.fetchedAt} (Pacific)
</p>
</div>
) : (
<>
<p
style={{ fontFamily: "'Nunito'", fontWeight: 400, fontSize: 13, animationDelay: "30ms" }}
className="text-zinc-400 dark:text-zinc-600 fade-up"
>
Last updated (Pacific): {result.fetchedAt}
</p>

{(() => {
const { open, closed, unknown } = groupByStatus(result.libraries);
return (
<div className="grid grid-cols-1 gap-6 fade-up" style={{ animationDelay: "60ms" }}>
<div className="mag-card">
<div className="mag-label">Now open ({open.length})</div>
<LibraryList items={open} emptyLabel="None listed as open right now." tone="open" />
</div>

<div className="mag-card">
<div className="mag-label">Now closed ({closed.length})</div>
<LibraryList items={closed} emptyLabel="None listed as closed." tone="closed" />
</div>

<div className="mag-card">
<div className="mag-label">Status unknown ({unknown.length})</div>
<LibraryList
items={unknown}
emptyLabel="No libraries in this category."
tone="unknown"
/>
</div>
</div>
);
})()}
</>
)}
</div>
);
}
63 changes: 0 additions & 63 deletions app/components/nav-wave-overlay.tsx

This file was deleted.

56 changes: 52 additions & 4 deletions app/components/nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,41 @@

import Link from "next/link";
import ThemeToggle from "./theme-toggle";
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
import { createPortal } from "react-dom";

const WAVE_SVG = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='4'%3E%3Cpath d='M0 3 Q5 0 10 3 Q15 6 20 3' stroke='%23C4894F' stroke-width='1.5' fill='none'/%3E%3C/svg%3E")`;

const NAV_LINKS = [
{ href: "/about", label: "About", external: false },
{ href: "/projects", label: "Projects", external: false },
{ href: "/notes", label: "Notes", external: false },
{ href: "https://news.kaichen.dev", label: "News", external: true },
{ href: "https://substack.com/@kaiiiichen", label: "Blog", external: true },
{ href: "https://kaiiiichen.substack.com/", label: "Blog", external: true },
{ href: "/gallery", label: "Gallery", external: false },
];

export default function Nav() {
const [isOpen, setIsOpen] = useState(false);
const navRef = useRef<HTMLElement>(null);
const [waveRect, setWaveRect] = useState<{ left: number; width: number; top: number } | null>(null);
const waveLeaveFrame = useRef(0);

const onWaveEnter = useCallback((e: React.MouseEvent<HTMLElement>) => {
const el = e.currentTarget;
const r = el.getBoundingClientRect();
cancelAnimationFrame(waveLeaveFrame.current);
setWaveRect({ left: r.left, width: r.width, top: r.bottom + 2 });
}, []);

const onWaveLeave = useCallback(() => {
waveLeaveFrame.current = requestAnimationFrame(() => setWaveRect(null));
}, []);

const waveProps = {
onMouseEnter: onWaveEnter,
onMouseLeave: onWaveLeave,
};

useEffect(() => {
if (!isOpen) return;
Expand All @@ -29,6 +50,7 @@ export default function Nav() {
}, [isOpen]);

return (
<>
<nav ref={navRef} className="fixed top-0 left-0 right-0 z-50 border-b border-zinc-200 dark:border-zinc-800 bg-[var(--background)]">
<div className="max-w-[1180px] mx-auto px-4 md:px-8 py-4 flex items-center justify-between">
<Link
Expand All @@ -50,7 +72,8 @@ export default function Nav() {
target="_blank"
rel="noopener noreferrer"
style={{ fontFamily: "'Nunito'", fontWeight: 400 }}
className="nav-wave text-sm text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors duration-150"
className="text-sm text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors duration-150"
{...waveProps}
>
{label}
</a>
Expand All @@ -59,7 +82,8 @@ export default function Nav() {
key={label}
href={href}
style={{ fontFamily: "'Nunito'", fontWeight: 400 }}
className="nav-wave text-sm text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors duration-150"
className="text-sm text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors duration-150"
{...waveProps}
>
{label}
</Link>
Expand Down Expand Up @@ -133,5 +157,29 @@ export default function Nav() {
</div>
</div>
</nav>
{typeof document !== "undefined"
? createPortal(
<div
style={{
position: "fixed",
left: waveRect?.left ?? 0,
top: waveRect?.top ?? 0,
width: waveRect?.width ?? 0,
height: 4,
backgroundImage: WAVE_SVG,
backgroundRepeat: "repeat-x",
backgroundSize: "20px 4px",
opacity: waveRect ? 1 : 0,
transition: "opacity 0.15s ease",
zIndex: 99999,
pointerEvents: "none",
animation: "nav-wave-flow 0.6s linear infinite",
animationPlayState: waveRect ? "running" : "paused",
}}
/>,
document.body
)
: null}
</>
);
}
2 changes: 1 addition & 1 deletion app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ body {
}


/* ── Nav wave underline (rendered via portal, see nav-wave-overlay.tsx) ── */
/* ── Nav wave underline (portal in nav.tsx) ── */
@keyframes nav-wave-flow {
from { background-position-x: 0px; }
to { background-position-x: 20px; }
Expand Down
2 changes: 0 additions & 2 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import "@fontsource/jetbrains-mono/400.css";
import "@fontsource/jetbrains-mono/500.css";
import "./globals.css";
import Nav from "./components/nav";
import NavWaveOverlay from "./components/nav-wave-overlay";
import SubpageEnter from "./components/subpage-enter";
import Providers from "./components/providers";
import { Analytics } from "@vercel/analytics/react";
Expand Down Expand Up @@ -56,7 +55,6 @@ export default function RootLayout({
{`(function(){try{var d=document.documentElement;var t=localStorage.getItem('theme');var dark;if(t==='dark')dark=true;else if(t==='light')dark=false;else dark=false;d.classList.toggle('dark',dark);d.style.colorScheme=dark?'dark':'light';}catch(e){}})();`}
</Script>
<Providers>
<NavWaveOverlay />
<Nav />
<main className="flex-1 pt-16">
<SubpageEnter>{children}</SubpageEnter>
Expand Down
Loading
Loading