First commit

This commit is contained in:
2026-02-14 21:47:08 +01:00
parent 323ab95e47
commit 8dc205d54a
65 changed files with 10411 additions and 660 deletions

194
hooks/use-toast.ts Normal file
View File

@@ -0,0 +1,194 @@
"use client"
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

131
hooks/useScrollAnimation.ts Normal file
View File

@@ -0,0 +1,131 @@
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
interface UseScrollAnimationOptions {
threshold?: number;
rootMargin?: string;
triggerOnce?: boolean;
}
export function useScrollAnimation<T extends HTMLElement = HTMLDivElement>(
options: UseScrollAnimationOptions = {},
) {
const { threshold = 0.1, rootMargin = "0px", triggerOnce = true } = options;
const ref = useRef<T>(null);
const [isVisible, setIsVisible] = useState(false);
const [hasTriggered, setHasTriggered] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return; // extra safety
const element = ref.current;
if (!element) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
if (triggerOnce) {
setHasTriggered(true);
observer.unobserve(element);
}
} else if (!triggerOnce) {
setIsVisible(false);
}
},
{ threshold, rootMargin },
);
observer.observe(element);
return () => observer.disconnect();
}, [threshold, rootMargin, triggerOnce]);
const reset = useCallback(() => {
setIsVisible(false);
setHasTriggered(false);
}, []);
return {
ref,
isVisible: triggerOnce ? isVisible || hasTriggered : isVisible,
reset,
};
}
export function useCountUp(
end: number,
duration: number = 2000,
start: number = 0,
) {
const [count, setCount] = useState(start);
const [isAnimating, setIsAnimating] = useState(false);
const countRef = useRef(start);
const startAnimation = useCallback(() => {
if (isAnimating) return;
setIsAnimating(true);
const startTime = Date.now();
const range = end - start;
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
const easeOut = 1 - Math.pow(1 - progress, 3);
const currentCount = Math.floor(start + range * easeOut);
countRef.current = currentCount;
setCount(currentCount);
if (progress < 1) {
requestAnimationFrame(animate);
} else {
setCount(end);
setIsAnimating(false);
}
};
requestAnimationFrame(animate);
}, [end, duration, start, isAnimating]);
const reset = useCallback(() => {
setCount(start);
setIsAnimating(false);
}, [start]);
return { count, startAnimation, reset, isAnimating };
}
export function useParallax(speed: number = 0.5) {
const ref = useRef<HTMLDivElement>(null);
const [offset, setOffset] = useState(0);
useEffect(() => {
if (typeof window === "undefined") return;
const handleScroll = () => {
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
const scrolled = window.scrollY;
const elementTop = rect.top + scrolled;
const relativeScroll = scrolled - elementTop + window.innerHeight;
setOffset(relativeScroll * speed * 0.1);
};
window.addEventListener("scroll", handleScroll, { passive: true });
handleScroll();
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, [speed]);
return { ref, offset };
}

22
hooks/useTheme.ts Normal file
View File

@@ -0,0 +1,22 @@
"use client";
import { useState } from "react";
import { useTheme as useNextTheme } from "next-themes";
export function useTheme() {
const { theme, resolvedTheme, setTheme } = useNextTheme();
const [mounted] = useState(true);
const currentTheme = resolvedTheme ?? theme ?? "light";
const toggleTheme = () => {
setTheme(currentTheme === "dark" ? "light" : "dark");
};
return {
theme: currentTheme,
setTheme,
toggleTheme,
mounted,
};
}