d2l-ui / lib /hooks.ts
Berkkirik's picture
ux: mobile sidebar, stop, rename, hotkeys, sync timestamp + 8 more
d784651
"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);
}