Add tooltips to GitHub sync status

This commit is contained in:
Sam Becker 2025-02-01 22:53:33 -06:00
parent 1ae97f1ee1
commit 112a6c1442
14 changed files with 292 additions and 69 deletions

View File

@ -14,6 +14,7 @@
"@aws-sdk/s3-request-presigner": "3.740.0",
"@radix-ui/react-dialog": "^1.1.5",
"@radix-ui/react-dropdown-menu": "^2.1.5",
"@radix-ui/react-tooltip": "^1.1.7",
"@radix-ui/react-visually-hidden": "^1.1.1",
"@upstash/ratelimit": "^2.0.5",
"@vercel/analytics": "^1.4.1",

36
pnpm-lock.yaml generated
View File

@ -23,6 +23,9 @@ importers:
'@radix-ui/react-dropdown-menu':
specifier: ^2.1.5
version: 2.1.5(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-tooltip':
specifier: ^1.1.7
version: 1.1.7(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-visually-hidden':
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@ -1190,6 +1193,19 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-tooltip@1.1.7':
resolution: {integrity: sha512-ss0s80BC0+g0+Zc53MvilcnTYSOi4mSuFWBPYPuTOFGjx+pUU+ZrmamMNwS56t8MTFlniA5ocjd4jYm/CdhbOg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-use-callback-ref@1.1.0':
resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==}
peerDependencies:
@ -5860,6 +5876,26 @@ snapshots:
optionalDependencies:
'@types/react': 19.0.8
'@radix-ui/react-tooltip@1.1.7(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/primitive': 1.1.1
'@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.8)(react@19.0.0)
'@radix-ui/react-context': 1.1.1(@types/react@19.0.8)(react@19.0.0)
'@radix-ui/react-dismissable-layer': 1.1.4(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-id': 1.1.0(@types/react@19.0.8)(react@19.0.0)
'@radix-ui/react-popper': 1.2.1(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-portal': 1.1.3(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-presence': 1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-primitive': 2.0.1(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-slot': 1.1.1(@types/react@19.0.8)(react@19.0.0)
'@radix-ui/react-use-controllable-state': 1.1.0(@types/react@19.0.8)(react@19.0.0)
'@radix-ui/react-visually-hidden': 1.1.1(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
optionalDependencies:
'@types/react': 19.0.8
'@types/react-dom': 19.0.3(@types/react@19.0.8)
'@radix-ui/react-use-callback-ref@1.1.0(@types/react@19.0.8)(react@19.0.0)':
dependencies:
react: 19.0.0

View File

@ -5,7 +5,10 @@ import { IS_DEVELOPMENT } from '@/site/config';
export default function GitHubForkStatusBadge() {
return IS_DEVELOPMENT
? <GitHubForkStatusBadgeClient label="Local" />
? <GitHubForkStatusBadgeClient
label="Local"
tooltip="GitHub status unknown when running locally."
/>
: <Suspense>
<GitHubForkStatusBadgeServer />
</Suspense>;

View File

@ -1,18 +1,17 @@
import Spinner from '@/components/Spinner';
import Tooltip from '@/components/Tooltip';
import clsx from 'clsx/lite';
import Link from 'next/link';
import { ReactNode } from 'react';
import { BiLogoGithub } from 'react-icons/bi';
export default function GitHubForkStatusBadgeClient({
url,
label,
style = 'mono',
title,
tooltip,
}: {
url?: string
label?: string
label?: ReactNode
style?: 'success' | 'warning' | 'mono'
title?: string
tooltip?: ReactNode
}) {
const classNameForStyle = () => {
switch (style) {
@ -29,47 +28,32 @@ export default function GitHubForkStatusBadgeClient({
'border-amber-300/25 dark:border-amber-900',
);
default: return clsx(
'text-gray-700 hover:text-gray-700',
'dark:text-gray-300 dark:hover:text-gray-300',
'text-main',
'bg-white dark:bg-transparent',
'border-main',
);
}
};
const className = clsx(
'opacity-0 transition-opacity animate-fade-in',
'inline-flex items-center gap-2',
'border transition-colors',
url ? 'hover:underline' : 'select-none',
'pl-[4.5px] pr-2.5 py-[3px]',
'rounded-full shadow-sm',
classNameForStyle(),
return (
<Tooltip content={tooltip}>
<div className={clsx(
'opacity-0 transition-opacity animate-fade-in',
'inline-flex items-center gap-2',
'border transition-colors',
'select-none',
'pl-[4.5px] pr-2.5 py-[3px]',
'rounded-full shadow-sm',
classNameForStyle(),
)}>
{!label
? <Spinner
color="text"
className="translate-x-[3px]"
/>
: <BiLogoGithub size={17} />}
{label ?? 'Checking'}
</div>
</Tooltip>
);
const content = <>
{!label
? <Spinner
color="text"
className="translate-x-[3px]"
/>
: <BiLogoGithub size={17} />}
{label ?? 'Checking'}
</>;
return url
? <Link
target="_blank"
href={url}
title={title}
className={className}
>
{content}
</Link>
: <span
title={title}
className={className}
>
{content}
</span>;
}

View File

@ -3,6 +3,7 @@ import {
VERCEL_GIT_BRANCH,
VERCEL_GIT_REPO_OWNER,
VERCEL_GIT_REPO_SLUG,
VERCEL_GIT_COMMIT_SHA,
} from '@/site/config';
import { getGitHubMetaWithFallback } from '.';
@ -10,21 +11,36 @@ export default async function GitHubForkStatusBadgeServer() {
const owner = VERCEL_GIT_REPO_OWNER;
const repo = VERCEL_GIT_REPO_SLUG;
const branch = VERCEL_GIT_BRANCH;
const commit = VERCEL_GIT_COMMIT_SHA;
const {
url,
isForkedFromBase,
isBaseRepo,
label,
title,
isBehind,
} = await getGitHubMetaWithFallback({ owner, repo, branch });
label,
description,
} = await getGitHubMetaWithFallback({ owner, repo, branch, commit });
return isForkedFromBase || isBaseRepo
? <GitHubForkStatusBadgeClient {...{
url,
label,
title,
tooltip: <>
{description}
{isBehind && <>
{' '}
<a
href="https://github.com/sambecker/exif-photo-blog"
target="_blank"
className="underline hover:no-underline hover:text-main"
>
Sync on GitHub
</a>
{' '}
for latest updates.
</>}
</>,
style: isBehind === undefined || isBehind ? 'warning' : 'mono',
}} />
: null;

View File

@ -18,11 +18,12 @@ interface RepoParams {
owner?: string
repo?: string
branch?: string
commit?: string
};
// Website urls
const getGitHubRepoUrl = ({
export const getGitHubRepoUrl = ({
owner = TEMPLATE_BASE_OWNER,
repo = TEMPLATE_BASE_REPO,
}: RepoParams = {}) =>
@ -45,19 +46,22 @@ const getGitHubApiRepoUrl = ({
`https://api.github.com/repos/${owner}/${repo}`;
const getGitHubApiCommitsUrl = (params?: RepoParams) =>
`${getGitHubApiRepoUrl(params)}/commits/main`;
`${getGitHubApiRepoUrl(params)}/commits/${params?.branch || DEFAULT_BRANCH}`;
const getGitHubApiForksUrl = (params?: RepoParams) =>
`${getGitHubApiRepoUrl(params)}/forks`;
const getGitHubApiCompareUrl = ({
const getGitHubApiCompareToRepoUrl = ({
owner,
repo,
branch = 'main',
branch = DEFAULT_BRANCH,
}: RepoParams = {}) =>
// eslint-disable-next-line max-len
`${getGitHubApiRepoUrl()}/compare/${TEMPLATE_BASE_BRANCH}...${owner}:${repo}:${branch}`;
const getGitHubApiCompareToCommitUrl = ({ commit }: RepoParams = {}) =>
`${getGitHubApiRepoUrl()}/compare/${TEMPLATE_BASE_BRANCH}...${commit}`;
// Requests
export const getLatestBaseRepoCommitSha = async () => {
@ -75,13 +79,25 @@ const getIsRepoForkedFromBase = async (params: RepoParams) => {
);
};
const getGitHubCommitsBehind = async (params?: RepoParams) => {
const response = await fetch(getGitHubApiCompareUrl(params), FETCH_CONFIG);
const getGitHubCommitsBehindFromRepo = async (params?: RepoParams) => {
const response = await fetch(
getGitHubApiCompareToRepoUrl(params),
FETCH_CONFIG,
);
const data = await response.json();
return data.behind_by as number;
};
const isRepoBaseRepo = async ({ owner, repo }: RepoParams) =>
const getGitHubCommitsBehindFromCommit = async (params?: RepoParams) => {
const response = await fetch(
getGitHubApiCompareToCommitUrl(params),
FETCH_CONFIG,
);
const data = await response.json();
return data.behind_by as number;
};
const isRepoBaseRepo = ({ owner, repo }: RepoParams) =>
owner?.toLowerCase() === TEMPLATE_BASE_OWNER &&
repo?.toLowerCase() === TEMPLATE_BASE_REPO;
@ -97,16 +113,22 @@ export const getGitHubPublicFork = async (
};
const getGitHubMeta = async (params: RepoParams) => {
const url = getGitHubRepoUrl(params);
const isBaseRepo = isRepoBaseRepo(params);
console.log(getGitHubApiCompareToCommitUrl({
...params,
commit: 'e3745e24e8c54e35aee1f54b66d1ee710e7803e0',
}));
const [
url,
isForkedFromBase,
isBaseRepo,
behindBy,
] = await Promise.all([
getGitHubRepoUrl(params),
getIsRepoForkedFromBase(params),
isRepoBaseRepo(params),
getGitHubCommitsBehind(params),
isBaseRepo && params.commit
? getGitHubCommitsBehindFromCommit(params)
: getGitHubCommitsBehindFromRepo(params),
]);
const isBehind = behindBy === undefined
@ -119,11 +141,10 @@ const getGitHubMeta = async (params: RepoParams) => {
? `${behindBy} Behind`
: 'Synced';
const title = isBehind === undefined
const description = isBehind === undefined
? FALLBACK_TEXT
: isBehind
// eslint-disable-next-line max-len
? `This fork is ${behindBy} commit${behindBy === 1 ? '' : 's'} behind. Consider syncing on GitHub for the latest updates.`
? `This fork is ${behindBy} commit${behindBy === 1 ? '' : 's'} behind.`
: 'This fork is up to date.';
return {
@ -133,7 +154,7 @@ const getGitHubMeta = async (params: RepoParams) => {
behindBy,
isBehind,
label,
title,
description,
};
};
@ -148,6 +169,6 @@ export const getGitHubMetaWithFallback = (params: RepoParams) =>
behindBy: undefined,
isBehind: undefined,
label: FALLBACK_TEXT,
title: FALLBACK_TEXT,
description: FALLBACK_TEXT,
};
});

View File

@ -0,0 +1,18 @@
import { ReactNode } from 'react';
import TooltipPrimitive from './primitives/TooltipPrimitive';
export default function Tooltip({
children,
content,
className,
}: {
children: ReactNode
content?: ReactNode
className?: string
}) {
return (
<TooltipPrimitive {...{ content, className }} >
{children}
</TooltipPrimitive>
);
}

View File

@ -39,8 +39,8 @@ export default function MoreMenu({
'z-10',
'min-w-[8rem]',
'ml-2.5',
'p-1 rounded-md border',
'bg-content',
'component-surface',
'p-1',
'shadow-lg dark:shadow-xl',
className,
)}

View File

@ -0,0 +1,28 @@
import { ReactNode, RefObject } from 'react';
import clsx from 'clsx/lite';
export default function MenuSurface({
ref,
children,
className,
}: {
ref?: RefObject<HTMLDivElement | null>
children: ReactNode
className?: string
}) {
return (
<div
ref={ref}
className={clsx(
'component-surface',
'px-2 pt-1.5 pb-2 max-w-[14rem]',
'shadow-sm',
'text-[0.8rem] leading-tight',
'text-balance text-center',
className,
)}
>
{children}
</div>
);
}

View File

@ -0,0 +1,67 @@
'use client';
import { ReactNode, useRef, useState } from 'react';
import * as Tooltip from '@radix-ui/react-tooltip';
import MenuSurface from './MenuSurface';
import useSupportsHover from '@/utility/useSupportsHover';
import clsx from 'clsx/lite';
import useClickInsideOutside from '@/utility/useClickInsideOutside';
export default function TooltipPrimitive({
content,
children,
className,
}: {
content?: ReactNode
children: ReactNode
className?: string
}) {
const refTrigger = useRef<HTMLButtonElement>(null);
const refContent = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
const supportsHover = useSupportsHover();
useClickInsideOutside({
htmlElements: [refTrigger, refContent],
onClickOutside: () => {
console.log('onClickOutside');
setIsOpen(false);
},
});
return (
<Tooltip.Provider delayDuration={100}>
<Tooltip.Root open={!supportsHover ? isOpen : undefined}>
<Tooltip.Trigger asChild>
<button
ref={refTrigger}
onClick={!supportsHover ? () => setIsOpen(!isOpen) : undefined}
className="link cursor-default"
>
{children}
</button>
</Tooltip.Trigger>
<Tooltip.Portal >
<Tooltip.Content
ref={refContent}
sideOffset={10}
className={clsx(
// Entrance animations
'data-[side=top]:animate-fade-in-from-bottom',
'data-[side=bottom]:animate-fade-in-from-top',
// Extra collision padding
'mx-2',
)}
>
{content &&
<MenuSurface className={className}>
{content}
</MenuSurface>}
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
};

View File

@ -121,6 +121,12 @@
hover:text-gray-600
hover:dark:text-gray-400
}
/* Components */
.component-surface {
@apply
bg-content border border-main
rounded-lg
}
/* Utilities: Text */
.text-main {
@apply

View File

@ -1,10 +1,10 @@
import { useCallback, useEffect } from 'react';
import { RefObject, useCallback, useEffect } from 'react';
const MOUSE_DOWN = 'mousedown';
interface Options {
// HTML reference
htmlElements: (HTMLElement | null)[],
htmlElements: RefObject<HTMLElement | null>[],
// Callbacks based on click target
onClick?: (event?: MouseEvent) => void,
onClickInside?: (event?: MouseEvent) => void,
@ -24,7 +24,7 @@ const useClickInsideOutside = ({
const target = event.target as HTMLElement;
const htmlElementsContainTarget = htmlElements
.some(element => element?.contains(target));
.some(element => element.current?.contains(target));
// On click
onClick?.(event);

View File

@ -0,0 +1,19 @@
import { useState, useEffect } from 'react';
export default function useSupportsHover() {
const [supportsHover, setSupportsHover] = useState(true);
useEffect(() => {
const mql = window.matchMedia('(hover: hover)');
setSupportsHover(mql.matches);
const listener = (e: MediaQueryListEvent) => {
setSupportsHover(e.matches);
};
mql.addEventListener('change', listener);
return () => mql.removeEventListener('change', listener);
}, []);
return supportsHover;
};

View File

@ -30,6 +30,10 @@ module.exports = {
'rotate-pulse 0.75s linear infinite normal both running',
'fade-in':
'fade-in 0.5s linear both running',
'fade-in-from-top':
'fade-in-from-top 0.25s ease-in-out',
'fade-in-from-bottom':
'fade-in-from-bottom 0.25s ease-in-out',
'hover-drift':
'hover-drift 8s linear infinite',
'hover-wobble':
@ -40,6 +44,26 @@ module.exports = {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
'fade-in-from-top': {
'0%': {
opacity: '0',
transform: 'translateY(-10px)',
},
'100%': {
opacity: '1',
transform: 'translateY(0)',
},
},
'fade-in-from-bottom': {
'0%': {
opacity: '0',
transform: 'translateY(10px)',
},
'100%': {
opacity: '1',
transform: 'translateY(0)',
},
},
'rotate-pulse': {
'0%': { transform: 'rotate(0deg) scale(1)' },
'50%': { transform: 'rotate(180deg) scale(0.8)' },