claude-code

This commit is contained in:
ashutoshpythoncs@gmail.com
2026-03-31 18:58:05 +05:30
parent a2a44a5841
commit b564857c0b
2148 changed files with 564518 additions and 2 deletions

65
web/hooks/useAriaLive.ts Normal file
View File

@@ -0,0 +1,65 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
interface UseAriaLiveOptions {
politeness?: "polite" | "assertive";
/** Delay in ms before the message is injected — allows the region to reset first */
delay?: number;
}
interface UseAriaLiveReturn {
/** The current announcement string — render this inside an aria-live region */
announcement: string;
/** Call this to trigger a new announcement */
announce: (message: string) => void;
/** Props to spread onto your aria-live container element */
liveRegionProps: {
role: "status";
"aria-live": "polite" | "assertive";
"aria-atomic": true;
};
}
/**
* Hook-based aria-live region manager.
* Returns an `announcement` string to render inside a visually-hidden container
* and an `announce` function to update it.
*
* @example
* const { announcement, announce, liveRegionProps } = useAriaLive();
*
* // Trigger
* announce("Message sent");
*
* // Render (visually hidden)
* <div {...liveRegionProps} className="sr-only">{announcement}</div>
*/
export function useAriaLive({
politeness = "polite",
delay = 50,
}: UseAriaLiveOptions = {}): UseAriaLiveReturn {
const [announcement, setAnnouncement] = useState("");
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const announce = useCallback(
(message: string) => {
setAnnouncement("");
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setAnnouncement(message), delay);
},
[delay]
);
useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
return {
announcement,
announce,
liveRegionProps: {
role: "status",
"aria-live": politeness,
"aria-atomic": true,
},
};
}

View File

