Initial commit
Next.js + Express event management app for Hope Family Church.
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
import * as React from "react"
|
||||
import type { ToastActionElement, ToastProps } from "@/components/ui/toast"
|
||||
|
||||
// Based on shadcn/ui toast hook implementation
|
||||
export type Toast = Omit<ToastProps, "id"> & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const TOAST_LIMIT = 5
|
||||
const TOAST_REMOVE_DELAY = 1000
|
||||
|
||||
type State = {
|
||||
toasts: Toast[]
|
||||
}
|
||||
|
||||
type ToastInput = Omit<Toast, "id">
|
||||
|
||||
type Listener = (state: State) => void
|
||||
|
||||
let count = 0
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const state: State = { toasts: [] }
|
||||
const listeners: Listener[] = []
|
||||
|
||||
function setState(newState: Partial<State>) {
|
||||
Object.assign(state, newState)
|
||||
listeners.forEach((l) => l(state))
|
||||
}
|
||||
|
||||
function addToast(toast: ToastInput) {
|
||||
const id = genId()
|
||||
|
||||
const newToast: Toast = {
|
||||
...toast,
|
||||
id,
|
||||
open: true as any,
|
||||
}
|
||||
|
||||
// Ensure limit
|
||||
const nextToasts = [newToast, ...state.toasts].slice(0, TOAST_LIMIT)
|
||||
|
||||
setState({ toasts: nextToasts })
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
function updateToast(id: string, update: Partial<Toast>) {
|
||||
setState({
|
||||
toasts: state.toasts.map((t) => (t.id === id ? { ...t, ...update } : t)),
|
||||
})
|
||||
}
|
||||
|
||||
function dismissToast(id?: string) {
|
||||
if (id) {
|
||||
queueRemoval(id)
|
||||
updateToast(id, { open: false } as any)
|
||||
} else {
|
||||
state.toasts.forEach((t) => {
|
||||
queueRemoval(t.id)
|
||||
updateToast(t.id, { open: false } as any)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function removeToast(id?: string) {
|
||||
if (id) {
|
||||
setState({ toasts: state.toasts.filter((t) => t.id !== id) })
|
||||
} else {
|
||||
setState({ toasts: [] })
|
||||
}
|
||||
}
|
||||
|
||||
function queueRemoval(id: string) {
|
||||
if (toastTimeouts.has(id)) return
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(id)
|
||||
removeToast(id)
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
toastTimeouts.set(id, timeout)
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const [localState, setLocalState] = React.useState<State>(state)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setLocalState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setLocalState)
|
||||
if (index > -1) listeners.splice(index, 1)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
...localState,
|
||||
toast: ({ ...props }: ToastInput) => addToast(props),
|
||||
dismiss: (id?: string) => dismissToast(id),
|
||||
remove: (id?: string) => removeToast(id),
|
||||
}
|
||||
}
|
||||
|
||||
export type { ToastActionElement, ToastProps }
|
||||
@@ -0,0 +1,3 @@
|
||||
"use client";
|
||||
import { useAuthContext } from "@/contexts/AuthContext";
|
||||
export const useAuth = useAuthContext;
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { BrowserQRCodeReader } from "@zxing/browser";
|
||||
|
||||
export type UseQRScannerOptions = {
|
||||
onResult?: (text: string) => void;
|
||||
onError?: (error: unknown) => void;
|
||||
};
|
||||
|
||||
// Hook to decode QR codes from a HTMLVideoElement using @zxing/browser
|
||||
export function useQRScanner(videoEl: HTMLVideoElement | null, opts: UseQRScannerOptions = {}) {
|
||||
const { onResult, onError } = opts;
|
||||
const [active, setActive] = useState(false);
|
||||
const [permissionError, setPermissionError] = useState<string | null>(null);
|
||||
const readerRef = useRef<BrowserQRCodeReader | null>(null);
|
||||
const cancelRef = useRef<(() => void) | undefined>(undefined);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
cancelRef.current?.();
|
||||
cancelRef.current = undefined;
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(() => setActive((p) => !p), []);
|
||||
|
||||
useEffect(() => {
|
||||
readerRef.current = new BrowserQRCodeReader();
|
||||
return () => {
|
||||
stop();
|
||||
readerRef.current = null;
|
||||
};
|
||||
}, [stop]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function run() {
|
||||
setPermissionError(null);
|
||||
try {
|
||||
// Start decode loop
|
||||
const reader = readerRef.current!;
|
||||
const scan = async () => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
if (!videoEl) return;
|
||||
const result = await reader.decodeOnceFromVideoElement(videoEl);
|
||||
const text = (result as any)?.getText ? (result as any).getText() : (result as any)?.text ?? String(result ?? "");
|
||||
if (text) onResult?.(text);
|
||||
} catch (err) {
|
||||
// Ignore transient decode errors and retry
|
||||
} finally {
|
||||
if (!cancelled && active) setTimeout(scan, 200);
|
||||
}
|
||||
};
|
||||
scan();
|
||||
|
||||
cancelRef.current = () => {
|
||||
cancelled = true;
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (err?.name === "NotAllowedError") {
|
||||
setPermissionError("Camera permission denied. Please enable camera access.");
|
||||
} else if (err?.name === "NotFoundError") {
|
||||
setPermissionError("No suitable camera found. Try a device with a back camera.");
|
||||
} else {
|
||||
setPermissionError("Unable to access camera.");
|
||||
}
|
||||
onError?.(err);
|
||||
setActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (active && videoEl) run();
|
||||
else stop();
|
||||
}, [active, onError, onResult, stop, videoEl]);
|
||||
|
||||
return { active, toggle, stop, permissionError };
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
/**
|
||||
* Use inside any client component that requires authentication.
|
||||
* Returns `ready: true` only once auth has resolved and the user is confirmed.
|
||||
* While loading or unauthenticated, `ready` is false — render null to prevent flash.
|
||||
*
|
||||
* @example
|
||||
* const { ready, user, token } = useRequireAuth();
|
||||
* if (!ready) return null;
|
||||
*/
|
||||
export function useRequireAuth(redirectTo = "/login") {
|
||||
const { user, token, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user) router.replace(redirectTo);
|
||||
}, [user, loading, router, redirectTo]);
|
||||
|
||||
const ready = !loading && !!user;
|
||||
return { ready, user, token, loading };
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useScrollSpy(sectionIds: string[], offset: number = 120) {
|
||||
const [activeId, setActiveId] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const scrollPosition = window.scrollY + offset;
|
||||
|
||||
let currentId = "";
|
||||
for (const id of sectionIds) {
|
||||
const element = document.getElementById(id);
|
||||
if (element && element.offsetTop <= scrollPosition) {
|
||||
currentId = id;
|
||||
}
|
||||
}
|
||||
setActiveId(currentId);
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
handleScroll();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, [sectionIds, offset]);
|
||||
|
||||
return activeId;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { jsonEqual } from "@/lib/deepEqual";
|
||||
|
||||
/**
|
||||
* Like useState, but the setter is a no-op (no re-render) when the new value is
|
||||
* structurally identical to the current one. Meant for state that's refreshed by
|
||||
* polling — background polls that return unchanged data shouldn't cause a flicker.
|
||||
*/
|
||||
export function useStableState<T>(initial: T) {
|
||||
const [state, setState] = useState<T>(initial);
|
||||
const ref = useRef<T>(initial);
|
||||
|
||||
const setIfChanged = useCallback((next: T) => {
|
||||
if (!jsonEqual(ref.current, next)) {
|
||||
ref.current = next;
|
||||
setState(next);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return [state, setIfChanged] as const;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Runs `callback` every `intervalMs` while the tab is visible. Skips ticks while the
|
||||
* tab is backgrounded (document.hidden) to avoid polling for no one, and fires an
|
||||
* immediate refetch the moment the tab becomes visible again so data isn't stale on
|
||||
* return. Does NOT call `callback` on mount — callers should do their own initial load.
|
||||
*/
|
||||
export function useVisiblePolling(callback: () => void, intervalMs: number, enabled: boolean = true) {
|
||||
const callbackRef = useRef(callback);
|
||||
useEffect(() => {
|
||||
callbackRef.current = callback;
|
||||
}, [callback]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const id = setInterval(() => {
|
||||
if (document.hidden) return;
|
||||
callbackRef.current();
|
||||
}, intervalMs);
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
if (!document.hidden) callbackRef.current();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
|
||||
return () => {
|
||||
clearInterval(id);
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
};
|
||||
}, [intervalMs, enabled]);
|
||||
}
|
||||
Reference in New Issue
Block a user