Download lib/hooks.ts from Etiya/d2l-ui: direct link, hf CLI and curl.
- Browser
- Download file 4.36 kB
-
https://huggingface.co/spaces/Etiya/d2l-ui/resolve/main/lib/hooks.ts
- Command line
-
hf download hf://spaces/Etiya/d2l-ui/lib/hooks.ts
-
curl -L -o hooks.ts https://huggingface.co/spaces/Etiya/d2l-ui/resolve/main/lib/hooks.ts
4.36 kB
| "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<string, (e: KeyboardEvent) => 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<T extends { updatedAt: number }>( | |
| 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<string, T[]> = { | |
| 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); | |
| } | |