@@ -0,0 +1,348 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { CollabSocket } from "@/lib/collaboration/socket";
import type {
CollabUser,
CollabRole,
ToolUsePendingEvent,
AnnotationAddedEvent,
AnnotationReplyEvent,
} from "@/lib/collaboration/socket";
import type { CollabAnnotation, PendingToolUse } from "@/lib/collaboration/types";
// ─── Options ──────────────────────────────────────────────────────────────────
export interface UseCollaborationOptions {
sessionId: string;
currentUser: CollabUser;
wsUrl?: string;
}
// ─── State ────────────────────────────────────────────────────────────────────
export interface CollaborationState {
isConnected: boolean;
myRole: CollabRole | null;
pendingToolUses: PendingToolUse[];
annotations: Record<string, CollabAnnotation[]>; // messageId → annotations
toolApprovalPolicy: "owner-only" | "any-collaborator";
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function useCollaboration({
sessionId,
currentUser,
wsUrl,
}: UseCollaborationOptions) {
const socketRef = useRef<CollabSocket | null>(null);
const [state, setState] = useState<CollaborationState>({
isConnected: false,
myRole: null,
pendingToolUses: [],
annotations: {},
toolApprovalPolicy: "any-collaborator",
});
const effectiveWsUrl =
wsUrl ??
(typeof process !== "undefined"
? process.env.NEXT_PUBLIC_WS_URL ?? "ws://localhost:3001"
: "ws://localhost:3001");
useEffect(() => {
const socket = new CollabSocket(sessionId, currentUser.id);
socketRef.current = socket;
socket.onConnectionChange = (connected) => {
setState((s) => ({ ...s, isConnected: connected }));
};
const cleanup: Array<() => void> = [];
cleanup.push(
socket.on("session_state", (e) => {
const me = e.users.find((u) => u.id === currentUser.id);
setState((s) => ({
...s,
myRole: me?.role ?? null,
toolApprovalPolicy: e.toolApprovalPolicy,
}));
})
);
cleanup.push(
socket.on("tool_use_pending", (e: ToolUsePendingEvent) => {
const entry: PendingToolUse = {
id: e.toolUseId,
name: e.toolName,
input: e.toolInput,
messageId: e.messageId,
requestedAt: e.timestamp,
};
setState((s) => ({
...s,
pendingToolUses: [...s.pendingToolUses, entry],
}));
})
);
cleanup.push(
socket.on("tool_use_approved", (e) => {
setState((s) => ({
...s,
pendingToolUses: s.pendingToolUses.filter((t) => t.id !== e.toolUseId),
}));
})
);
cleanup.push(
socket.on("tool_use_denied", (e) => {
setState((s) => ({
...s,
pendingToolUses: s.pendingToolUses.filter((t) => t.id !== e.toolUseId),
}));
})
);
cleanup.push(
socket.on("role_changed", (e) => {
if (e.targetUserId === currentUser.id) {
setState((s) => ({ ...s, myRole: e.newRole }));
}
})
);
cleanup.push(
socket.on("access_revoked", (e) => {
if (e.targetUserId === currentUser.id) {
socket.disconnect();
setState((s) => ({ ...s, isConnected: false, myRole: null }));
}
})
);
cleanup.push(
socket.on("ownership_transferred", (e) => {
if (e.newOwnerId === currentUser.id) {
setState((s) => ({ ...s, myRole: "owner" }));
} else if (e.previousOwnerId === currentUser.id) {
setState((s) => ({ ...s, myRole: "collaborator" }));
}
})
);
cleanup.push(
socket.on("annotation_added", (e: AnnotationAddedEvent) => {
const ann: CollabAnnotation = { ...e.annotation };
setState((s) => ({
...s,
annotations: {
...s.annotations,
[ann.messageId]: [...(s.annotations[ann.messageId] ?? []), ann],
},
}));
})
);
cleanup.push(
socket.on("annotation_resolved", (e) => {
setState((s) => {
const next: Record<string, CollabAnnotation[]> = {};
for (const [msgId, anns] of Object.entries(s.annotations)) {
next[msgId] = anns.map((a) =>
a.id === e.annotationId ? { ...a, resolved: e.resolved } : a
);
}
return { ...s, annotations: next };
});
})
);
cleanup.push(
socket.on("annotation_reply", (e: AnnotationReplyEvent) => {
setState((s) => {
const next: Record<string, CollabAnnotation[]> = {};
for (const [msgId, anns] of Object.entries(s.annotations)) {
next[msgId] = anns.map((a) =>
a.id === e.annotationId
? { ...a, replies: [...a.replies, e.reply] }
: a
);
}
return { ...s, annotations: next };
});
})
);
socket.connect(`${effectiveWsUrl}/collab`);
return () => {
cleanup.forEach((off) => off());
socket.disconnect();
};
}, [sessionId, currentUser.id, effectiveWsUrl]);
// ─── Actions ───────────────────────────────────────────────────────────────
const approveTool = useCallback(
(toolUseId: string) => {
socketRef.current?.send({
type: "tool_use_approved",
sessionId,
userId: currentUser.id,
toolUseId,
approvedBy: currentUser,
});
},
[sessionId, currentUser]
);
const denyTool = useCallback(
(toolUseId: string) => {
socketRef.current?.send({
type: "tool_use_denied",
sessionId,
userId: currentUser.id,
toolUseId,
deniedBy: currentUser,
});
},
[sessionId, currentUser]
);
const addAnnotation = useCallback(
(messageId: string, text: string, parentId?: string) => {
const annotation: CollabAnnotation = {
id: crypto.randomUUID(),
messageId,
parentId,
text,
author: currentUser,
createdAt: Date.now(),
resolved: false,
replies: [],
};
// Optimistic update
setState((s) => ({
...s,
annotations: {
...s.annotations,
[messageId]: [...(s.annotations[messageId] ?? []), annotation],
},
}));
socketRef.current?.send({
type: "annotation_added",
sessionId,
userId: currentUser.id,
annotation,
});
},
[sessionId, currentUser]
);
const resolveAnnotation = useCallback(
(annotationId: string, resolved: boolean) => {
setState((s) => {
const next: Record<string, CollabAnnotation[]> = {};
for (const [msgId, anns] of Object.entries(s.annotations)) {
next[msgId] = anns.map((a) =>
a.id === annotationId ? { ...a, resolved } : a
);
}
return { ...s, annotations: next };
});
socketRef.current?.send({
type: "annotation_resolved",
sessionId,
userId: currentUser.id,
annotationId,
resolved,
resolvedBy: currentUser,
});
},
[sessionId, currentUser]
);
const replyAnnotation = useCallback(
(annotationId: string, text: string) => {
const reply = {
id: crypto.randomUUID(),
text,
author: currentUser,
createdAt: Date.now(),
};
// Optimistic update
setState((s) => {
const next: Record<string, CollabAnnotation[]> = {};
for (const [msgId, anns] of Object.entries(s.annotations)) {
next[msgId] = anns.map((a) =>
a.id === annotationId
? { ...a, replies: [...a.replies, reply] }
: a
);
}
return { ...s, annotations: next };
});
socketRef.current?.send({
type: "annotation_reply",
sessionId,
userId: currentUser.id,
annotationId,
reply,
});
},
[sessionId, currentUser]
);
const revokeAccess = useCallback(
(targetUserId: string) => {
socketRef.current?.send({
type: "access_revoked",
sessionId,
userId: currentUser.id,
targetUserId,
});
},
[sessionId, currentUser.id]
);
const changeRole = useCallback(
(targetUserId: string, newRole: CollabRole) => {
socketRef.current?.send({
type: "role_changed",
sessionId,
userId: currentUser.id,
targetUserId,
newRole,
});
},
[sessionId, currentUser.id]
);
const transferOwnership = useCallback(
(newOwnerId: string) => {
socketRef.current?.send({
type: "ownership_transferred",
sessionId,
userId: currentUser.id,
newOwnerId,
previousOwnerId: currentUser.id,
});
},
[sessionId, currentUser.id]
);
return {
...state,
approveTool,
denyTool,
addAnnotation,
resolveAnnotation,
replyAnnotation,
revokeAccess,
changeRole,
transferOwnership,
};
}

View File

@@ -0,0 +1,137 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import type { Command } from "@/lib/shortcuts";
const RECENT_MAX = 5;
const RECENT_KEY = "claude-code-recent-commands";
interface CommandRegistryContextValue {
/** Live list of all registered commands for UI rendering */
commands: Command[];
/** Ref always pointing to the latest commands list — use in event handlers */
commandsRef: React.MutableRefObject<Command[]>;
registerCommand: (cmd: Command) => () => void;
/** Run a command by id and record it as recently used */
runCommand: (id: string) => void;
paletteOpen: boolean;
openPalette: () => void;
closePalette: () => void;
helpOpen: boolean;
openHelp: () => void;
closeHelp: () => void;
recentCommandIds: string[];
}
const CommandRegistryContext = createContext<CommandRegistryContextValue>({
commands: [],
commandsRef: { current: [] },
registerCommand: () => () => {},
runCommand: () => {},
paletteOpen: false,
openPalette: () => {},
closePalette: () => {},
helpOpen: false,
openHelp: () => {},
closeHelp: () => {},
recentCommandIds: [],
});
export function CommandRegistryProvider({
children,
}: {
children: React.ReactNode;
}) {
const [commands, setCommands] = useState<Command[]>([]);
const commandsRef = useRef<Command[]>([]);
const [paletteOpen, setPaletteOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const [recentCommandIds, setRecentCommandIds] = useState<string[]>(() => {
if (typeof window === "undefined") return [];
try {
return JSON.parse(localStorage.getItem(RECENT_KEY) ?? "[]");
} catch {
return [];
}
});
const registerCommand = useCallback((cmd: Command) => {
setCommands((prev) => {
const next = [...prev.filter((c) => c.id !== cmd.id), cmd];
commandsRef.current = next;
return next;
});
return () => {
setCommands((prev) => {
const next = prev.filter((c) => c.id !== cmd.id);
commandsRef.current = next;
return next;
});
};
}, []);
const addToRecent = useCallback((id: string) => {
setRecentCommandIds((prev) => {
const next = [id, ...prev.filter((r) => r !== id)].slice(0, RECENT_MAX);
try {
localStorage.setItem(RECENT_KEY, JSON.stringify(next));
} catch {}
return next;
});
}, []);
const runCommand = useCallback(
(id: string) => {
const cmd = commandsRef.current.find((c) => c.id === id);
if (!cmd) return;
if (cmd.when && !cmd.when()) return;
addToRecent(id);
cmd.action();
},
[addToRecent]
);
const openPalette = useCallback(() => setPaletteOpen(true), []);
const closePalette = useCallback(() => setPaletteOpen(false), []);
const openHelp = useCallback(() => setHelpOpen(true), []);
const closeHelp = useCallback(() => setHelpOpen(false), []);
// Keep commandsRef in sync when state updates
useEffect(() => {
commandsRef.current = commands;
}, [commands]);
return (
<CommandRegistryContext.Provider
value={{
commands,
commandsRef,
registerCommand,
runCommand,
paletteOpen,
openPalette,
closePalette,
helpOpen,
openHelp,
closeHelp,
recentCommandIds,
}}
>
{children}
</CommandRegistryContext.Provider>
);
}
export function useCommandRegistry() {
return useContext(CommandRegistryContext);
}

View File

@@ -0,0 +1,15 @@
import { useChatStore } from "@/lib/store";
export function useConversation(id: string) {
const { conversations, addMessage, updateMessage, deleteConversation } = useChatStore();
const conversation = conversations.find((c) => c.id === id) ?? null;
return {
conversation,
messages: conversation?.messages ?? [],
addMessage: (msg: Parameters<typeof addMessage>[1]) => addMessage(id, msg),
updateMessage: (msgId: string, updates: Parameters<typeof updateMessage>[2]) =>
updateMessage(id, msgId, updates),
deleteConversation: () => deleteConversation(id),
};
}

View File

@@ -0,0 +1,40 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
/**
* Saves the currently focused element and returns a function to restore focus to it.
* Use when opening modals/dialogs to return focus to the trigger on close.
*
* @example
* const returnFocus = useFocusReturn();
*
* const openDialog = () => {
* returnFocus.save(); // call before showing the dialog
* setOpen(true);
* };
*
* const closeDialog = () => {
* setOpen(false);
* returnFocus.restore(); // call after hiding the dialog
* };
*/
export function useFocusReturn() {
const savedRef = useRef<HTMLElement | null>(null);
const save = useCallback(() => {
savedRef.current = document.activeElement as HTMLElement | null;
}, []);
const restore = useCallback(() => {
if (savedRef.current && typeof savedRef.current.focus === "function") {
savedRef.current.focus();
savedRef.current = null;
}
}, []);
// Safety cleanup on unmount
useEffect(() => () => { savedRef.current = null; }, []);
return { save, restore };
}

View File

@@ -0,0 +1,101 @@
"use client";
import { useEffect, useRef } from "react";
import { parseKey, matchesEvent } from "@/lib/keyParser";
import { useCommandRegistry } from "./useCommandRegistry";
const SEQUENCE_TIMEOUT_MS = 1000;
/** Tags whose focus should suppress non-global shortcuts */
const INPUT_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"]);
function isTypingTarget(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false;
if (INPUT_TAGS.has(el.tagName)) return true;
if (el.isContentEditable) return true;
return false;
}
/**
* Attaches a global keydown listener that fires registered commands.
* Supports single combos ("mod+k") and two-key sequences ("g d").
* Must be used inside a CommandRegistryProvider.
*/
export function useKeyboardShortcuts() {
const { commandsRef } = useCommandRegistry();
const pendingSequenceRef = useRef<string | null>(null);
const sequenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const clearSequence = () => {
pendingSequenceRef.current = null;
if (sequenceTimerRef.current) {
clearTimeout(sequenceTimerRef.current);
sequenceTimerRef.current = null;
}
};
const handler = (e: KeyboardEvent) => {
// Ignore bare modifier keypresses
if (["Meta", "Control", "Shift", "Alt"].includes(e.key)) return;
const inInput = isTypingTarget(e.target);
const commands = commandsRef.current;
// --- Sequence matching (e.g. "g" then "d") ---
if (pendingSequenceRef.current) {
const seq = `${pendingSequenceRef.current} ${e.key.toLowerCase()}`;
const match = commands.find(
(cmd) =>
(!inInput || cmd.global) &&
(!cmd.when || cmd.when()) &&
cmd.keys.includes(seq)
);
clearSequence();
if (match) {
e.preventDefault();
match.action();
return;
}
}
// --- Single combo matching ---
const singleMatch = commands.find((cmd) => {
if (inInput && !cmd.global) return false;
if (cmd.when && !cmd.when()) return false;
return cmd.keys.some((k) => {
// Sequence keys contain a space; skip them in the single pass
if (k.includes(" ")) return false;
return matchesEvent(parseKey(k), e);
});
});
if (singleMatch) {
e.preventDefault();
singleMatch.action();
return;
}
// --- Start-of-sequence detection (single bare key that starts a sequence) ---
// Only when not in an input and no modifier held
if (!inInput && !e.metaKey && !e.ctrlKey && !e.altKey) {
const keyLower = e.key.toLowerCase();
const startsSequence = commands.some((cmd) =>
cmd.keys.some((k) => k.includes(" ") && k.startsWith(keyLower + " "))
);
if (startsSequence) {
e.preventDefault();
clearSequence();
pendingSequenceRef.current = keyLower;
sequenceTimerRef.current = setTimeout(clearSequence, SEQUENCE_TIMEOUT_MS);
}
}
};
document.addEventListener("keydown", handler);
return () => {
document.removeEventListener("keydown", handler);
clearSequence();
};
}, [commandsRef]);
}

