| "use client"; |
|
|
| import { useEffect, useRef, useState } from "react"; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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; |
| |
| 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); |
| }, []); |
| } |
|
|
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| export function useTick(ms = 30_000) { |
| const [, force] = useState(0); |
| useEffect(() => { |
| const id = setInterval(() => force((n) => n + 1), ms); |
| return () => clearInterval(id); |
| }, [ms]); |
| } |
|
|
| |
| |
| |
| 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]); |
| } |
|
|
| |
| |
| |
| 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", |
| }); |
| } |
|
|
| |
| |
| |
| |
| 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); |
| } |
|
|