Initial commit

Next.js + Express event management app for Hope Family Church.
This commit is contained in:
2026-07-23 15:26:47 +02:00
commit 3d381944d2
246 changed files with 57565 additions and 0 deletions
+110
View File
@@ -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 }
+3
View File
@@ -0,0 +1,3 @@
"use client";
import { useAuthContext } from "@/contexts/AuthContext";
export const useAuth = useAuthContext;
+78
View File
@@ -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 };
}
+27
View File
@@ -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 };
}
+31
View File
@@ -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;
}
+21
View File
@@ -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;
}
+33
View File
@@ -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]);
}