chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
@@ -0,0 +1,152 @@
"use client";
import { useEffect, useRef, useState } from "react";
export interface AutocompleteOption {
id: string;
label: string;
sub?: string; // secondary label shown in smaller text
}
interface AutocompleteInputProps {
options: AutocompleteOption[];
value: string; // selected id
onChange: (id: string) => void;
placeholder?: string;
className?: string;
}
function normalize(s: string) {
return s.toLowerCase().replace(/[-_\s]/g, "");
}
function scoreMatch(option: AutocompleteOption, query: string): number {
const q = normalize(query);
const label = normalize(option.label);
const sub = normalize(option.sub ?? "");
if (label.startsWith(q) || sub.startsWith(q)) return 2;
if (label.includes(q) || sub.includes(q)) return 1;
return 0;
}
export function AutocompleteInput({ options, value, onChange, placeholder = "Search…", className }: AutocompleteInputProps) {
const selected = options.find((o) => o.id === value);
const [input, setInput] = useState(selected?.label ?? "");
const [open, setOpen] = useState(false);
const [activeIdx, setActiveIdx] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Keep input text in sync when selected value changes externally
useEffect(() => {
setInput(selected?.label ?? "");
}, [selected?.label]);
const filtered = input.trim() === "" || (selected && input === selected.label)
? options.slice(0, 20)
: options
.map((o) => ({ o, score: scoreMatch(o, input) }))
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score)
.map(({ o }) => o)
.slice(0, 20);
function select(opt: AutocompleteOption) {
onChange(opt.id);
setInput(opt.label);
setOpen(false);
setActiveIdx(0);
}
function clear() {
onChange("");
setInput("");
setOpen(true);
setActiveIdx(0);
inputRef.current?.focus();
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "ArrowDown") { e.preventDefault(); setActiveIdx((i) => Math.min(i + 1, filtered.length - 1)); }
else if (e.key === "ArrowUp") { e.preventDefault(); setActiveIdx((i) => Math.max(i - 1, 0)); }
else if (e.key === "Enter") {
e.preventDefault();
const opt = filtered[activeIdx];
if (opt) select(opt);
} else if (e.key === "Escape") {
setOpen(false);
// Restore display text to match current selection
setInput(selected?.label ?? "");
}
}
// Close on outside click
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setInput(selected?.label ?? "");
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open, selected?.label]);
return (
<div ref={containerRef} className={`relative ${className ?? ""}`}>
<div className="relative">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => {
setInput(e.target.value);
setOpen(true);
setActiveIdx(0);
// Clear selection if user edits away from selected label
if (selected && e.target.value !== selected.label) onChange("");
}}
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="w-full px-3 py-2 pr-7 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white dark:bg-gray-900 dark:text-gray-100 dark:placeholder-gray-500"
/>
{value && (
<button
type="button"
onClick={clear}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 leading-none text-base"
aria-label="Clear"
>
×
</button>
)}
</div>
{open && filtered.length > 0 && (
<ul
className="absolute z-50 left-0 right-0 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden max-h-56 overflow-y-auto"
onMouseDown={(e) => e.preventDefault()}
>
{filtered.map((opt, idx) => (
<li key={opt.id}>
<button
type="button"
onMouseDown={(e) => { e.preventDefault(); select(opt); }}
className={`w-full text-left px-3 py-2 text-sm flex items-baseline gap-2 transition-colors ${
idx === activeIdx
? "bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300"
: "text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
}`}
>
<span className="truncate">{opt.label}</span>
{opt.sub && <span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">{opt.sub}</span>}
</button>
</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,53 @@
"use client";
import { clsx } from "clsx";
interface BatchAction {
label: string;
onClick: () => void;
variant?: "default" | "danger";
disabled?: boolean;
}
interface BatchActionBarProps {
count: number;
actions: BatchAction[];
onClear: () => void;
}
export function BatchActionBar({ count, actions, onClear }: BatchActionBarProps) {
if (count === 0) return null;
return (
<div className="fixed bottom-6 left-72 right-6 z-40 flex items-center gap-4 bg-gray-900 text-white px-5 py-3 rounded-xl shadow-2xl border border-gray-700">
<span className="text-sm font-medium shrink-0">
{count} selected
</span>
<div className="flex items-center gap-2 flex-1">
{actions.map((action) => (
<button
key={action.label}
type="button"
onClick={action.onClick}
disabled={action.disabled}
className={clsx(
"px-3 py-1.5 text-sm font-medium rounded-lg transition-colors disabled:opacity-50",
action.variant === "danger"
? "bg-red-600 hover:bg-red-700 text-white"
: "bg-white/10 hover:bg-white/20 text-white",
)}
>
{action.label}
</button>
))}
</div>
<button
type="button"
onClick={onClear}
className="text-sm text-gray-400 hover:text-white transition-colors"
>
Clear
</button>
</div>
);
}
@@ -0,0 +1,162 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import type { ColumnDef } from "@planarchy/shared";
interface ColumnTogglePanelProps {
allColumns: ColumnDef[];
visibleKeys: string[];
onSetVisible: (keys: string[]) => void;
defaultKeys: string[];
}
export function ColumnTogglePanel({
allColumns,
visibleKeys,
onSetVisible,
defaultKeys,
}: ColumnTogglePanelProps) {
const [open, setOpen] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
const dragKey = useRef<string | null>(null);
function toggle(key: string) {
const col = allColumns.find((c) => c.key === key);
if (!col?.hideable) return; // always-visible columns can't be toggled
const next = visibleKeys.includes(key)
? visibleKeys.filter((k) => k !== key)
: [...visibleKeys, key];
onSetVisible(next);
}
function reset() {
onSetVisible(defaultKeys);
}
const reorder = useCallback((fromKey: string, toKey: string) => {
if (fromKey === toKey) return;
const next = [...visibleKeys];
const from = next.indexOf(fromKey);
const to = next.indexOf(toKey);
if (from === -1 || to === -1) return;
next.splice(from, 1);
next.splice(to, 0, fromKey);
onSetVisible(next);
}, [visibleKeys, onSetVisible]);
const builtins = allColumns.filter((c) => !c.isCustom);
const customs = allColumns.filter((c) => c.isCustom);
return (
<div className="relative" ref={panelRef}>
<button
type="button"
onClick={() => setOpen((o) => !o)}
title="Toggle columns"
className={`p-1.5 rounded-lg border text-sm transition-colors ${
open
? "border-brand-400 bg-brand-50 text-brand-700"
: "border-gray-300 text-gray-500 hover:border-gray-400 hover:text-gray-700"
}`}
aria-label="Column visibility"
>
{/* columns icon */}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
<rect x="1" y="3" width="4" height="10" rx="1" stroke="currentColor" strokeWidth="1.5"/>
<rect x="6" y="3" width="4" height="10" rx="1" stroke="currentColor" strokeWidth="1.5"/>
<rect x="11" y="3" width="4" height="10" rx="1" stroke="currentColor" strokeWidth="1.5"/>
</svg>
</button>
{open && (
<div className="absolute right-0 top-full mt-1 z-50 w-52 bg-white border border-gray-200 rounded-xl shadow-xl py-2">
<div className="px-3 pb-1 flex items-center justify-between">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Columns</span>
<button
type="button"
onClick={reset}
className="text-xs text-brand-600 hover:text-brand-800"
>
Reset
</button>
</div>
<div className="max-h-72 overflow-y-auto">
{builtins.map((col) => {
const isVisible = visibleKeys.includes(col.key);
return (
<div
key={col.key}
draggable={col.hideable && isVisible}
onDragStart={() => { dragKey.current = col.key; }}
onDragOver={(e) => { e.preventDefault(); }}
onDrop={() => { if (dragKey.current) reorder(dragKey.current, col.key); dragKey.current = null; }}
className={`flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 ${
!col.hideable ? "opacity-50" : "cursor-grab"
}`}
>
{col.hideable && isVisible && (
<span className="text-gray-300 text-xs select-none"></span>
)}
<label className="flex items-center gap-2 flex-1 cursor-pointer">
<input
type="checkbox"
checked={isVisible}
onChange={() => toggle(col.key)}
disabled={!col.hideable}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<span className="text-sm text-gray-700">{col.label}</span>
</label>
</div>
);
})}
{customs.length > 0 && (
<>
<div className="my-1 border-t border-gray-100" />
<p className="px-3 py-1 text-xs text-gray-400 font-medium">Custom Fields</p>
{customs.map((col) => {
const isVisible = visibleKeys.includes(col.key);
return (
<div
key={col.key}
draggable={isVisible}
onDragStart={() => { dragKey.current = col.key; }}
onDragOver={(e) => { e.preventDefault(); }}
onDrop={() => { if (dragKey.current) reorder(dragKey.current, col.key); dragKey.current = null; }}
className="flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 cursor-grab"
>
{isVisible && <span className="text-gray-300 text-xs select-none"></span>}
<label className="flex items-center gap-2 flex-1 cursor-pointer">
<input
type="checkbox"
checked={isVisible}
onChange={() => toggle(col.key)}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<span className="text-sm text-gray-700">{col.label}</span>
</label>
</div>
);
})}
</>
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,57 @@
"use client";
interface ConfirmDialogProps {
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: "default" | "danger";
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmDialog({
title,
message,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
variant = "default",
onConfirm,
onCancel,
}: ConfirmDialogProps) {
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
onClick={(e) => {
if (e.target === e.currentTarget) onCancel();
}}
>
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md">
<div className="px-6 py-5">
<h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
<p className="text-sm text-gray-600">{message}</p>
</div>
<div className="flex items-center justify-end gap-3 px-6 pb-5">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
{cancelLabel}
</button>
<button
type="button"
onClick={onConfirm}
className={
variant === "danger"
? "px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors"
: "px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 transition-colors"
}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,105 @@
"use client";
import { FieldType } from "@planarchy/shared";
import type { BlueprintFieldDefinition } from "@planarchy/shared";
import type { CustomFieldFilter } from "~/hooks/useFilters.js";
interface Props {
/** Filterable field definitions from global blueprints */
filterableFields: (BlueprintFieldDefinition & { blueprintName: string })[];
activeFilters: CustomFieldFilter[];
onSetFilter: (key: string, value: string, type: FieldType) => void;
}
const INPUT_CLS =
"px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white";
export function CustomFieldFilterBar({ filterableFields, activeFilters, onSetFilter }: Props) {
if (filterableFields.length === 0) return null;
function getValue(key: string) {
return activeFilters.find((f) => f.key === key)?.value ?? "";
}
return (
<div className="flex flex-wrap items-center gap-2 mt-2">
<span className="text-xs font-medium text-gray-400 uppercase tracking-wider">Custom:</span>
{filterableFields.map((field) => {
const value = getValue(field.key);
if (field.type === FieldType.BOOLEAN) {
return (
<select
key={field.key}
value={value}
onChange={(e) => onSetFilter(field.key, e.target.value, field.type)}
className={INPUT_CLS}
aria-label={field.label}
>
<option value="">{field.label}: any</option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
);
}
if (field.type === FieldType.SELECT && field.options && field.options.length > 0) {
return (
<select
key={field.key}
value={value}
onChange={(e) => onSetFilter(field.key, e.target.value, field.type)}
className={INPUT_CLS}
aria-label={field.label}
>
<option value="">{field.label}: any</option>
{field.options.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label || opt.value}</option>
))}
</select>
);
}
if (field.type === FieldType.NUMBER) {
return (
<input
key={field.key}
type="number"
value={value}
onChange={(e) => onSetFilter(field.key, e.target.value, field.type)}
placeholder={field.label}
className={`${INPUT_CLS} w-32`}
aria-label={field.label}
/>
);
}
if (field.type === FieldType.DATE) {
return (
<input
key={field.key}
type="date"
value={value}
onChange={(e) => onSetFilter(field.key, e.target.value, field.type)}
className={INPUT_CLS}
aria-label={field.label}
/>
);
}
// TEXT, TEXTAREA, URL, EMAIL, MULTI_SELECT — text search
return (
<input
key={field.key}
type="text"
value={value}
onChange={(e) => onSetFilter(field.key, e.target.value, field.type)}
placeholder={field.label}
className={`${INPUT_CLS} w-40`}
aria-label={field.label}
/>
);
})}
</div>
);
}
+146
View File
@@ -0,0 +1,146 @@
"use client";
/**
* DateInput — always displays dd/mm/yyyy regardless of browser/OS locale.
*
* - Text field shows and accepts dd/mm/yyyy (auto-inserts slashes while typing)
* - Calendar icon opens a hidden <input type="date"> for native picker
* - Internal value / onChange contract: yyyy-mm-dd strings (same as <input type="date">)
*/
import { useState, useEffect, useRef } from "react";
import { clsx } from "clsx";
// ── Conversion helpers ────────────────────────────────────────────────────────
function isoToDisplay(iso: string): string {
if (!iso) return "";
const [y, m, d] = iso.split("-");
if (!y || !m || !d) return "";
return `${d}/${m}/${y}`;
}
function displayToISO(display: string): string {
const digits = display.replace(/\D/g, "");
if (digits.length !== 8) return "";
const d = digits.slice(0, 2);
const m = digits.slice(2, 4);
const y = digits.slice(4, 8);
if (parseInt(d) < 1 || parseInt(d) > 31) return "";
if (parseInt(m) < 1 || parseInt(m) > 12) return "";
if (parseInt(y) < 1900 || parseInt(y) > 2100) return "";
return `${y}-${m}-${d}`;
}
// Auto-insert slashes as the user types
function autoSlash(raw: string): string {
const digits = raw.replace(/\D/g, "").slice(0, 8);
if (digits.length <= 2) return digits;
if (digits.length <= 4) return `${digits.slice(0, 2)}/${digits.slice(2)}`;
return `${digits.slice(0, 2)}/${digits.slice(2, 4)}/${digits.slice(4)}`;
}
// ── Component ─────────────────────────────────────────────────────────────────
interface DateInputProps {
value: string; // yyyy-mm-dd (empty string = unset)
onChange: (v: string) => void; // called with yyyy-mm-dd when valid
id?: string;
className?: string;
required?: boolean;
min?: string; // yyyy-mm-dd
max?: string; // yyyy-mm-dd
disabled?: boolean;
}
export function DateInput({
value,
onChange,
id,
className,
required,
min,
max,
disabled,
}: DateInputProps) {
const [display, setDisplay] = useState(isoToDisplay(value));
const hiddenRef = useRef<HTMLInputElement>(null);
// Sync display when value is changed externally
useEffect(() => {
setDisplay(isoToDisplay(value));
}, [value]);
function handleTextChange(e: React.ChangeEvent<HTMLInputElement>) {
const formatted = autoSlash(e.target.value);
setDisplay(formatted);
const iso = displayToISO(formatted);
if (iso) onChange(iso);
}
function handleBlur() {
// Re-normalise display to canonical format on blur
const iso = displayToISO(display);
if (iso) {
setDisplay(isoToDisplay(iso));
} else if (display && display.replace(/\D/g, "").length < 8) {
// Incomplete — keep as-is so user can fix it
} else if (!display) {
// Cleared
setDisplay("");
}
}
function handleHiddenChange(e: React.ChangeEvent<HTMLInputElement>) {
const iso = e.target.value; // yyyy-mm-dd from native picker
if (iso) {
setDisplay(isoToDisplay(iso));
onChange(iso);
}
}
return (
<div className="relative flex items-center">
<input
id={id}
type="text"
inputMode="numeric"
placeholder="dd/mm/yyyy"
value={display}
onChange={handleTextChange}
onBlur={handleBlur}
disabled={disabled}
required={required}
className={clsx("pr-8", className)}
/>
{/* Hidden native date input driven by the calendar icon */}
<input
ref={hiddenRef}
type="date"
tabIndex={-1}
aria-hidden="true"
value={value}
min={min}
max={max}
onChange={handleHiddenChange}
className="sr-only absolute inset-0 w-full opacity-0 pointer-events-none"
/>
{/* Calendar icon — clicking opens the hidden picker */}
<button
type="button"
tabIndex={-1}
disabled={disabled}
aria-label="Open date picker"
onClick={() => hiddenRef.current?.showPicker()}
className="absolute right-2 text-gray-400 hover:text-gray-600 transition-colors disabled:opacity-40"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<line x1="16" y1="2" x2="16" y2="6" strokeWidth="2" strokeLinecap="round" />
<line x1="8" y1="2" x2="8" y2="6" strokeWidth="2" strokeLinecap="round" />
<line x1="3" y1="10" x2="21" y2="10" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
</div>
);
}
@@ -0,0 +1,71 @@
"use client";
import { useRef, useState } from "react";
interface DraggableTableRowProps {
id: string;
/** Shared ref across all rows in the table — holds the ID currently being dragged. */
dragRef: React.MutableRefObject<string | null>;
/**
* Called when another row is dropped onto this row.
* Receives the ID of the row that was dragged (not this row's ID).
*/
onDrop: (draggedId: string) => void;
children: React.ReactNode;
className?: string;
}
/**
* Table row with a left-side drag handle for manual row reordering.
*
* Usage: replace <tr> with <DraggableTableRow>, add <th className="w-8 px-2" /> as the
* first header cell. Each table shares one dragRef (useRef<string | null>(null)).
*/
export function DraggableTableRow({
id,
dragRef,
onDrop,
children,
className = "",
}: DraggableTableRowProps) {
const [isDragOver, setIsDragOver] = useState(false);
return (
<tr
className={`${className} ${isDragOver ? "border-t-2 border-brand-400" : ""}`}
onDragOver={(e) => {
e.preventDefault();
if (dragRef.current && dragRef.current !== id) setIsDragOver(true);
}}
onDragLeave={() => setIsDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setIsDragOver(false);
if (dragRef.current && dragRef.current !== id) {
// Pass the dragged ID so the caller knows what moved where
onDrop(dragRef.current);
}
dragRef.current = null;
}}
>
{/* Drag handle — left-most cell */}
<td
draggable
onDragStart={(e) => {
dragRef.current = id;
// Semi-transparent ghost
e.dataTransfer.effectAllowed = "move";
}}
onDragEnd={() => {
dragRef.current = null;
setIsDragOver(false);
}}
className="w-8 px-2 py-3 cursor-grab active:cursor-grabbing select-none text-gray-300 hover:text-gray-400 text-center"
title="Drag to reorder"
>
</td>
{children}
</tr>
);
}
+24
View File
@@ -0,0 +1,24 @@
"use client";
interface FilterBarProps {
children: React.ReactNode;
hasActiveFilters?: boolean;
onClearFilters?: () => void;
}
export function FilterBar({ children, hasActiveFilters, onClearFilters }: FilterBarProps) {
return (
<div className="mb-4 flex flex-wrap items-center gap-3">
{children}
{hasActiveFilters && onClearFilters && (
<button
type="button"
onClick={onClearFilters}
className="px-3 py-2 text-sm text-gray-500 hover:text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
Clear filters
</button>
)}
</div>
);
}
@@ -0,0 +1,41 @@
export interface Chip {
label: string;
onRemove: () => void;
}
interface FilterChipsProps {
chips: Chip[];
onClearAll: () => void;
}
export function FilterChips({ chips, onClearAll }: FilterChipsProps) {
if (chips.length === 0) return null;
return (
<div className="flex flex-wrap items-center gap-2">
{chips.map((chip) => (
<span
key={chip.label}
className="inline-flex items-center gap-1 rounded-full bg-brand-50 text-brand-700 border border-brand-200 px-2.5 py-0.5 text-xs"
>
{chip.label}
<button
type="button"
onClick={chip.onRemove}
className="ml-0.5 hover:text-brand-900 transition-colors"
aria-label={`Remove filter: ${chip.label}`}
>
×
</button>
</span>
))}
<button
type="button"
onClick={onClearAll}
className="text-xs text-gray-400 hover:text-gray-600 transition-colors"
>
Clear all
</button>
</div>
);
}
@@ -0,0 +1,33 @@
"use client";
import { useRef, useEffect } from "react";
interface InfiniteScrollSentinelProps {
onVisible: () => void;
isLoading: boolean;
}
export function InfiniteScrollSentinel({ onVisible, isLoading }: InfiniteScrollSentinelProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const obs = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting && !isLoading) onVisible();
},
{ threshold: 0.1 },
);
obs.observe(el);
return () => obs.disconnect();
}, [isLoading, onVisible]);
return (
<div ref={ref} className="h-6 flex items-center justify-center">
{isLoading && (
<div className="w-4 h-4 border-2 border-brand-500 border-t-transparent rounded-full animate-spin" />
)}
</div>
);
}
@@ -0,0 +1,96 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
interface InfoTooltipProps {
content: React.ReactNode;
/** Position relative to the trigger icon. Default: "top" */
position?: "top" | "bottom";
/** Extra width class, e.g. "w-72". Default: "w-60" */
width?: string;
}
/**
* Small icon that shows a tooltip on hover / focus.
* Rendered via a portal into document.body so it's never clipped by
* ancestor overflow:hidden containers (table cells, widget cards, etc.).
*/
export function InfoTooltip({ content, position = "top", width = "w-60" }: InfoTooltipProps) {
const [show, setShow] = useState(false);
const [coords, setCoords] = useState({ top: 0, left: 0 });
const btnRef = useRef<HTMLButtonElement>(null);
function computeCoords() {
if (!btnRef.current) return;
const rect = btnRef.current.getBoundingClientRect();
if (position === "top") {
setCoords({
top: rect.top + window.scrollY - 8, // 8px gap + arrow
left: rect.left + window.scrollX + rect.width / 2,
});
} else {
setCoords({
top: rect.bottom + window.scrollY + 8,
left: rect.left + window.scrollX + rect.width / 2,
});
}
}
function handleShow() {
computeCoords();
setShow(true);
}
// Recompute on scroll/resize while shown so tooltip follows the trigger
useEffect(() => {
if (!show) return;
const update = () => computeCoords();
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return () => {
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [show]);
const tooltipStyle: React.CSSProperties =
position === "top"
? { position: "fixed", top: coords.top, left: coords.left, transform: "translate(-50%, -100%)" }
: { position: "fixed", top: coords.top, left: coords.left, transform: "translateX(-50%)" };
const arrowClass =
position === "top"
? "top-full border-t-gray-900 border-l-transparent border-r-transparent border-b-transparent border-l-4 border-r-4 border-t-4 border-b-0"
: "bottom-full border-b-gray-900 border-l-transparent border-r-transparent border-t-transparent border-l-4 border-r-4 border-b-4 border-t-0";
return (
<span className="relative inline-flex items-center">
<button
ref={btnRef}
type="button"
onMouseEnter={handleShow}
onMouseLeave={() => setShow(false)}
onFocus={handleShow}
onBlur={() => setShow(false)}
className="ml-1 w-3.5 h-3.5 rounded-full bg-gray-200 dark:bg-gray-600 text-gray-500 dark:text-gray-300 text-[9px] font-bold flex items-center justify-center hover:bg-gray-300 dark:hover:bg-gray-500 cursor-help flex-shrink-0 leading-none"
aria-label="More information"
>
i
</button>
{show &&
createPortal(
<div
style={tooltipStyle}
className={`z-[9999] ${width} bg-gray-900 text-white text-xs rounded-lg px-3 py-2 shadow-xl pointer-events-none`}
>
{content}
<span className={`absolute left-1/2 -translate-x-1/2 w-0 h-0 ${arrowClass}`} />
</div>,
document.body,
)}
</span>
);
}
@@ -0,0 +1,69 @@
"use client";
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
/**
* Thin brand-colored progress bar at the top of the page.
* Animates to 100% on route change, then fades out.
* Pure CSS animation — no external dependency.
*/
export function NavProgressBar() {
const pathname = usePathname();
const searchParams = useSearchParams();
const [visible, setVisible] = useState(false);
const [width, setWidth] = useState(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const prevPathRef = useRef<string | null>(null);
// Detect link clicks to start the bar early
useEffect(() => {
function handleClick(e: MouseEvent) {
const target = (e.target as Element).closest("a");
if (!target) return;
const href = target.getAttribute("href");
if (!href || href.startsWith("http") || href.startsWith("#") || href.startsWith("mailto")) return;
// Internal navigation — start bar
setVisible(true);
setWidth(60); // jump to 60% immediately, await route change for completion
}
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, []);
// Complete bar when route actually changes
useEffect(() => {
const current = pathname + searchParams.toString();
if (prevPathRef.current !== null && prevPathRef.current !== current) {
// Route changed — complete the bar
setWidth(100);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
setVisible(false);
setWidth(0);
}, 350);
}
prevPathRef.current = current;
}, [pathname, searchParams]);
// Cleanup
useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
if (!visible && width === 0) return null;
return (
<div
aria-hidden="true"
className="fixed top-0 left-0 right-0 z-[9999] h-0.5 pointer-events-none"
>
<div
className="h-full bg-brand-500 transition-all ease-out"
style={{
width: `${width}%`,
transitionDuration: width === 100 ? "200ms" : "400ms",
opacity: visible ? 1 : 0,
}}
/>
</div>
);
}
@@ -0,0 +1,140 @@
"use client";
import { useState, useRef, useEffect, useMemo } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { useDebounce } from "~/hooks/useDebounce.js";
import type { ProjectStatus } from "@planarchy/shared";
interface ProjectComboboxProps {
value: string | null;
onChange: (id: string | null) => void;
placeholder?: string;
disabled?: boolean;
status?: ProjectStatus;
className?: string;
}
export function ProjectCombobox({
value,
onChange,
placeholder = "Search project…",
disabled = false,
status,
className = "",
}: ProjectComboboxProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const { data } = trpc.project.list.useQuery(
{ search: debouncedSearch || undefined, limit: 15, ...(status ? { status } : {}) },
{ enabled: open, staleTime: 30_000 },
);
const projects = data?.projects ?? [];
const { data: allData } = trpc.project.list.useQuery(
{ limit: 500 },
{ enabled: !!value && !open, staleTime: 60_000 },
);
const selectedLabel = useMemo(() => {
if (!value) return "";
const fromOpen = projects.find((p) => p.id === value);
if (fromOpen) return `${fromOpen.shortCode}${fromOpen.name}`;
const fromAll = allData?.projects.find((p) => p.id === value);
if (fromAll) return `${fromAll.shortCode}${fromAll.name}`;
return value;
}, [value, projects, allData]);
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setSearch("");
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
function handleOpen() {
if (disabled) return;
setOpen(true);
setSearch("");
setTimeout(() => inputRef.current?.focus(), 0);
}
function select(id: string | null) {
onChange(id);
setOpen(false);
setSearch("");
}
return (
<div className={`relative ${className}`} ref={containerRef}>
<button
type="button"
onClick={handleOpen}
disabled={disabled}
className={`w-full text-left px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 bg-white disabled:opacity-50 disabled:cursor-not-allowed ${
open ? "border-brand-500 ring-2 ring-brand-500" : "hover:border-gray-400"
}`}
>
<span className={selectedLabel ? "text-gray-900" : "text-gray-400"}>
{selectedLabel || placeholder}
</span>
{value && !disabled && (
<span
role="button"
tabIndex={0}
onMouseDown={(e) => { e.stopPropagation(); select(null); }}
onKeyDown={(e) => { if (e.key === "Enter") select(null); }}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 text-lg leading-none"
aria-label="Clear"
>
×
</span>
)}
</button>
{open && (
<div className="absolute left-0 right-0 top-full mt-1 z-50 bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden">
<div className="p-2 border-b border-gray-100">
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Type to search…"
className="w-full px-2 py-1 text-sm border-0 outline-none"
/>
</div>
<ul className="max-h-52 overflow-y-auto py-1">
{projects.length === 0 ? (
<li className="px-3 py-2 text-sm text-gray-400">No results</li>
) : (
projects.map((p) => (
<li key={p.id}>
<button
type="button"
onMouseDown={() => select(p.id)}
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-brand-50 ${
p.id === value ? "bg-brand-50 text-brand-700 font-medium" : "text-gray-700"
}`}
>
<span className="font-medium text-xs text-gray-400 mr-1.5">{p.shortCode}</span>
<span>{p.name}</span>
</button>
</li>
))
)}
</ul>
</div>
)}
</div>
);
}
@@ -0,0 +1,140 @@
"use client";
import { useState, useRef, useEffect, useMemo } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { useDebounce } from "~/hooks/useDebounce.js";
interface ResourceComboboxProps {
value: string | null;
onChange: (id: string | null) => void;
placeholder?: string;
disabled?: boolean;
isActive?: boolean;
className?: string;
}
export function ResourceCombobox({
value,
onChange,
placeholder = "Search resource…",
disabled = false,
isActive = true,
className = "",
}: ResourceComboboxProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const { data } = trpc.resource.list.useQuery(
{ search: debouncedSearch || undefined, limit: 15, isActive },
{ enabled: open, staleTime: 30_000 },
);
const resources = data?.resources ?? [];
// Resolve display name for currently selected value
const { data: selectedData } = trpc.resource.list.useQuery(
{ search: undefined, limit: 500, isActive: undefined as unknown as boolean },
{ enabled: !!value && !open, staleTime: 60_000 },
);
const selectedLabel = useMemo(() => {
if (!value) return "";
const fromOpen = resources.find((r) => r.id === value);
if (fromOpen) return `${fromOpen.displayName} (${fromOpen.eid})`;
const fromSelected = selectedData?.resources.find((r) => r.id === value);
if (fromSelected) return `${fromSelected.displayName} (${fromSelected.eid})`;
return value;
}, [value, resources, selectedData]);
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setSearch("");
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
function handleOpen() {
if (disabled) return;
setOpen(true);
setSearch("");
setTimeout(() => inputRef.current?.focus(), 0);
}
function select(id: string | null) {
onChange(id);
setOpen(false);
setSearch("");
}
return (
<div className={`relative ${className}`} ref={containerRef}>
<button
type="button"
onClick={handleOpen}
disabled={disabled}
className={`w-full text-left px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 bg-white disabled:opacity-50 disabled:cursor-not-allowed ${
open ? "border-brand-500 ring-2 ring-brand-500" : "hover:border-gray-400"
}`}
>
<span className={selectedLabel ? "text-gray-900" : "text-gray-400"}>
{selectedLabel || placeholder}
</span>
{value && !disabled && (
<span
role="button"
tabIndex={0}
onMouseDown={(e) => { e.stopPropagation(); select(null); }}
onKeyDown={(e) => { if (e.key === "Enter") select(null); }}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 text-lg leading-none"
aria-label="Clear"
>
×
</span>
)}
</button>
{open && (
<div className="absolute left-0 right-0 top-full mt-1 z-50 bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden">
<div className="p-2 border-b border-gray-100">
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Type to search…"
className="w-full px-2 py-1 text-sm border-0 outline-none"
/>
</div>
<ul className="max-h-52 overflow-y-auto py-1">
{resources.length === 0 ? (
<li className="px-3 py-2 text-sm text-gray-400">No results</li>
) : (
resources.map((r) => (
<li key={r.id}>
<button
type="button"
onMouseDown={() => select(r.id)}
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-brand-50 ${
r.id === value ? "bg-brand-50 text-brand-700 font-medium" : "text-gray-700"
}`}
>
<span>{r.displayName}</span>
<span className="ml-1.5 text-xs text-gray-400">{r.eid}</span>
</button>
</li>
))
)}
</ul>
</div>
)}
</div>
);
}
@@ -0,0 +1,139 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
interface SkillTagInputProps {
value: string[];
onChange: (skills: string[]) => void;
placeholder?: string;
className?: string;
}
export function SkillTagInput({ value, onChange, placeholder = "Add skill…", className }: SkillTagInputProps) {
const [input, setInput] = useState("");
const [open, setOpen] = useState(false);
const [activeIdx, setActiveIdx] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const { data: analytics } = trpc.resource.getSkillsAnalytics.useQuery(undefined, {
staleTime: 120_000,
});
const aggregated: { skill: string }[] = analytics?.aggregated ?? [];
const suggestions: string[] = aggregated
.map((a) => a.skill)
.filter((s) => !value.includes(s) && s.toLowerCase().includes(input.toLowerCase()))
.slice(0, 8);
function addSkill(skill: string) {
const trimmed = skill.trim();
if (trimmed && !value.includes(trimmed)) {
onChange([...value, trimmed]);
}
setInput("");
setOpen(false);
setActiveIdx(-1);
inputRef.current?.focus();
}
function removeSkill(skill: string) {
onChange(value.filter((s) => s !== skill));
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") {
e.preventDefault();
if (activeIdx >= 0 && suggestions[activeIdx]) {
addSkill(suggestions[activeIdx]);
} else if (input.trim()) {
addSkill(input);
}
} else if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIdx((i) => Math.min(i + 1, suggestions.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIdx((i) => Math.max(i - 1, -1));
} else if (e.key === "Escape") {
setOpen(false);
setActiveIdx(-1);
} else if (e.key === "Backspace" && input === "" && value.length > 0) {
onChange(value.slice(0, -1));
}
}
// Close on outside click
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
return (
<div ref={containerRef} className={`relative ${className ?? ""}`}>
{/* Tags + input row */}
<div
className="min-h-[42px] flex flex-wrap gap-1.5 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 cursor-text focus-within:ring-2 focus-within:ring-brand-500 focus-within:border-transparent"
onClick={() => inputRef.current?.focus()}
>
{value.map((skill) => (
<span
key={skill}
className="inline-flex items-center gap-1 px-2 py-0.5 bg-brand-100 dark:bg-brand-900/40 text-brand-800 dark:text-brand-300 text-xs font-medium rounded-full"
>
{skill}
<button
type="button"
onClick={() => removeSkill(skill)}
className="hover:text-brand-600 dark:hover:text-brand-200 leading-none"
aria-label={`Remove ${skill}`}
>
×
</button>
</span>
))}
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => { setInput(e.target.value); setOpen(true); setActiveIdx(-1); }}
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}
placeholder={value.length === 0 ? placeholder : ""}
className="flex-1 min-w-[120px] outline-none bg-transparent text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500"
/>
</div>
{/* Dropdown */}
{open && suggestions.length > 0 && (
<ul
className="absolute z-50 left-0 right-0 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden max-h-48 overflow-y-auto"
onMouseDown={(e) => e.preventDefault()}
>
{suggestions.map((skill, idx) => (
<li key={skill}>
<button
type="button"
onMouseDown={(e) => { e.preventDefault(); addSkill(skill); }}
className={`w-full text-left px-3 py-2 text-sm transition-colors ${
idx === activeIdx
? "bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300"
: "text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
}`}
>
{skill}
</button>
</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,73 @@
import { InfoTooltip } from "./InfoTooltip.js";
interface SortIconProps {
dir: "asc" | "desc" | null;
}
function SortIcon({ dir }: SortIconProps) {
return (
<span className="inline-flex flex-col leading-none ml-0.5">
<svg width="8" height="12" viewBox="0 0 8 12" fill="none" aria-hidden>
{/* Up chevron */}
<path
d="M1 5L4 2L7 5"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className={dir === "asc" ? "stroke-brand-600" : "stroke-gray-300"}
/>
{/* Down chevron */}
<path
d="M1 7L4 10L7 7"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className={dir === "desc" ? "stroke-brand-600" : "stroke-gray-300"}
/>
</svg>
</span>
);
}
interface SortableColumnHeaderProps {
label: string;
field: string;
sortField: string | null;
sortDir: "asc" | "desc" | null;
onSort: (field: string) => void;
className?: string;
align?: "left" | "right" | "center";
tooltip?: string;
tooltipWidth?: string;
}
export function SortableColumnHeader({
label,
field,
sortField,
sortDir,
onSort,
className = "",
align = "left",
tooltip,
tooltipWidth,
}: SortableColumnHeaderProps) {
const activeDir = sortField === field ? sortDir : null;
const alignClass = align === "right" ? "justify-end" : align === "center" ? "justify-center" : "justify-start";
return (
<th className={`px-3 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider ${className}`}>
<div className={`flex items-center gap-0.5 ${alignClass}`}>
<button
type="button"
onClick={() => onSort(field)}
className="flex items-center gap-0.5 hover:text-gray-700 transition-colors group"
>
<span>{label}</span>
<SortIcon dir={activeDir} />
</button>
{tooltip && <InfoTooltip content={tooltip} {...(tooltipWidth !== undefined ? { width: tooltipWidth } : {})} />}
</div>
</th>
);
}