mirror of
https://github.com/codeaashu/claude-code.git
synced 2026-04-08 22:28:48 +03:00
claude-code
This commit is contained in:
65
web/hooks/useAriaLive.ts
Normal file
65
web/hooks/useAriaLive.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
348
web/hooks/useCollaboration.ts
Normal file
348
web/hooks/useCollaboration.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
137
web/hooks/useCommandRegistry.ts
Normal file
137
web/hooks/useCommandRegistry.ts
Normal 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);
|
||||
}
|
||||
15
web/hooks/useConversation.ts
Normal file
15
web/hooks/useConversation.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
40
web/hooks/useFocusReturn.ts
Normal file
40
web/hooks/useFocusReturn.ts
Normal 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 };
|
||||
}
|
||||
101
web/hooks/useKeyboardShortcuts.ts
Normal file
101
web/hooks/useKeyboardShortcuts.ts
Normal 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]);
|
||||
}
|
||||
32
web/hooks/useMediaQuery.ts
Normal file
32
web/hooks/useMediaQuery.ts
Normal 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)");
|
||||
}
|
||||
54
web/hooks/useNotifications.ts
Normal file
54
web/hooks/useNotifications.ts
Normal 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
154
web/hooks/usePresence.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
27
web/hooks/useReducedMotion.ts
Normal file
27
web/hooks/useReducedMotion.ts
Normal 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
1
web/hooks/useTheme.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useTheme } from "@/components/layout/ThemeProvider";
|
||||
29
web/hooks/useToast.ts
Normal file
29
web/hooks/useToast.ts
Normal 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 };
|
||||
}
|
||||
111
web/hooks/useTouchGesture.ts
Normal file
111
web/hooks/useTouchGesture.ts
Normal 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 };
|
||||
}
|
||||
52
web/hooks/useViewportHeight.ts
Normal file
52
web/hooks/useViewportHeight.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user