File size: 4,359 Bytes
d784651 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 | "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);
}
|