| import * as React from "react"; | ||
| type UseBooleanReturn = { | ||
| value: boolean; | ||
| setValue: React.Dispatch<React.SetStateAction<boolean>>; | ||
| setTrue: () => void; | ||
| setFalse: () => void; | ||
| toggle: () => void; | ||
| }; | ||
| export function useBoolean(defaultValue = false): UseBooleanReturn { | ||
| if (typeof defaultValue !== "boolean") { | ||
| throw new Error("defaultValue must be `true` or `false`"); | ||
| } | ||
| const [value, setValue] = React.useState(defaultValue); | ||
| const setTrue = React.useCallback(() => { | ||
| setValue(true); | ||
| }, []); | ||
| const setFalse = React.useCallback(() => { | ||
| setValue(false); | ||
| }, []); | ||
| const toggle = React.useCallback(() => { | ||
| setValue((x) => !x); | ||
| }, []); | ||
| return { value, setValue, setTrue, setFalse, toggle }; | ||
| } |
| import * as React from "react"; | ||
| type CopyFn = (text: string) => Promise<boolean>; | ||
| export function useCopyToClipboard(delay = 2000): [CopyFn, boolean] { | ||
| const [isCopied, setIsCopied] = React.useState(false); | ||
| React.useEffect(() => { | ||
| if (!isCopied) return; | ||
| const timer = setTimeout(() => { | ||
| setIsCopied(false); | ||
| }, delay); | ||
| return () => clearTimeout(timer); | ||
| }, [isCopied, delay]); | ||
| const copy: CopyFn = React.useCallback(async (text) => { | ||
| if (!navigator?.clipboard) { | ||
| console.warn("Clipboard not supported"); | ||
| return false; | ||
| } | ||
| try { | ||
| await navigator.clipboard.writeText(text); | ||
| setIsCopied(true); | ||
| return true; | ||
| } catch (error) { | ||
| console.warn("Copy failed", error); | ||
| return false; | ||
| } | ||
| }, []); | ||
| return [copy, isCopied]; | ||
| } |
| import * as React from "react"; | ||
| export function useDocumentTitle(title: string) { | ||
| React.useEffect(() => { | ||
| document.title = title; | ||
| }, [title]); | ||
| } |
| import * as React from "react"; | ||
| export function useInterval(callback: () => void, delay: number | null) { | ||
| const savedCallback = React.useRef(callback); | ||
| React.useEffect(() => { | ||
| savedCallback.current = callback; | ||
| }, [callback]); | ||
| React.useEffect(() => { | ||
| if (delay === null || typeof delay !== "number") return; | ||
| const tick = () => savedCallback.current(); | ||
| const id = setInterval(tick, delay); | ||
| return () => clearInterval(id); | ||
| }, [delay]); | ||
| } |
| import * as React from "react"; | ||
| export const useIsomorphicLayoutEffect = | ||
| typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect; |
| import * as React from "react"; | ||
| export function useUnmount(func: () => void) { | ||
| const funcRef = React.useRef(func); | ||
| funcRef.current = func; | ||
| React.useEffect( | ||
| () => () => { | ||
| funcRef.current(); | ||
| }, | ||
| [] | ||
| ); | ||
| } |
| import * as React from "react"; | ||
| type UseCounterReturn = { | ||
| count: number; | ||
| increment: () => void; | ||
| decrement: () => void; | ||
| reset: () => void; | ||
| setCount: React.Dispatch<React.SetStateAction<number>>; | ||
| }; | ||
| export function useCounter(initialValue?: number): UseCounterReturn { | ||
| const [count, setCount] = React.useState(initialValue ?? 0); | ||
| const increment = React.useCallback(() => { | ||
| setCount((x) => x + 1); | ||
| }, []); | ||
| const decrement = React.useCallback(() => { | ||
| setCount((x) => x - 1); | ||
| }, []); | ||
| const reset = React.useCallback(() => { | ||
| setCount(initialValue ?? 0); | ||
| }, [initialValue]); | ||
| return { | ||
| count, | ||
| increment, | ||
| decrement, | ||
| reset, | ||
| setCount, | ||
| }; | ||
| } |
| import * as React from "react"; | ||
| export type Position = { | ||
| x: number; | ||
| y: number; | ||
| elementX?: number; | ||
| elementY?: number; | ||
| elementPositionX?: number; | ||
| elementPositionY?: number; | ||
| }; | ||
| export function useMousePosition<T extends HTMLElement>(): [Position, React.Ref<T>] { | ||
| const [state, setState] = React.useState<Position>({ | ||
| x: 0, | ||
| y: 0, | ||
| }); | ||
| const ref = React.useRef<T>(null); | ||
| React.useLayoutEffect(() => { | ||
| const handleMouseMove = (event: MouseEvent) => { | ||
| const newState: Position = { | ||
| x: event.pageX, | ||
| y: event.pageY, | ||
| }; | ||
| if (ref.current?.nodeType === Node.ELEMENT_NODE) { | ||
| const { left, top } = ref.current.getBoundingClientRect(); | ||
| const elementPositionX = left + window.scrollX; | ||
| const elementPositionY = top + window.scrollY; | ||
| const elementX = event.pageX - elementPositionX; | ||
| const elementY = event.pageY - elementPositionY; | ||
| newState.elementPositionX = elementPositionX; | ||
| newState.elementPositionY = elementPositionY; | ||
| newState.elementX = elementX; | ||
| newState.elementY = elementY; | ||
| } | ||
| setState((s) => ({ | ||
| ...s, | ||
| ...newState, | ||
| })); | ||
| }; | ||
| document.addEventListener("mousemove", handleMouseMove); | ||
| return () => document.removeEventListener("mousemove", handleMouseMove); | ||
| }, []); | ||
| return [state, ref]; | ||
| } |
+30
-112
| #!/usr/bin/env node | ||
| import { Command } from "commander"; | ||
| import fs from "fs/promises"; | ||
@@ -7,125 +6,44 @@ import path from "path"; | ||
| const program = new Command(); | ||
| const __dirname = path.dirname(fileURLToPath(import.meta.url)); | ||
| const __filename = fileURLToPath(import.meta.url); | ||
| const __dirname = path.dirname(__filename); | ||
| const toKebab = (name) => | ||
| name | ||
| .replace(/([a-z0-9])([A-Z])/g, "$1-$2") | ||
| .replace(/[\s_]+/g, "-") | ||
| .toLowerCase(); | ||
| async function copyDir(src, dest) { | ||
| await fs.mkdir(dest, { recursive: true }); | ||
| const entries = await fs.readdir(src, { withFileTypes: true }); | ||
| async function ensureDir(dir) { | ||
| await fs.mkdir(dir, { recursive: true }); | ||
| } | ||
| for (const entry of entries) { | ||
| const srcPath = path.join(src, entry.name); | ||
| const destPath = path.join(dest, entry.name); | ||
| async function fileExists(p) { | ||
| try { | ||
| await fs.access(p); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| if (entry.isDirectory()) { | ||
| await copyDir(srcPath, destPath); | ||
| } else { | ||
| await fs.copyFile(srcPath, destPath); | ||
| } | ||
| } | ||
| } | ||
| async function readTemplate(hookSlug) { | ||
| const base = path.join(__dirname, "../templates", hookSlug); | ||
| const ts = `${base}.ts`; | ||
| const tsx = `${base}.tsx`; | ||
| if (await fileExists(ts)) return { path: ts, ext: ".ts" }; | ||
| if (await fileExists(tsx)) return { path: tsx, ext: ".tsx" }; | ||
| throw new Error(`Template not found for "${hookSlug}" in /templates`); | ||
| } | ||
| async function main() { | ||
| try { | ||
| const projectRoot = process.cwd(); | ||
| async function writeHook({ hookSlug, outDir, force }) { | ||
| const tpl = await readTemplate(hookSlug); | ||
| const code = await fs.readFile(tpl.path, "utf8"); | ||
| // default to src/hooks if src exists, else hooks at root | ||
| const srcDir = path.join(projectRoot, "src"); | ||
| const hasSrc = await fs | ||
| .access(srcDir) | ||
| .then(() => true) | ||
| .catch(() => false); | ||
| await ensureDir(outDir); | ||
| const outPath = path.join(outDir, `${hookSlug}${tpl.ext}`); | ||
| const dest = path.join(projectRoot, hasSrc ? "src/hooks" : "hooks"); | ||
| const templatesDir = path.join(__dirname, "../templates"); | ||
| if (!force && (await fileExists(outPath))) { | ||
| throw new Error( | ||
| `File exists: ${path.relative( | ||
| process.cwd(), | ||
| outPath | ||
| )} (use --force to overwrite)` | ||
| ); | ||
| } | ||
| await fs.writeFile(outPath, code, "utf8"); | ||
| return outPath; | ||
| } | ||
| await copyDir(templatesDir, dest); | ||
| async function updateBarrel(outDir, hookSlug) { | ||
| const barrel = path.join(outDir, "index.ts"); | ||
| let content = ""; | ||
| try { | ||
| content = await fs.readFile(barrel, "utf8"); | ||
| } catch { } | ||
| const line = `export * from "./${hookSlug}";\n`; | ||
| if (!content.includes(line)) { | ||
| await fs.writeFile(barrel, content + line, "utf8"); | ||
| return true; | ||
| console.log(`✅ Hooks copied to ${path.relative(projectRoot, dest)}`); | ||
| } catch (err) { | ||
| console.error("❌ Error copying hooks:", err.message); | ||
| process.exit(1); | ||
| } | ||
| return false; | ||
| } | ||
| // 🔹 Smart default: use src/hooks if src/ exists, else hooks/ | ||
| async function getDefaultHooksDir() { | ||
| const root = process.cwd(); | ||
| const src = path.join(root, "src"); | ||
| if (await fileExists(src)) return "src/hooks"; | ||
| return "hooks"; | ||
| } | ||
| program | ||
| .name("hookbase") | ||
| .description("Install React hooks into your project’s /hooks folder") | ||
| .version("0.1.0"); | ||
| program | ||
| .command("add <hook>") | ||
| .description("Add a hook by name (must exist in this package’s templates)") | ||
| .option("-d, --dir <path>", "output directory") // no default here | ||
| .option("-f, --force", "overwrite if file exists", false) | ||
| .option("--no-barrel", "do not update hooks/index.ts") | ||
| .action(async (hook, opts) => { | ||
| try { | ||
| const hookSlug = toKebab(hook); | ||
| const defaultDir = opts.dir || (await getDefaultHooksDir()); | ||
| const outDir = path.resolve(process.cwd(), defaultDir); | ||
| const outPath = await writeHook({ | ||
| hookSlug, | ||
| outDir, | ||
| force: opts.force, | ||
| }); | ||
| console.log( | ||
| `✅ Added ${hookSlug} → ${path.relative(process.cwd(), outPath)}` | ||
| ); | ||
| if (opts.barrel) { | ||
| const changed = await updateBarrel(outDir, hookSlug); | ||
| if (changed) console.log("📦 Updated hooks/index.ts"); | ||
| } | ||
| } catch (err) { | ||
| console.error(`❌ ${err.message}`); | ||
| process.exitCode = 1; | ||
| } | ||
| }); | ||
| program | ||
| .command("list") | ||
| .description("List available templates in this package") | ||
| .action(async () => { | ||
| const tdir = path.join(__dirname, "../templates"); | ||
| const files = (await fs.readdir(tdir)).filter( | ||
| (f) => f.endsWith(".ts") || f.endsWith(".tsx") | ||
| ); | ||
| if (files.length === 0) return console.log("No templates found."); | ||
| files | ||
| .map((f) => f.replace(/\.tsx?$/, "")) | ||
| .sort() | ||
| .forEach((name) => console.log(`- ${name}`)); | ||
| }); | ||
| program.parse(); | ||
| main(); |
+3
-4
| { | ||
| "name": "hookbase", | ||
| "version": "0.1.5", | ||
| "version": "0.1.7", | ||
| "type": "module", | ||
@@ -13,5 +13,4 @@ "bin": { | ||
| ], | ||
| "dependencies": { | ||
| "commander": "^12.0.0" | ||
| }, | ||
| "preferGlobal": true, | ||
| "dependencies": {}, | ||
| "devDependencies": { | ||
@@ -18,0 +17,0 @@ "typescript": "^5.8.0", |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
7038
62.92%0
-100%12
200%214
72.58%1
Infinity%- Removed
- Removed