This commit is contained in:
yohlo
2025-08-20 22:35:40 -05:00
commit f51c278cd3
169 changed files with 8173 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
import { useMemo } from "react";
import { useIsMobile } from "./use-is-mobile";
import useHeaderConfig from "@/features/core/hooks/use-header-config";
const useAppShellHeight = () => {
const isMobile = useIsMobile();
const headerConfig = useHeaderConfig();
const height = useMemo(() =>
`calc(100dvh - var(--app-shell-header-height, 0px) - ${isMobile && !headerConfig.collapsed ? '4rem' : '0px'} - 1.285rem)`,
[isMobile, headerConfig.collapsed]);
return height;
}
export default useAppShellHeight;

61
src/hooks/use-haptic.ts Normal file
View File

@@ -0,0 +1,61 @@
// Sneaky way of triggering haptic feedback, without using the vibration API (not available on iOS)
// Source: https://github.com/posaune0423/use-haptic
import { useCallback, useEffect, useMemo, useRef } from "react";
const detectiOS = (): boolean => {
if (typeof navigator === "undefined") {
return false;
}
const toMatch = [/iPhone/i, /iPad/i, /iPod/i];
return toMatch.some((toMatchItem) => {
return RegExp(toMatchItem).exec(navigator.userAgent);
});
};
const useHaptic = (
duration = 200,
): { triggerHaptic: () => void } => {
const inputRef = useRef<HTMLInputElement | null>(null);
const labelRef = useRef<HTMLLabelElement | null>(null);
const isIOS = useMemo(() => detectiOS(), []);
useEffect(() => {
const input = document.createElement("input");
input.type = "checkbox";
input.id = "haptic-switch";
input.setAttribute("switch", "");
input.style.display = "none";
document.body.appendChild(input);
inputRef.current = input;
const label = document.createElement("label");
label.htmlFor = "haptic-switch";
label.style.display = "none";
document.body.appendChild(label);
labelRef.current = label;
return () => {
document.body.removeChild(input);
document.body.removeChild(label);
};
}, []);
const triggerHaptic = useCallback(() => {
if (isIOS) {
labelRef.current?.click();
} else {
if (navigator?.vibrate) {
navigator.vibrate(duration);
} else {
labelRef.current?.click();
}
}
}, [isIOS]);
return { triggerHaptic };
};
export default useHaptic;

View File

@@ -0,0 +1,6 @@
import { useMediaQuery } from "@mantine/hooks";
export const useIsMobile = () => {
const isMobile = useMediaQuery('(max-width: 48em)');
return isMobile;
}

View File

@@ -0,0 +1,153 @@
import { useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Logger } from "@/lib/logger";
import { useAuth } from "@/contexts/auth-context";
/*
import { authClient } from "~/lib/auth-client";
import { getIdeaFn } from "~/routes/-fn/getIdeaFn";
import { useSession } from "~/lib/sessionContext";
*/
// Global state for new ideas notification
let newIdeasAvailable = false;
let newIdeasCallbacks: (() => void)[] = [];
const logger = new Logger('ServerEvents');
// Event handler types for better type safety
type SSEEvent = {
type: string;
[key: string]: any;
};
type EventHandler = (event: SSEEvent, queryClient: ReturnType<typeof useQueryClient>, currentSessionId?: string) => void;
// Event handlers map - add new handlers here for easy extension
const eventHandlers: Record<string, EventHandler> = {
"connected": () => {
logger.info("ServerEvents | New Connection");
},
"ping": () => {
// Keep-alive ping, no action needed
},
"test": (event, queryClient) => {
},
// Add new event handlers here:
// "idea-updated": (event, queryClient) => {
// queryClient.invalidateQueries({ queryKey: ["ideas"] });
// },
// "vote-changed": (event, queryClient) => {
// queryClient.invalidateQueries({ queryKey: ["idea", event.ideaId] });
// },
};
// Functions to manage new ideas notification state
export function getNewIdeasAvailable(): boolean {
return newIdeasAvailable;
}
export function clearNewIdeasAvailable(): void {
newIdeasAvailable = false;
}
export function subscribeToNewIdeas(callback: () => void): () => void {
newIdeasCallbacks.push(callback);
// Return unsubscribe function
return () => {
const index = newIdeasCallbacks.indexOf(callback);
if (index > -1) {
newIdeasCallbacks.splice(index, 1);
}
};
}
export function useServerEvents() {
const queryClient = useQueryClient();
const { user } = useAuth();
const retryCountRef = useRef(0);
const shouldConnectRef = useRef(true);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (!user?.id) return;
shouldConnectRef.current = true;
retryCountRef.current = 0;
const connectEventSource = () => {
if (!shouldConnectRef.current) return;
const eventSource = new EventSource(`/api/events/$`);
eventSource.onopen = () => {
// Reset retry count on successful connection
retryCountRef.current = 0;
};
eventSource.onmessage = (event) => {
try {
const data: SSEEvent = JSON.parse(event.data);
logger.info("Event received", data);
// Use the event handler pattern for extensible event processing
const handler = eventHandlers[data.type];
if (handler) {
handler(data, queryClient, user?.id);
} else {
logger.warn(`Unhandled SSE event type: ${data.type}`);
}
} catch (error) {
logger.error("Error parsing SSE message", error);
}
};
eventSource.onerror = (error) => {
logger.error("SSE connection error", error);
eventSource.close();
// Only retry if we should still be connecting and haven't exceeded max retries
if (shouldConnectRef.current && retryCountRef.current < 5) {
retryCountRef.current += 1;
const delay = Math.min(
1000 * Math.pow(2, retryCountRef.current - 1),
30000
); // Cap at 30 seconds
logger.info(
`SSE reconnection attempt ${retryCountRef.current}/5 in ${delay}ms`
);
timeoutRef.current = setTimeout(() => {
if (shouldConnectRef.current) {
connectEventSource();
}
}, delay);
} else if (retryCountRef.current >= 5) {
logger.error("SSE max reconnection attempts reached");
}
};
return eventSource;
};
const eventSource = connectEventSource();
return () => {
logger.info("Closing SSE connection");
shouldConnectRef.current = false;
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (eventSource) {
eventSource.close();
}
};
}, [user?.id, queryClient]);
}

24
src/hooks/use-sheet.ts Normal file
View File

@@ -0,0 +1,24 @@
import { useState, useCallback } from "react";
interface UseSheetOptions {
defaultOpen?: boolean;
}
export function useSheet(options: UseSheetOptions = {}) {
const [isOpen, setIsOpen] = useState(options.defaultOpen ?? false);
const open = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => setIsOpen(false), []);
const toggle = useCallback(() => setIsOpen(prev => !prev), []);
return {
isOpen,
open,
close,
toggle,
props: {
opened: isOpen,
onChange: setIsOpen,
}
};
}