"use client"; import { useEffect, useId, useRef, useState, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; import "./page-header.css"; export type PageColor = "gray" | "brown" | "orange" | "yellow" | "green" | "blue" | "purple" | "pink" | "red"; export type PageOption = { id: string; label: string; color: PageColor }; export type PageCover = { id: string; label: string; color?: string; image?: string }; export type PageState = { icon: string; title: string; cover: string; /** Vertical focus of an image cover, 0–100. */ coverY: number; status: string; owner: string; due: string; tags: string[]; }; export type PageHeaderProps = { defaultValue: PageState; covers: PageCover[]; statuses: PageOption[]; people: string[]; tagOptions: PageOption[]; icons?: string[]; onChange?: (page: PageState) => void; theme?: "light" | "dark"; className?: string; }; const COLORS: PageColor[] = ["blue", "green", "orange", "purple", "pink", "yellow", "brown", "red", "gray"]; const initials = (name: string) => name.split(/\s+/).map((p) => p[0]).slice(0, 2).join("").toUpperCase(); /** A small listbox popover: focus moves into it, arrows choose, Escape returns focus to the trigger. */ function Picker({ idBase, label, options, selected, onPick, onClose, render }: { idBase: string; label: string; options: string[]; selected?: string; onPick: (v: string) => void; onClose: () => void; render: (v: string) => ReactNode }) { const [active, setActive] = useState(Math.max(0, options.indexOf(selected ?? ""))); const list = useRef(null); useEffect(() => { list.current?.focus(); }, []); const key = (e: KeyboardEvent) => { if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); setActive((a) => (a + (e.key === "ArrowDown" ? 1 : -1) + options.length) % options.length); } else if (e.key === "Home") { e.preventDefault(); setActive(0); } else if (e.key === "End") { e.preventDefault(); setActive(options.length - 1); } else if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onPick(options[active]); } else if (e.key === "Escape" || e.key === "Tab") { e.preventDefault(); onClose(); } }; const optionId = (i: number) => `${idBase}-${label.replace(/\W/g, "")}-${i}`; return ( ); } /** * Page Header * The top of a document: a cover you can change and reposition, an emoji * icon that overlaps it, an editable title, and property rows β€” status, * owner, due date and tags β€” each edited in place with a small picker. */ export function PageHeader({ defaultValue, covers, statuses, people, tagOptions, icons = ["πŸ—ΊοΈ", "πŸ“", "🧭", "πŸŒ™", "πŸ”οΈ", "πŸ“¦", "πŸ§ͺ", "πŸͺ΄"], onChange, theme = "light", className = "" }: PageHeaderProps) { const id = useId(); const [page, setPage] = useState(defaultValue); const [open, setOpen] = useState(null); const [tagList, setTagList] = useState(tagOptions); const [tagQuery, setTagQuery] = useState(""); const [reposition, setReposition] = useState<{ from: number } | null>(null); const triggers = useRef(new Map()); const coverRef = useRef(null); const titleRef = useRef(null); const dragY = useRef<{ y: number; start: number; pointer: number } | null>(null); const cover = covers.find((c) => c.id === page.cover) ?? covers[0]; const status = statuses.find((s) => s.id === page.status); const set = (patch: Partial) => { const next = { ...page, ...patch }; setPage(next); onChange?.(next); }; const close = (which = open) => { setOpen(null); if (which) requestAnimationFrame(() => triggers.current.get(which)?.focus()); }; const ref = (key: string) => (el: HTMLElement | null) => { if (el) triggers.current.set(key, el); else triggers.current.delete(key); }; useEffect(() => { const el = titleRef.current; if (el) { el.style.height = "0px"; el.style.height = `${el.scrollHeight}px`; } }, [page.title]); // Close popovers on an outside press. useEffect(() => { if (!open) return; const down = (e: PointerEvent) => { if (!(e.target as Element).closest(`[data-pop="${id}"]`)) setOpen(null); }; document.addEventListener("pointerdown", down); return () => document.removeEventListener("pointerdown", down); }, [open, id]); const startDrag = (e: ReactPointerEvent) => { if (!reposition) return; e.currentTarget.setPointerCapture(e.pointerId); dragY.current = { y: e.clientY, start: page.coverY, pointer: e.pointerId }; }; const moveDrag = (e: ReactPointerEvent) => { const d = dragY.current; if (!d || d.pointer !== e.pointerId || !coverRef.current) return; const h = coverRef.current.getBoundingClientRect().height; set({ coverY: Math.round(Math.min(100, Math.max(0, d.start - ((e.clientY - d.y) / h) * 100))) }); }; const coverKey = (e: KeyboardEvent) => { if (!reposition) return; if (e.key === "ArrowUp" || e.key === "ArrowDown") { e.preventDefault(); set({ coverY: Math.min(100, Math.max(0, page.coverY + (e.key === "ArrowUp" ? 5 : -5))) }); } if (e.key === "Enter") { e.preventDefault(); setReposition(null); } if (e.key === "Escape") { e.preventDefault(); set({ coverY: reposition.from }); setReposition(null); } }; const toggleTag = (label: string) => set({ tags: page.tags.includes(label) ? page.tags.filter((t) => t !== label) : [...page.tags, label] }); const createTag = () => { const label = tagQuery.trim(); if (!label) return; if (!tagList.some((t) => t.label.toLowerCase() === label.toLowerCase())) setTagList((l) => [...l, { id: label, label, color: COLORS[l.length % COLORS.length] }]); if (!page.tags.includes(label)) set({ tags: [...page.tags, label] }); setTagQuery(""); }; const tagColor = (label: string) => tagList.find((t) => t.label === label)?.color ?? "gray"; const matches = tagList.filter((t) => t.label.toLowerCase().includes(tagQuery.trim().toLowerCase())); return (
{ dragY.current = null; }} onKeyDown={coverKey} > {reposition &&

Drag image to reposition Β· ↑ ↓ Β· Enter to save

}
{reposition ? ( <> ) : ( <> {cover.image && } )} {open === "cover" && ( c.id)} selected={page.cover} onPick={(v) => { set({ cover: v, coverY: 50 }); close("cover"); }} onClose={() => close("cover")} render={(v) => { const c = covers.find((x) => x.id === v)!; return <>{c.label}; }} /> )}
{open === "icon" && ( { set({ icon: v }); close("icon"); }} onClose={() => close("icon")} render={(v) => {v}} /> )}