diff --git a/package.json b/package.json index 22fdb116..5d5c9f85 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bcecec0f..c92c693f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/admin/github/GitHubForkStatusBadge.tsx b/src/admin/github/GitHubForkStatusBadge.tsx index e9cd6263..0af8dbbb 100644 --- a/src/admin/github/GitHubForkStatusBadge.tsx +++ b/src/admin/github/GitHubForkStatusBadge.tsx @@ -5,7 +5,10 @@ import { IS_DEVELOPMENT } from '@/site/config'; export default function GitHubForkStatusBadge() { return IS_DEVELOPMENT - ? + ? : ; diff --git a/src/admin/github/GitHubForkStatusBadgeClient.tsx b/src/admin/github/GitHubForkStatusBadgeClient.tsx index eaf5323a..86eca35a 100644 --- a/src/admin/github/GitHubForkStatusBadgeClient.tsx +++ b/src/admin/github/GitHubForkStatusBadgeClient.tsx @@ -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 ( + + + {!label + ? + : } + {label ?? 'Checking'} + + ); - - const content = <> - {!label - ? - : } - {label ?? 'Checking'} - >; - - return url - ? - {content} - - : - {content} - ; } diff --git a/src/admin/github/GitHubForkStatusBadgeServer.tsx b/src/admin/github/GitHubForkStatusBadgeServer.tsx index 34677866..e6eb4a9c 100644 --- a/src/admin/github/GitHubForkStatusBadgeServer.tsx +++ b/src/admin/github/GitHubForkStatusBadgeServer.tsx @@ -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 ? + {description} + {isBehind && <> + {' '} + + Sync on GitHub + + {' '} + for latest updates. + >} + >, style: isBehind === undefined || isBehind ? 'warning' : 'mono', }} /> : null; diff --git a/src/admin/github/index.ts b/src/admin/github/index.ts index 129f352c..46f14a31 100644 --- a/src/admin/github/index.ts +++ b/src/admin/github/index.ts @@ -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, }; }); diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx new file mode 100644 index 00000000..f5802980 --- /dev/null +++ b/src/components/Tooltip.tsx @@ -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 ( + + {children} + + ); +} diff --git a/src/components/more/MoreMenu.tsx b/src/components/more/MoreMenu.tsx index dd919069..c7dbf0d2 100644 --- a/src/components/more/MoreMenu.tsx +++ b/src/components/more/MoreMenu.tsx @@ -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, )} diff --git a/src/components/primitives/MenuSurface.tsx b/src/components/primitives/MenuSurface.tsx new file mode 100644 index 00000000..21937bdc --- /dev/null +++ b/src/components/primitives/MenuSurface.tsx @@ -0,0 +1,28 @@ +import { ReactNode, RefObject } from 'react'; +import clsx from 'clsx/lite'; + +export default function MenuSurface({ + ref, + children, + className, +}: { + ref?: RefObject + children: ReactNode + className?: string +}) { + return ( + + {children} + + ); +} diff --git a/src/components/primitives/TooltipPrimitive.tsx b/src/components/primitives/TooltipPrimitive.tsx new file mode 100644 index 00000000..015dc601 --- /dev/null +++ b/src/components/primitives/TooltipPrimitive.tsx @@ -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(null); + const refContent = useRef(null); + + const [isOpen, setIsOpen] = useState(false); + + const supportsHover = useSupportsHover(); + + useClickInsideOutside({ + htmlElements: [refTrigger, refContent], + onClickOutside: () => { + console.log('onClickOutside'); + setIsOpen(false); + }, + }); + + return ( + + + + setIsOpen(!isOpen) : undefined} + className="link cursor-default" + > + {children} + + + + + {content && + + {content} + } + + + + + ); +}; diff --git a/src/site/globals.css b/src/site/globals.css index a949e023..46d056e0 100644 --- a/src/site/globals.css +++ b/src/site/globals.css @@ -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 diff --git a/src/utility/useClickInsideOutside.ts b/src/utility/useClickInsideOutside.ts index 26aedda5..d0728add 100644 --- a/src/utility/useClickInsideOutside.ts +++ b/src/utility/useClickInsideOutside.ts @@ -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[], // 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); diff --git a/src/utility/useSupportsHover.ts b/src/utility/useSupportsHover.ts new file mode 100644 index 00000000..d2388d3b --- /dev/null +++ b/src/utility/useSupportsHover.ts @@ -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; +}; diff --git a/tailwind.config.js b/tailwind.config.js index 889d2572..3b9baa57 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -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)' },