"use client"; import { useEffect, useRef, useState } from "react"; /** * Lightweight keyboard-shortcut hook. Pass a map of `mod+key` strings to * handlers. `mod` is platform-aware: ⌘ on macOS, Ctrl elsewhere. * * Examples: * useHotkeys({ "mod+k": () => focusComposer(), "mod+n": newConv }) * * Keys are case-insensitive. Modifiers may be combined with `+`: * mod ⌘ on macOS, Ctrl elsewhere * shift, alt */ export function useHotkeys(map: Record void>) { const ref = useRef(map); ref.current = map; useEffect(() => { const isMac = typeof navigator !== "undefined" && /Mac|iPad|iPhone|iPod/.test(navigator.platform); const handler = (e: KeyboardEvent) => { const mod = isMac ? e.metaKey : e.ctrlKey; const key = e.key.toLowerCase(); for (const combo in ref.current) { const parts = combo.toLowerCase().split("+"); const wantsMod = parts.includes("mod"); const wantsShift = parts.includes("shift"); const wantsAlt = parts.includes("alt"); const wantedKey = parts[parts.length - 1]; if (wantsMod !== mod) continue; if (wantsShift !== e.shiftKey) continue; if (wantsAlt !== e.altKey) continue; // Special: "enter" matches Return if (wantedKey === "enter" && key !== "enter") continue; if (wantedKey !== "enter" && key !== wantedKey) continue; ref.current[combo](e); return; } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, []); } /** * Tracks viewport breakpoint reactively. Returns true when below `px`. */ export function useIsMobile(px = 768): boolean { const [isMobile, setIsMobile] = useState(false); useEffect(() => { const mq = window.matchMedia(`(max-width: ${px - 1}px)`); const update = () => setIsMobile(mq.matches); update(); mq.addEventListener("change", update); return () => mq.removeEventListener("change", update); }, [px]); return isMobile; } /** * Re-renders every `ms` so relative-time strings stay fresh. */ export function useTick(ms = 30_000) { const [, force] = useState(0); useEffect(() => { const id = setInterval(() => force((n) => n + 1), ms); return () => clearInterval(id); }, [ms]); } /** * Sets document.title based on the active conversation title. */ export function useDocumentTitle(title: string | null | undefined) { useEffect(() => { const base = "doc·to·lora · Etiya BSS Atelier"; document.title = title ? `${title} — doc·to·lora` : base; }, [title]); } /** * Returns a relative-time string like "12s ago", "3m ago", "yesterday". */ export function relativeTime(ts: number, now = Date.now()): string { const diff = Math.max(0, now - ts); const s = Math.floor(diff / 1000); if (s < 5) return "just now"; if (s < 60) return `${s}s ago`; const m = Math.floor(s / 60); if (m < 60) return `${m}m ago`; const h = Math.floor(m / 60); if (h < 24) return `${h}h ago`; const d = Math.floor(h / 24); if (d < 7) return `${d}d ago`; return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric", }); } /** * Buckets conversations by recency (Today / Yesterday / Last 7 days / * Last 30 days / Earlier). Preserves the input order within each bucket. */ export function bucketByDate( items: T[], now = Date.now() ): { label: string; items: T[] }[] { const day = 24 * 60 * 60 * 1000; const startOfToday = new Date(now); startOfToday.setHours(0, 0, 0, 0); const todayMs = startOfToday.getTime(); const buckets: Record = { Today: [], Yesterday: [], "Last 7 days": [], "Last 30 days": [], Earlier: [], }; const order = ["Today", "Yesterday", "Last 7 days", "Last 30 days", "Earlier"]; for (const it of items) { const t = it.updatedAt; if (t >= todayMs) buckets.Today.push(it); else if (t >= todayMs - day) buckets.Yesterday.push(it); else if (t >= todayMs - 7 * day) buckets["Last 7 days"].push(it); else if (t >= todayMs - 30 * day) buckets["Last 30 days"].push(it); else buckets.Earlier.push(it); } return order .map((label) => ({ label, items: buckets[label] })) .filter((g) => g.items.length > 0); }