View File

@@ -0,0 +1,32 @@
"use client";
import { useState, useEffect } from "react";
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
const mq = window.matchMedia(query);
setMatches(mq.matches);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, [query]);
return matches;
}
/** < 768px */
export function useIsMobile(): boolean {
return useMediaQuery("(max-width: 767px)");
}
/** 768px 1023px */
export function useIsTablet(): boolean {
return useMediaQuery("(min-width: 768px) and (max-width: 1023px)");
}
/** >= 1024px */
export function useIsDesktop(): boolean {
return useMediaQuery("(min-width: 1024px)");
}

View File

@@ -0,0 +1,54 @@
"use client";
import { useCallback } from "react";
import { useNotificationStore, type NotificationCategory } from "@/lib/notifications";
import { browserNotifications } from "@/lib/browser-notifications";
export interface NotifyOptions {
title: string;
description: string;
category: NotificationCategory;
link?: string;
browserNotification?: boolean;
}
export function useNotifications() {
const notifications = useNotificationStore((s) => s.notifications);
const browserNotificationsEnabled = useNotificationStore(
(s) => s.browserNotificationsEnabled
);
const addNotification = useNotificationStore((s) => s.addNotification);
const markRead = useNotificationStore((s) => s.markRead);
const markAllRead = useNotificationStore((s) => s.markAllRead);
const clearHistory = useNotificationStore((s) => s.clearHistory);
const notify = useCallback(
async (options: NotifyOptions) => {
addNotification({
title: options.title,
description: options.description,
category: options.category,
link: options.link,
});
if (options.browserNotification && browserNotificationsEnabled) {
await browserNotifications.send({
title: options.title,
body: options.description,
});
}
},
[addNotification, browserNotificationsEnabled]
);
const unreadCount = notifications.filter((n) => !n.read).length;
return {
notifications,
unreadCount,
notify,
markRead,
markAllRead,
clearHistory,
};
}

