From 069792a0a4b2d7181faf4c75c654807fb5ce1824 Mon Sep 17 00:00:00 2001 From: Jack Tench <79285604+JackTench@users.noreply.github.com> Date: Mon, 3 Nov 2025 12:27:09 +0000 Subject: [PATCH 1/4] [reui] Install GitHub button --- components.json | 4 +- package-lock.json | 69 +++++++ package.json | 1 + src/components/ui/github-button.tsx | 296 ++++++++++++++++++++++++++++ 4 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 src/components/ui/github-button.tsx diff --git a/components.json b/components.json index 2b0833f..2d677db 100644 --- a/components.json +++ b/components.json @@ -18,5 +18,7 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "registries": {} + "registries": { + "@reui": "https://reui.io/r/{name}.json" + } } diff --git a/package-lock.json b/package-lock.json index f925f16..fca63d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "mdast-util-from-markdown": "^2.0.2", "mdast-util-gfm": "^3.1.0", "micromark-extension-gfm": "^3.0.0", + "motion": "^12.23.24", "next-themes": "^0.4.6", "react": "^19.1.1", "react-code-block": "^1.1.3", @@ -2458,6 +2459,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/framer-motion": { + "version": "12.23.24", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz", + "integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.23.23", + "motion-utils": "^12.23.6", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3585,6 +3613,47 @@ ], "license": "MIT" }, + "node_modules/motion": { + "version": "12.23.24", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.23.24.tgz", + "integrity": "sha512-Rc5E7oe2YZ72N//S3QXGzbnXgqNrTESv8KKxABR20q2FLch9gHLo0JLyYo2hZ238bZ9Gx6cWhj9VO0IgwbMjCw==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.23.24", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.23.23", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz", + "integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.23.6" + } + }, + "node_modules/motion-utils": { + "version": "12.23.6", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/package.json b/package.json index 80ac85a..f4010b0 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "mdast-util-from-markdown": "^2.0.2", "mdast-util-gfm": "^3.1.0", "micromark-extension-gfm": "^3.0.0", + "motion": "^12.23.24", "next-themes": "^0.4.6", "react": "^19.1.1", "react-code-block": "^1.1.3", diff --git a/src/components/ui/github-button.tsx b/src/components/ui/github-button.tsx new file mode 100644 index 0000000..040f4c2 --- /dev/null +++ b/src/components/ui/github-button.tsx @@ -0,0 +1,296 @@ +'use client'; + +import React, { useCallback, useEffect, useState } from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { Star } from 'lucide-react'; +import { motion, useInView, type SpringOptions, type UseInViewOptions } from 'motion/react'; +import { cn } from '@/lib/utils'; + +const githubButtonVariants = cva( + 'cursor-pointer relative overflow-hidden will-change-transform backface-visibility-hidden transform-gpu transition-transform duration-200 ease-out hover:scale-105 group whitespace-nowrap focus-visible:outline-hidden inline-flex items-center justify-center whitespace-nowrap font-medium ring-offset-background disabled:pointer-events-none disabled:opacity-60 [&_svg]:shrink-0', + { + variants: { + variant: { + default: + 'bg-zinc-950 hover:bg-zinc-900 text-white border-gray-700 dark:bg-zinc-50 dark:border-gray-300 dark:text-zinc-950 dark:hover:bg-zinc-50', + outline: 'bg-background text-accent-foreground border border-input hover:bg-accent', + }, + size: { + default: 'h-8.5 rounded-md px-3 gap-2 text-[0.8125rem] leading-none [&_svg]:size-4 gap-2', + sm: 'h-7 rounded-md px-2.5 gap-1.5 text-xs leading-none [&_svg]:size-3.5 gap-1.5', + lg: 'h-10 rounded-md px-4 gap-2.5 text-sm leading-none [&_svg]:size-5 gap-2.5', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +); + +interface GithubButtonProps extends React.ComponentProps<'button'>, VariantProps { + /** Whether to round stars */ + roundStars?: boolean; + /** Whether to show Github icon */ + fixedWidth?: boolean; + /** Initial number of stars */ + initialStars?: number; + /** Class for stars */ + starsClass?: string; + /** Target number of stars to animate to */ + targetStars?: number; + /** Animation duration in seconds */ + animationDuration?: number; + /** Animation delay in seconds */ + animationDelay?: number; + /** Whether to start animation automatically */ + autoAnimate?: boolean; + /** Callback when animation completes */ + onAnimationComplete?: () => void; + /** Whether to show Github icon */ + showGithubIcon?: boolean; + /** Whether to show star icon */ + showStarIcon?: boolean; + /** Whether to show separator */ + separator?: boolean; + /** Whether stars should be filled */ + filled?: boolean; + /** Repository URL for actual Github integration */ + repoUrl?: string; + /** Button text label */ + label?: string; + /** Use in-view detection to trigger animation */ + useInViewTrigger?: boolean; + /** In-view options */ + inViewOptions?: UseInViewOptions; + /** Spring transition options */ + transition?: SpringOptions; +} + +function GithubButton({ + initialStars = 0, + targetStars = 0, + starsClass = '', + fixedWidth = true, + animationDuration = 2, + animationDelay = 0, + autoAnimate = true, + className, + variant = 'default', + size = 'default', + showGithubIcon = true, + showStarIcon = true, + roundStars = false, + separator = false, + filled = false, + repoUrl, + onClick, + label = '', + useInViewTrigger = false, + inViewOptions = { once: true }, + transition, + ...props +}: GithubButtonProps) { + const [currentStars, setCurrentStars] = useState(initialStars); + const [isAnimating, setIsAnimating] = useState(false); + const [starProgress, setStarProgress] = useState(filled ? 100 : 0); + const [hasAnimated, setHasAnimated] = useState(false); + + // Format number with units + const formatNumber = (num: number) => { + const units = ['k', 'M', 'B', 'T']; + + if (roundStars && num >= 1000) { + let unitIndex = -1; + let value = num; + + while (value >= 1000 && unitIndex < units.length - 1) { + value /= 1000; + unitIndex++; + } + + // Format to 1 decimal place if needed, otherwise show whole number + const formatted = value % 1 === 0 ? value.toString() : value.toFixed(1); + return `${formatted}${units[unitIndex]}`; + } + + return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); + }; + + // Start animation + const startAnimation = useCallback(() => { + if (isAnimating || hasAnimated) return; + + setIsAnimating(true); + const startTime = Date.now(); + const startValue = 0; // Always start from 0 for number animation + const endValue = targetStars; + const duration = animationDuration * 1000; + + const animate = () => { + const elapsed = Date.now() - startTime; + const progress = Math.min(elapsed / duration, 1); + + // Easing function for smooth animation + const easeOutQuart = 1 - Math.pow(1 - progress, 4); + + // Update star count from 0 to target with more frequent updates + const newStars = Math.round(startValue + (endValue - startValue) * easeOutQuart); + setCurrentStars(newStars); + + // Update star fill progress (0 to 100) + setStarProgress(progress * 100); + + if (progress < 1) { + requestAnimationFrame(animate); + } else { + setCurrentStars(endValue); + setStarProgress(100); + setIsAnimating(false); + setHasAnimated(true); + } + }; + + setTimeout(() => { + requestAnimationFrame(animate); + }, animationDelay * 1000); + }, [isAnimating, hasAnimated, targetStars, animationDuration, animationDelay]); + + // Use in-view detection if enabled + const ref = React.useRef(null); + const isInView = useInView(ref, inViewOptions); + + // Reset animation state when targetStars changes + useEffect(() => { + setHasAnimated(false); + setCurrentStars(initialStars); + }, [targetStars, initialStars]); + + // Auto-start animation or use in-view trigger + useEffect(() => { + if (useInViewTrigger) { + if (isInView && !hasAnimated) { + startAnimation(); + } + } else if (autoAnimate && !hasAnimated) { + startAnimation(); + } + }, [autoAnimate, useInViewTrigger, isInView, hasAnimated, startAnimation]); + + const navigateToRepo = () => { + if (!repoUrl) { + return; + } + + // Next.js compatible navigation approach + try { + // Create a temporary anchor element for reliable navigation + const link = document.createElement('a'); + link.href = repoUrl; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + + // Temporarily add to DOM and click + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } catch { + // Fallback to window.open + try { + window.open(repoUrl, '_blank', 'noopener,noreferrer'); + } catch { + // Final fallback + window.location.href = repoUrl; + } + } + }; + + const handleClick = (event: React.MouseEvent) => { + if (onClick) { + onClick(event); + return; + } + + if (repoUrl) { + navigateToRepo(); + } else if (!hasAnimated) { + startAnimation(); + } + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + // Handle Enter and Space key presses for accessibility + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + + if (repoUrl) { + navigateToRepo(); + } else if (!hasAnimated) { + startAnimation(); + } + } + }; + + return ( + + ); +} + +export { GithubButton, githubButtonVariants }; +export type { GithubButtonProps }; From e36eb11effd7030d4d4f998f51bac9774f4a63d4 Mon Sep 17 00:00:00 2001 From: Jack Tench <79285604+JackTench@users.noreply.github.com> Date: Mon, 3 Nov 2025 12:29:00 +0000 Subject: [PATCH 2/4] Create AppGitHubButton component --- src/components/libresplit/AppGitHubButton.tsx | 5 +++++ src/components/libresplit/AppNav.tsx | 9 ++------- 2 files changed, 7 insertions(+), 7 deletions(-) create mode 100644 src/components/libresplit/AppGitHubButton.tsx diff --git a/src/components/libresplit/AppGitHubButton.tsx b/src/components/libresplit/AppGitHubButton.tsx new file mode 100644 index 0000000..ad0e0a4 --- /dev/null +++ b/src/components/libresplit/AppGitHubButton.tsx @@ -0,0 +1,5 @@ +import { GithubButton } from "../ui/github-button"; + +export function AppGitHubButton() { + return ; +} diff --git a/src/components/libresplit/AppNav.tsx b/src/components/libresplit/AppNav.tsx index d52c815..fee0ea7 100644 --- a/src/components/libresplit/AppNav.tsx +++ b/src/components/libresplit/AppNav.tsx @@ -1,12 +1,11 @@ -import { Button } from "../ui/button"; import { NavigationMenu, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, } from "../ui/navigation-menu"; +import { AppGitHubButton } from "./AppGitHubButton"; import { AppThemeToggleButton } from "./AppThemeToggleButton"; -import { Github } from "lucide-react"; import { Link } from "react-router"; export function AppNav() { @@ -57,11 +56,7 @@ function LeftNav() { function RightNav() { return (
- +
); From 52ad6ed3ef639100c8f1ac0e011e920018a72bd0 Mon Sep 17 00:00:00 2001 From: Jack Tench <79285604+JackTench@users.noreply.github.com> Date: Mon, 3 Nov 2025 12:44:24 +0000 Subject: [PATCH 3/4] Redesign GitHub button --- src/components/libresplit/AppGitHubButton.tsx | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/src/components/libresplit/AppGitHubButton.tsx b/src/components/libresplit/AppGitHubButton.tsx index ad0e0a4..29e02b6 100644 --- a/src/components/libresplit/AppGitHubButton.tsx +++ b/src/components/libresplit/AppGitHubButton.tsx @@ -1,5 +1,61 @@ +import { useEffect, useState } from "react"; + +import { Button } from "../ui/button"; import { GithubButton } from "../ui/github-button"; +import { AppLoading } from "./AppLoading"; +import { Github } from "lucide-react"; export function AppGitHubButton() { - return ; + const [stars, setStars] = useState(null); + const [error, setError] = useState(null); + + // Fetch repo data from GitHub API. + useEffect(() => { + async function fetchStars() { + try { + const response = await fetch( + "https://api.github.com/repos/LibreSplit/LibreSplit", + { + headers: { Accept: "application/vnd.github+json" }, + }, + ); + + if (!response.ok) + throw new Error(`GitHub API error: ${response.status}`); + + const data = await response.json(); + setStars(data.stargazers_count); + } catch (err) { + setError((err as Error).message); + } + } + + fetchStars(); + }, []); + + // Show whilst API request loads. + if (stars == null) { + return ; + } + + // Show in the case of an error. + if (error) { + return ( + + ); + } + + // Show full component. + return ( + + ); } From 98959ba818909b50ffd9492285c6aa3108a3be6a Mon Sep 17 00:00:00 2001 From: Jack Tench <79285604+JackTench@users.noreply.github.com> Date: Mon, 3 Nov 2025 12:45:54 +0000 Subject: [PATCH 4/4] Format reui JSX with prettier --- src/components/ui/github-button.tsx | 105 ++++++++++++++++++---------- 1 file changed, 69 insertions(+), 36 deletions(-) diff --git a/src/components/ui/github-button.tsx b/src/components/ui/github-button.tsx index 040f4c2..c7ef5ec 100644 --- a/src/components/ui/github-button.tsx +++ b/src/components/ui/github-button.tsx @@ -1,34 +1,44 @@ -'use client'; +"use client"; -import React, { useCallback, useEffect, useState } from 'react'; -import { cva, type VariantProps } from 'class-variance-authority'; -import { Star } from 'lucide-react'; -import { motion, useInView, type SpringOptions, type UseInViewOptions } from 'motion/react'; -import { cn } from '@/lib/utils'; +import React, { useCallback, useEffect, useState } from "react"; + +import { cn } from "@/lib/utils"; +import { type VariantProps, cva } from "class-variance-authority"; +import { Star } from "lucide-react"; +import { + type SpringOptions, + type UseInViewOptions, + motion, + useInView, +} from "motion/react"; const githubButtonVariants = cva( - 'cursor-pointer relative overflow-hidden will-change-transform backface-visibility-hidden transform-gpu transition-transform duration-200 ease-out hover:scale-105 group whitespace-nowrap focus-visible:outline-hidden inline-flex items-center justify-center whitespace-nowrap font-medium ring-offset-background disabled:pointer-events-none disabled:opacity-60 [&_svg]:shrink-0', + "cursor-pointer relative overflow-hidden will-change-transform backface-visibility-hidden transform-gpu transition-transform duration-200 ease-out hover:scale-105 group whitespace-nowrap focus-visible:outline-hidden inline-flex items-center justify-center whitespace-nowrap font-medium ring-offset-background disabled:pointer-events-none disabled:opacity-60 [&_svg]:shrink-0", { variants: { variant: { default: - 'bg-zinc-950 hover:bg-zinc-900 text-white border-gray-700 dark:bg-zinc-50 dark:border-gray-300 dark:text-zinc-950 dark:hover:bg-zinc-50', - outline: 'bg-background text-accent-foreground border border-input hover:bg-accent', + "bg-zinc-950 hover:bg-zinc-900 text-white border-gray-700 dark:bg-zinc-50 dark:border-gray-300 dark:text-zinc-950 dark:hover:bg-zinc-50", + outline: + "bg-background text-accent-foreground border border-input hover:bg-accent", }, size: { - default: 'h-8.5 rounded-md px-3 gap-2 text-[0.8125rem] leading-none [&_svg]:size-4 gap-2', - sm: 'h-7 rounded-md px-2.5 gap-1.5 text-xs leading-none [&_svg]:size-3.5 gap-1.5', - lg: 'h-10 rounded-md px-4 gap-2.5 text-sm leading-none [&_svg]:size-5 gap-2.5', + default: + "h-8.5 rounded-md px-3 gap-2 text-[0.8125rem] leading-none [&_svg]:size-4 gap-2", + sm: "h-7 rounded-md px-2.5 gap-1.5 text-xs leading-none [&_svg]:size-3.5 gap-1.5", + lg: "h-10 rounded-md px-4 gap-2.5 text-sm leading-none [&_svg]:size-5 gap-2.5", }, }, defaultVariants: { - variant: 'default', - size: 'default', + variant: "default", + size: "default", }, }, ); -interface GithubButtonProps extends React.ComponentProps<'button'>, VariantProps { +interface GithubButtonProps + extends React.ComponentProps<"button">, + VariantProps { /** Whether to round stars */ roundStars?: boolean; /** Whether to show Github icon */ @@ -70,14 +80,14 @@ interface GithubButtonProps extends React.ComponentProps<'button'>, VariantProps function GithubButton({ initialStars = 0, targetStars = 0, - starsClass = '', + starsClass = "", fixedWidth = true, animationDuration = 2, animationDelay = 0, autoAnimate = true, className, - variant = 'default', - size = 'default', + variant = "default", + size = "default", showGithubIcon = true, showStarIcon = true, roundStars = false, @@ -85,7 +95,7 @@ function GithubButton({ filled = false, repoUrl, onClick, - label = '', + label = "", useInViewTrigger = false, inViewOptions = { once: true }, transition, @@ -98,7 +108,7 @@ function GithubButton({ // Format number with units const formatNumber = (num: number) => { - const units = ['k', 'M', 'B', 'T']; + const units = ["k", "M", "B", "T"]; if (roundStars && num >= 1000) { let unitIndex = -1; @@ -114,7 +124,7 @@ function GithubButton({ return `${formatted}${units[unitIndex]}`; } - return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); + return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); }; // Start animation @@ -135,7 +145,9 @@ function GithubButton({ const easeOutQuart = 1 - Math.pow(1 - progress, 4); // Update star count from 0 to target with more frequent updates - const newStars = Math.round(startValue + (endValue - startValue) * easeOutQuart); + const newStars = Math.round( + startValue + (endValue - startValue) * easeOutQuart, + ); setCurrentStars(newStars); // Update star fill progress (0 to 100) @@ -154,7 +166,13 @@ function GithubButton({ setTimeout(() => { requestAnimationFrame(animate); }, animationDelay * 1000); - }, [isAnimating, hasAnimated, targetStars, animationDuration, animationDelay]); + }, [ + isAnimating, + hasAnimated, + targetStars, + animationDuration, + animationDelay, + ]); // Use in-view detection if enabled const ref = React.useRef(null); @@ -185,10 +203,10 @@ function GithubButton({ // Next.js compatible navigation approach try { // Create a temporary anchor element for reliable navigation - const link = document.createElement('a'); + const link = document.createElement("a"); link.href = repoUrl; - link.target = '_blank'; - link.rel = 'noopener noreferrer'; + link.target = "_blank"; + link.rel = "noopener noreferrer"; // Temporarily add to DOM and click document.body.appendChild(link); @@ -197,7 +215,7 @@ function GithubButton({ } catch { // Fallback to window.open try { - window.open(repoUrl, '_blank', 'noopener,noreferrer'); + window.open(repoUrl, "_blank", "noopener,noreferrer"); } catch { // Final fallback window.location.href = repoUrl; @@ -220,7 +238,7 @@ function GithubButton({ const handleKeyDown = (event: React.KeyboardEvent) => { // Handle Enter and Space key presses for accessibility - if (event.key === 'Enter' || event.key === ' ') { + if (event.key === "Enter" || event.key === " ") { event.preventDefault(); if (repoUrl) { @@ -234,7 +252,10 @@ function GithubButton({ return ( );