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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
SPOTIFY_REFRESH_TOKEN=

# --- Mapbox -------------------------------------------------------------------
# Public token for `/playlists/map` (Mapbox GL JS). Create at mapbox.com → Access tokens.
NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN=
# Optional: override basemap style (default Standard monochrome). Example:
# NEXT_PUBLIC_MAPBOX_MAP_STYLE=mapbox://styles/mapbox/outdoors-v12

# --- GitHub ------------------------------------------------------------------
# Personal access token or fine-grained token with repo read access.
# Powers /api/github/contributions and pinned repos (incl. star counts) on the site.
Expand Down
54 changes: 43 additions & 11 deletions README.md

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions app/components/hover-link-hint.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ export function linkDestinationLabel(href: string): string {
if (href.startsWith("/projects")) return "Projects";
if (href.startsWith("/about")) return "About";
if (href.startsWith("/misc")) return "Misc";
if (href.startsWith("/playlists/map")) return "Map";
if (/^\/playlists\/[^/]+/.test(href)) return "Tracks";
if (href.startsWith("/playlists")) return "Music for Life";
return "Site";
}

Expand All @@ -17,6 +20,7 @@ export function linkDestinationLabel(href: string): string {

if (host === "github.com") return "GitHub";
if (host === "open.spotify.com" || host === "spotify.com") return "Spotify";
if (host === "mapbox.com") return "Mapbox";
if (host.endsWith("wikipedia.org")) return "Wikipedia";
if (host === "youtube.com" || host === "youtu.be") return "YouTube";
if (host.endsWith("bilibili.com")) return "Bilibili";
Expand All @@ -29,6 +33,7 @@ export function linkDestinationLabel(href: string): string {
if (host === "wisprflow.ai") return "Wispr Flow";
if (host === "berkeleyside.org") return "Berkeleyside";
if (host.endsWith("notion.site")) return "Notion";
if (host === "ginzasonypark.com") return "Ginza Sony Park";

const brand = host.split(".")[0];
return brand.charAt(0).toUpperCase() + brand.slice(1);
Expand Down
109 changes: 79 additions & 30 deletions app/components/hover-tip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,18 @@ function usePrefersHover() {
}

const TIP_BASE_CLASS =
"rounded-sm border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-[#252019] px-2.5 py-1.5 text-center text-[11px] leading-snug text-zinc-600 dark:text-zinc-300 shadow-[3px_3px_0_0_var(--color-border-tertiary)] transition-all duration-150";
"rounded-sm border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-[#252019] px-2.5 py-1.5 text-center text-[11px] leading-snug text-zinc-600 dark:text-zinc-300 shadow-[3px_3px_0_0_var(--color-border-tertiary)]";

function tipVisibilityClass(open: boolean) {
return open
? "opacity-100 translate-y-0 pointer-events-auto"
: "opacity-0 translate-y-0.5 pointer-events-none";
const SHOW_DELAY_MS = 70;

function tipBubbleClass(open: boolean, placement: "top" | "bottom") {
return [
"hover-tip-bubble block w-max max-w-[220px]",
placement === "bottom" ? "hover-tip-bubble--bottom" : "",
open ? "hover-tip-bubble--visible" : "",
]
.filter(Boolean)
.join(" ");
}

function portalFixedStyle(
Expand Down Expand Up @@ -111,9 +117,11 @@ export default function HoverTip({
const triggerRef = useRef<HTMLSpanElement>(null);
const tipRef = useRef<HTMLSpanElement>(null);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const showTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const prefersHover = usePrefersHover();
const [mounted, setMounted] = useState(false);
const [open, setOpen] = useState(false);
const [displayed, setDisplayed] = useState(false);
const [coords, setCoords] = useState<DOMRect | null>(null);
const touchToggle = tapToToggle || interactive;

Expand All @@ -127,19 +135,50 @@ export default function HoverTip({
setCoords(el.getBoundingClientRect());
}, []);

const show = useCallback(() => {
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current);
hideTimerRef.current = null;
}
syncCoords();
setOpen(true);
}, [syncCoords]);
const show = useCallback(
(immediate = false) => {
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current);
hideTimerRef.current = null;
}
syncCoords();
if (displayed && !open) {
setOpen(true);
return;
}
if (open) return;

if (showTimerRef.current) {
clearTimeout(showTimerRef.current);
showTimerRef.current = null;
}
showTimerRef.current = setTimeout(
() => {
setDisplayed(true);
requestAnimationFrame(() => setOpen(true));
},
immediate ? 0 : SHOW_DELAY_MS
);
},
[syncCoords, open, displayed]
);

const hide = useCallback(() => {
if (showTimerRef.current) {
clearTimeout(showTimerRef.current);
showTimerRef.current = null;
}
setOpen(false);
}, []);

const onBubbleTransitionEnd = useCallback(
(e: React.TransitionEvent<HTMLSpanElement>) => {
if (e.propertyName !== "opacity" || open) return;
setDisplayed(false);
},
[open]
);

const scheduleHide = useCallback(() => {
if (!interactive || !prefersHover) {
hide();
Expand All @@ -149,9 +188,9 @@ export default function HoverTip({
}, [hide, interactive, prefersHover]);

const toggle = useCallback(() => {
if (open) hide();
else show();
}, [open, hide, show]);
if (open || displayed) hide();
else show(true);
}, [open, displayed, hide, show]);

const onTriggerClick = useCallback(
(e: React.MouseEvent) => {
Expand Down Expand Up @@ -189,6 +228,7 @@ export default function HoverTip({
useEffect(
() => () => {
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
if (showTimerRef.current) clearTimeout(showTimerRef.current);
},
[]
);
Expand All @@ -203,24 +243,32 @@ export default function HoverTip({
<span
ref={triggerRef}
className={`inline-flex ${className}`}
onMouseEnter={prefersHover ? show : undefined}
onMouseEnter={prefersHover ? () => show() : undefined}
onMouseLeave={prefersHover ? scheduleHide : undefined}
onFocus={show}
onFocus={() => show(true)}
onBlur={prefersHover ? scheduleHide : undefined}
onClick={onTriggerClick}
>
{children}
</span>
{createPortal(
{displayed &&
createPortal(
<span
ref={tipRef}
role="tooltip"
aria-hidden={!open}
style={fixedStyle}
className={`${TIP_BASE_CLASS} w-max max-w-[220px] ${tipVisibilityClass(open)} ${tipClassName}`}
onMouseEnter={interactive && prefersHover ? show : undefined}
className="pointer-events-none"
onMouseEnter={interactive && prefersHover ? () => show(true) : undefined}
onMouseLeave={interactive && prefersHover ? scheduleHide : undefined}
>
{tip}
<span
className={`${TIP_BASE_CLASS} ${tipBubbleClass(open, placement)} ${tipClassName}`}
style={{ fontFamily: "'Nunito'", fontWeight: 400 }}
onTransitionEnd={onBubbleTransitionEnd}
>
{tip}
</span>
</span>,
document.body
)}
Expand All @@ -240,14 +288,15 @@ export default function HoverTip({
return (
<span className={`relative inline-flex group/hover-tip ${className}`}>
{children}
<span
role="tooltip"
className={`absolute ${position} z-40 w-max max-w-[220px] ${TIP_BASE_CLASS} opacity-0 translate-y-0.5 group-hover/hover-tip:opacity-100 group-hover/hover-tip:translate-y-0 group-focus-within/hover-tip:opacity-100 group-focus-within/hover-tip:translate-y-0 ${
interactive ? "pointer-events-auto" : "pointer-events-none"
} ${tipClassName}`}
style={{ fontFamily: "'Nunito'", fontWeight: 400 }}
>
{tip}
<span role="tooltip" className={`absolute ${position} z-40 ${tipClassName}`}>
<span
className={`${TIP_BASE_CLASS} hover-tip-bubble ${
placement === "bottom" ? "hover-tip-bubble--bottom" : ""
} ${interactive ? "group-hover/hover-tip:pointer-events-auto group-focus-within/hover-tip:pointer-events-auto" : ""}`}
style={{ fontFamily: "'Nunito'", fontWeight: 400 }}
>
{tip}
</span>
</span>
</span>
);
Expand Down
6 changes: 5 additions & 1 deletion app/components/listening-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useNowPlaying } from "@/app/hooks/use-now-playing";
import type { CSSProperties } from "react";
import ListeningLastMonthTop from "./listening-last-month-top";
import ListeningTrackRow from "./listening-track-row";
import MagChip from "./mag-chip";

// Spotify's audio-features API was retired (403 for this app), so the live dot
// uses fixed timings instead of per-track BPM/intensity.
Expand Down Expand Up @@ -89,8 +90,11 @@ export default function ListeningCard() {
>
{stateText}
</span>
<div className="ml-auto shrink-0">
<div className="ml-auto shrink-0 flex items-center gap-2">
<ListeningLastMonthTop />
<MagChip href="/playlists" arrow="right" aria-label="music for life playlists">
music for life
</MagChip>
</div>
</div>
</div>
Expand Down
128 changes: 128 additions & 0 deletions app/components/playlist-description.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import {
parsePlaylistDescriptionCredit,
playlistPlaceMapUrl,
splitDescriptionByPlaceLabels,
splitPlaylistJournalLines,
type PlaylistPlaceCoords,
} from "@/lib/spotify-playlists";

const TEXT_CLASS = "text-zinc-400 dark:text-zinc-500";
const CREDIT_CLASS = "text-zinc-400/90 dark:text-zinc-600";

const PLACE_LINK_WRAP =
"group/place inline rounded-sm px-0.5 -mx-0.5 " +
"hover:bg-zinc-50 dark:hover:bg-zinc-800/60 transition-all duration-150 no-underline";

const PLACE_LINK_TEXT =
"underline underline-offset-2 decoration-zinc-300 dark:decoration-zinc-600 " +
"group-hover/place:text-[#C4894F] group-hover/place:decoration-[#C4894F] " +
"dark:group-hover/place:text-[#D9A870] dark:group-hover/place:decoration-[#D9A870] " +
"transition-colors duration-300 ease-in-out";

const CREDIT_LINK_WRAP =
"group/credit inline rounded-sm px-0.5 -mx-0.5 " +
"hover:bg-zinc-50 dark:hover:bg-zinc-800/60 transition-all duration-150 no-underline";

const CREDIT_LINK_TEXT =
"underline underline-offset-2 decoration-zinc-300 dark:decoration-zinc-600 " +
"group-hover/credit:text-[#C4894F] group-hover/credit:decoration-[#C4894F] " +
"dark:group-hover/credit:text-[#D9A870] dark:group-hover/credit:decoration-[#D9A870] " +
"transition-colors duration-300 ease-in-out";

const textStyle = {
fontFamily: "'Nunito'",
fontWeight: 400,
fontSize: 13,
lineHeight: 1.6,
} as const;

type PlaylistDescriptionProps = {
description: string;
placeCoords?: PlaylistPlaceCoords;
/** Set false when rendered inside a parent link (e.g. playlist index cards). */
linkPlaces?: boolean;
linkCredit?: boolean;
};

function PlaceMapLink({ label, coords }: { label: string; coords: { lng: number; lat: number; zoom?: number } }) {
const mapUrl = playlistPlaceMapUrl(coords.lng, coords.lat, label, coords.zoom);
return (
<a href={mapUrl} className={PLACE_LINK_WRAP}>
<span className={PLACE_LINK_TEXT}>{label}</span>
</a>
);
}

function renderPlaceSegments(
placesLine: string,
placeCoords: PlaylistPlaceCoords | undefined,
labels: string[],
linkPlaces: boolean
) {
return splitDescriptionByPlaceLabels(placesLine, labels).map((segment, index) => {
if (segment.kind === "text") {
return <span key={`t-${index}`}>{segment.value}</span>;
}

const coords = placeCoords?.[segment.value];
if (linkPlaces && coords) {
return <PlaceMapLink key={`p-${index}-${segment.value}`} label={segment.value} coords={coords} />;
}

return <span key={`p-${index}-${segment.value}`}>{segment.value}</span>;
});
}

/** Spotify playlist description with optional Mapbox-linked place names. */
export default function PlaylistDescription({
description,
placeCoords,
linkPlaces = true,
linkCredit = true,
}: PlaylistDescriptionProps) {
const { body, credit } = parsePlaylistDescriptionCredit(description);
const labels = placeCoords ? Object.keys(placeCoords) : [];
const journalLines = splitPlaylistJournalLines(body);

return (
<div className="mt-1 pl-4 space-y-1">
{journalLines ? (
<>
<p style={textStyle} className={TEXT_CLASS}>
{journalLines.periodLine}
</p>
<p style={textStyle} className={TEXT_CLASS}>
{renderPlaceSegments(journalLines.placesLine, placeCoords, labels, linkPlaces)}
</p>
</>
) : (
<p style={textStyle} className={TEXT_CLASS}>
{renderPlaceSegments(body, placeCoords, labels, linkPlaces)}
</p>
)}

{credit ? (
<p style={textStyle} className={CREDIT_CLASS}>
(Credit: {credit.prefix}
{linkCredit ? (
<>
{" "}
-{" "}
<a
href={credit.href}
target="_blank"
rel="noopener noreferrer"
className={CREDIT_LINK_WRAP}
>
<span className={CREDIT_LINK_TEXT}>{credit.href}</span>
</a>
</>
) : (
<> - {credit.href}</>
)}
)
</p>
) : null}
</div>
);
}
Loading