From 4c541c27fd21f11cf99c020eba0643e5ff2d5f1d Mon Sep 17 00:00:00 2001 From: Himanshu Gupta Date: Mon, 17 Aug 2026 19:18:40 +0530 Subject: [PATCH 1/3] Improve project discovery and hero motion --- packages/all-things-youtube/README.md | 6 +- web/app/_components/flickering-pixel-text.tsx | 229 ++++++++++++++++++ web/app/_directions/craft-nav.tsx | 13 +- web/app/_directions/craft.tsx | 7 +- web/app/craft.css | 58 +++++ 5 files changed, 309 insertions(+), 4 deletions(-) create mode 100644 web/app/_components/flickering-pixel-text.tsx diff --git a/packages/all-things-youtube/README.md b/packages/all-things-youtube/README.md index cd8872a..7e3431b 100644 --- a/packages/all-things-youtube/README.md +++ b/packages/all-things-youtube/README.md @@ -11,7 +11,7 @@ Search YouTube and get transcripts/captions, comments, video details, channels, [![API key](https://img.shields.io/badge/API%20key-not%20required-2ea44f)](#scope-and-stability) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/devhims/video2ctx/blob/main/packages/all-things-youtube/LICENSE) -[Quick start](#quick-start) · [API](#api-at-a-glance) · [Pagination](#pagination) · [Reliability](#retries-and-rate-limits) · [Hosting](#using-the-library-vs-hosting-an-api) +[Website](https://video2ctx.dev/) · [Quick start](#quick-start) · [API](#api-at-a-glance) · [Pagination](#pagination) · [Reliability](#retries-and-rate-limits) · [Hosting](#using-the-library-vs-hosting-an-api) @@ -595,6 +595,10 @@ Live tests are best treated as integration checks: upstream availability, locali - Public data only; private videos, account gates, and regional restrictions are not bypassed. - No YouTube Data API key or OAuth setup is required. +## Support the project + +`all-things-youtube` is open source and independently maintained as part of [video2ctx](https://video2ctx.dev/). If it brings value to your work, [starring the repository](https://github.com/devhims/video2ctx) is a simple way to support its continued development. + ## Disclaimer This project is not affiliated with, endorsed by, or sponsored by YouTube or Google. YouTube is a trademark of Google LLC. Use the package in accordance with the policies and laws that apply to your project. diff --git a/web/app/_components/flickering-pixel-text.tsx b/web/app/_components/flickering-pixel-text.tsx new file mode 100644 index 0000000..8757028 --- /dev/null +++ b/web/app/_components/flickering-pixel-text.tsx @@ -0,0 +1,229 @@ +'use client'; + +import { useEffect, useRef } from 'react'; + +interface FlickeringPixelTextProps { + children: string; + className?: string; + color?: string; + flickerChance?: number; + gridGap?: number; + maxOpacity?: number; + minOpacity?: number; + squareSize?: number; +} + +interface GridState { + cols: number; + dpr: number; + height: number; + mask: HTMLCanvasElement; + opacities: Float32Array; + rows: number; + width: number; +} + +/* Magic UI's Flickering Grid behavior, adapted to a text-sized canvas. Each + * cell changes opacity independently; a text mask keeps the grid inside the + * glyphs while the real word remains accessible underneath. */ +export function FlickeringPixelText({ + children, + className, + color, + flickerChance = 0.65, + gridGap = 0, + maxOpacity = 1, + minOpacity = 0.72, + squareSize = 2, +}: FlickeringPixelTextProps) { + const containerRef = useRef(null); + const canvasRef = useRef(null); + + useEffect(() => { + const container = containerRef.current; + const canvas = canvasRef.current; + const context = canvas?.getContext('2d'); + if (!container || !canvas || !context) return; + + const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); + let animationFrame = 0; + let disposed = false; + let grid: GridState | null = null; + let inView = true; + let lastTime = 0; + let resolvedColor = color ?? window.getComputedStyle(container).color; + + const drawTextMask = ( + maskContext: CanvasRenderingContext2D, + width: number, + height: number, + dpr: number, + ) => { + const style = window.getComputedStyle(container); + const letterSpacing = Number.parseFloat(style.letterSpacing) || 0; + const characters = Array.from(children); + + maskContext.setTransform(dpr, 0, 0, dpr, 0, 0); + maskContext.clearRect(0, 0, width, height); + maskContext.fillStyle = '#fff'; + maskContext.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`; + maskContext.textBaseline = 'alphabetic'; + + const characterWidths = characters.map( + (character) => maskContext.measureText(character).width, + ); + const textWidth = + characterWidths.reduce((total, value) => total + value, 0) + + Math.max(0, characters.length - 1) * letterSpacing; + const textMetrics = maskContext.measureText(children); + const baseline = + (height + + textMetrics.actualBoundingBoxAscent - + textMetrics.actualBoundingBoxDescent) / + 2; + let x = (width - textWidth) / 2; + + characters.forEach((character, index) => { + maskContext.fillText(character, x, baseline); + x += characterWidths[index] + letterSpacing; + }); + }; + + const setup = () => { + if (disposed) return; + const bounds = container.getBoundingClientRect(); + const width = Math.max(1, Math.ceil(bounds.width)); + const height = Math.max(1, Math.ceil(bounds.height)); + const dpr = window.devicePixelRatio || 1; + const cols = Math.ceil(width / (squareSize + gridGap)); + const rows = Math.ceil(height / (squareSize + gridGap)); + const opacities = new Float32Array(cols * rows); + const mask = document.createElement('canvas'); + const opacityRange = Math.max(0, maxOpacity - minOpacity); + + resolvedColor = color ?? window.getComputedStyle(container).color; + + canvas.width = Math.ceil(width * dpr); + canvas.height = Math.ceil(height * dpr); + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + mask.width = canvas.width; + mask.height = canvas.height; + + for (let index = 0; index < opacities.length; index += 1) { + opacities[index] = minOpacity + Math.random() * opacityRange; + } + + const maskContext = mask.getContext('2d'); + if (!maskContext) return; + drawTextMask(maskContext, width, height, dpr); + grid = { cols, dpr, height, mask, opacities, rows, width }; + }; + + const draw = () => { + if (!grid || disposed) return; + const { cols, dpr, height, mask, opacities, rows, width } = grid; + + context.setTransform(dpr, 0, 0, dpr, 0, 0); + context.clearRect(0, 0, width, height); + context.fillStyle = resolvedColor; + + for (let column = 0; column < cols; column += 1) { + for (let row = 0; row < rows; row += 1) { + const index = column * rows + row; + context.globalAlpha = opacities[index]; + context.fillRect( + column * (squareSize + gridGap), + row * (squareSize + gridGap), + squareSize, + squareSize, + ); + } + } + + context.globalAlpha = 1; + context.globalCompositeOperation = 'destination-in'; + context.drawImage(mask, 0, 0, width, height); + context.globalCompositeOperation = 'source-over'; + container.dataset.flickerReady = 'true'; + }; + + const animate = (time: number) => { + animationFrame = 0; + if (!grid || !inView || reducedMotion.matches) return; + + const deltaTime = lastTime ? Math.min((time - lastTime) / 1000, 0.1) : 0; + const opacityRange = Math.max(0, maxOpacity - minOpacity); + lastTime = time; + for (let index = 0; index < grid.opacities.length; index += 1) { + if (Math.random() < flickerChance * deltaTime) { + grid.opacities[index] = minOpacity + Math.random() * opacityRange; + } + } + draw(); + animationFrame = window.requestAnimationFrame(animate); + }; + + const start = () => { + if (!disposed && !animationFrame && inView && !reducedMotion.matches) { + lastTime = 0; + animationFrame = window.requestAnimationFrame(animate); + } + }; + const stop = () => { + if (animationFrame) window.cancelAnimationFrame(animationFrame); + animationFrame = 0; + }; + + const resizeObserver = new ResizeObserver(() => { + setup(); + draw(); + start(); + }); + const intersectionObserver = new IntersectionObserver(([entry]) => { + inView = entry.isIntersecting; + if (inView) start(); + else stop(); + }); + const handleMotionPreference = () => { + if (reducedMotion.matches) { + stop(); + delete container.dataset.flickerReady; + context.clearRect(0, 0, canvas.width, canvas.height); + } else { + draw(); + start(); + } + }; + + resizeObserver.observe(container); + intersectionObserver.observe(container); + reducedMotion.addEventListener('change', handleMotionPreference); + void document.fonts.ready.then(() => { + if (disposed) return; + setup(); + draw(); + start(); + }); + + return () => { + disposed = true; + delete container.dataset.flickerReady; + stop(); + resizeObserver.disconnect(); + intersectionObserver.disconnect(); + reducedMotion.removeEventListener('change', handleMotionPreference); + }; + }, [children, color, flickerChance, gridGap, maxOpacity, minOpacity, squareSize]); + + return ( + + {children} + + ); +} diff --git a/web/app/_directions/craft-nav.tsx b/web/app/_directions/craft-nav.tsx index 689d1b8..8308e1a 100644 --- a/web/app/_directions/craft-nav.tsx +++ b/web/app/_directions/craft-nav.tsx @@ -1,6 +1,6 @@ 'use client'; -import { List, X } from '@phosphor-icons/react'; +import { GithubLogo, List, X } from '@phosphor-icons/react'; import { useEffect, useState } from 'react'; import { authClient } from '../../lib/auth-client'; @@ -8,7 +8,6 @@ const LINKS = [ { label: 'Docs', href: 'https://docs.video2ctx.dev' }, { label: 'Pricing', href: '#pricing' }, { label: 'FAQ', href: 'https://api.video2ctx.dev/docs#tag/FAQ' }, - { label: 'GitHub', href: 'https://github.com/devhims/video2ctx' }, { label: 'CLI + Skill', href: '#agent-setup', isNew: true }, ]; @@ -66,6 +65,16 @@ export function CraftNav() { {session?.user ? 'Dashboard' : 'Sign in'} + +