154
web/hooks/usePresence.ts Normal file
View File

@@ -0,0 +1,154 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import {
createPresenceState,
presenceAddUser,
presenceRemoveUser,
presenceSyncUsers,
presenceUpdateCursor,
presenceSetTyping,
} from "@/lib/collaboration/presence";
import type { PresenceState } from "@/lib/collaboration/presence";
import type { CollabSocket } from "@/lib/collaboration/socket";
import type { CollabUser } from "@/lib/collaboration/socket";
// ─── Options ──────────────────────────────────────────────────────────────────
export interface UsePresenceOptions {
socket: CollabSocket | null;
sessionId: string;
currentUser: CollabUser;
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function usePresence({ socket, sessionId, currentUser }: UsePresenceOptions) {
const [presence, setPresence] = useState<PresenceState>(createPresenceState);
const typingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isTypingRef = useRef(false);
useEffect(() => {
if (!socket) return;
const cleanup: Array<() => void> = [];
cleanup.push(
socket.on("presence_sync", (e) => {
setPresence((s) => presenceSyncUsers(s, e.users));
})
);
cleanup.push(
socket.on("session_state", (e) => {
setPresence((s) => presenceSyncUsers(s, e.users));
})
);
cleanup.push(
socket.on("user_joined", (e) => {
setPresence((s) => presenceAddUser(s, e.user));
})
);
cleanup.push(
socket.on("user_left", (e) => {
setPresence((s) => presenceRemoveUser(s, e.user.id));
})
);
cleanup.push(
socket.on("cursor_update", (e) => {
if (e.userId === currentUser.id) return; // skip own cursor
setPresence((s) =>
presenceUpdateCursor(s, e.userId, e.position, e.selectionStart, e.selectionEnd)
);
})
);
cleanup.push(
socket.on("typing_start", (e) => {
if (e.userId === currentUser.id) return;
setPresence((s) => presenceSetTyping(s, e.user.id, true));
})
);
cleanup.push(
socket.on("typing_stop", (e) => {
if (e.userId === currentUser.id) return;
setPresence((s) => presenceSetTyping(s, e.user.id, false));
})
);
return () => cleanup.forEach((off) => off());
}, [socket, currentUser.id]);
// ─── Actions ─────────────────────────────────────────────────────────────
const sendCursorUpdate = useCallback(
(position: number, selectionStart?: number, selectionEnd?: number) => {
socket?.send({
type: "cursor_update",
sessionId,
userId: currentUser.id,
position,
selectionStart,
selectionEnd,
});
},
[socket, sessionId, currentUser.id]
);
// Call this whenever the user types — auto-sends typing_start + debounced typing_stop
const notifyTyping = useCallback(() => {
if (!isTypingRef.current) {
isTypingRef.current = true;
socket?.send({
type: "typing_start",
sessionId,
userId: currentUser.id,
user: currentUser,
});
}
if (typingTimerRef.current) clearTimeout(typingTimerRef.current);
typingTimerRef.current = setTimeout(() => {
isTypingRef.current = false;
socket?.send({
type: "typing_stop",
sessionId,
userId: currentUser.id,
user: currentUser,
});
}, 2_000);
}, [socket, sessionId, currentUser]);
const stopTyping = useCallback(() => {
if (typingTimerRef.current) clearTimeout(typingTimerRef.current);
if (isTypingRef.current) {
isTypingRef.current = false;
socket?.send({
type: "typing_stop",
sessionId,
userId: currentUser.id,
user: currentUser,
});
}
}, [socket, sessionId, currentUser]);
// Derived helpers
const otherUsers = Array.from(presence.users.values()).filter(
(u) => u.id !== currentUser.id
);
const typingUsers = Array.from(presence.typing)
.filter((id) => id !== currentUser.id)
.map((id) => presence.users.get(id))
.filter((u): u is CollabUser => u !== undefined);
return {
presence,
otherUsers,
typingUsers,
sendCursorUpdate,
notifyTyping,
stopTyping,
};
}

View File

@@ -0,0 +1,27 @@
"use client";
import { useEffect, useState } from "react";
/**
* Returns true when the user has requested reduced motion via OS settings.
* Use this to disable or simplify animations for users who need it.
*
* @example
* const reducedMotion = useReducedMotion();
* <div className={reducedMotion ? "" : "animate-fade-in"}>...</div>
*/
export function useReducedMotion(): boolean {
const [reducedMotion, setReducedMotion] = useState(() => {
if (typeof window === "undefined") return false;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
});
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const handler = (e: MediaQueryListEvent) => setReducedMotion(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
return reducedMotion;
}

1
web/hooks/useTheme.ts Normal file
View File

@@ -0,0 +1 @@
export { useTheme } from "@/components/layout/ThemeProvider";

29
web/hooks/useToast.ts Normal file
View File

@@ -0,0 +1,29 @@
"use client";
import { useCallback } from "react";
import { useNotificationStore, DEFAULT_DURATIONS, type ToastVariant } from "@/lib/notifications";
export interface ToastOptions {
variant: ToastVariant;
title: string;
description?: string;
duration?: number;
action?: { label: string; onClick: () => void };
details?: string;
}
export function useToast() {
const addToast = useNotificationStore((s) => s.addToast);
const dismissToast = useNotificationStore((s) => s.dismissToast);
const dismissAllToasts = useNotificationStore((s) => s.dismissAllToasts);
const toast = useCallback(
(options: ToastOptions): string => {
const duration = options.duration ?? DEFAULT_DURATIONS[options.variant];
return addToast({ ...options, duration });
},
[addToast]
);
return { toast, dismiss: dismissToast, dismissAll: dismissAllToasts };
}

View File

@@ -0,0 +1,111 @@
"use client";
import { useRef, useCallback } from "react";
export type SwipeDirection = "left" | "right" | "up" | "down";
export interface TouchGestureOptions {
/** Minimum distance in px to recognise as a swipe (default 50) */
threshold?: number;
/** Minimum speed in px/ms to recognise as a swipe (default 0.2) */
velocityThreshold?: number;
onSwipe?: (direction: SwipeDirection, distance: number) => void;
onSwipeLeft?: (distance: number) => void;
onSwipeRight?: (distance: number) => void;
onSwipeUp?: (distance: number) => void;
onSwipeDown?: (distance: number) => void;
onLongPress?: () => void;
/** Long-press delay in ms (default 500) */
longPressDelay?: number;
}
export interface TouchGestureHandlers {
onTouchStart: (e: React.TouchEvent) => void;
onTouchMove: (e: React.TouchEvent) => void;
onTouchEnd: (e: React.TouchEvent) => void;
}
export function useTouchGesture(options: TouchGestureOptions = {}): TouchGestureHandlers {
const {
threshold = 50,
velocityThreshold = 0.2,
onSwipe,
onSwipeLeft,
onSwipeRight,
onSwipeUp,
onSwipeDown,
onLongPress,
longPressDelay = 500,
} = options;
const startRef = useRef<{ x: number; y: number; time: number } | null>(null);
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasMoved = useRef(false);
const onTouchStart = useCallback(
(e: React.TouchEvent) => {
const touch = e.touches[0];
startRef.current = { x: touch.clientX, y: touch.clientY, time: Date.now() };
hasMoved.current = false;
if (onLongPress) {
longPressTimer.current = setTimeout(() => {
if (!hasMoved.current) onLongPress();
}, longPressDelay);
}
},
[onLongPress, longPressDelay]
);
const onTouchMove = useCallback((_e: React.TouchEvent) => {
hasMoved.current = true;
if (longPressTimer.current) {
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
}, []);
const onTouchEnd = useCallback(
(e: React.TouchEvent) => {
if (longPressTimer.current) {
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
if (!startRef.current) return;
const touch = e.changedTouches[0];
const dx = touch.clientX - startRef.current.x;
const dy = touch.clientY - startRef.current.y;
const dt = Date.now() - startRef.current.time;
startRef.current = null;
if (dt === 0) return;
const velocity = Math.sqrt(dx * dx + dy * dy) / dt;
if (Math.abs(dx) < threshold && Math.abs(dy) < threshold) return;
if (velocity < velocityThreshold) return;
const isHorizontal = Math.abs(dx) >= Math.abs(dy);
let direction: SwipeDirection;
let distance: number;
if (isHorizontal) {
direction = dx > 0 ? "right" : "left";
distance = Math.abs(dx);
} else {
direction = dy > 0 ? "down" : "up";
distance = Math.abs(dy);
}
onSwipe?.(direction, distance);
if (direction === "left") onSwipeLeft?.(distance);
if (direction === "right") onSwipeRight?.(distance);
if (direction === "up") onSwipeUp?.(distance);
if (direction === "down") onSwipeDown?.(distance);
},
[threshold, velocityThreshold, onSwipe, onSwipeLeft, onSwipeRight, onSwipeUp, onSwipeDown]
);
return { onTouchStart, onTouchMove, onTouchEnd };
}

View File

@@ -0,0 +1,52 @@
"use client";
import { useState, useEffect } from "react";
export interface ViewportHeightState {
viewportHeight: number;
keyboardHeight: number;
isKeyboardOpen: boolean;
}
/**
* Tracks the visual viewport height to handle mobile keyboard open/close.
* Uses the VisualViewport API so we don't rely on position:fixed (which breaks on iOS).
*/
export function useViewportHeight(): ViewportHeightState {
const [state, setState] = useState<ViewportHeightState>({
viewportHeight: typeof window !== "undefined" ? window.innerHeight : 0,
keyboardHeight: 0,
isKeyboardOpen: false,
});
useEffect(() => {
const vv = window.visualViewport;
const update = () => {
const fullHeight = window.innerHeight;
const visibleHeight = vv ? vv.height : fullHeight;
const keyboardHeight = Math.max(0, fullHeight - visibleHeight);
setState({
viewportHeight: visibleHeight,
keyboardHeight,
isKeyboardOpen: keyboardHeight > 50,
});
};
update();
if (vv) {
vv.addEventListener("resize", update);
vv.addEventListener("scroll", update);
return () => {
vv.removeEventListener("resize", update);
vv.removeEventListener("scroll", update);
};
} else {
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}
}, []);
return state;
}