audit log, character list

This commit is contained in:
Dreamgineer
2025-10-23 04:31:57 -04:00 Unverified
parent ac69b256e3
commit fa25a97306
35 changed files with 1305 additions and 872 deletions
-1
View File
@@ -1,6 +1,5 @@
import { CircleX } from "lucide-react";
import type React from "react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export function Blocker({
-304
View File
@@ -1,304 +0,0 @@
"use client";
import {
expandAllFeature,
hotkeysCoreFeature,
searchFeature,
selectionFeature,
syncDataLoaderFeature,
type TreeState,
} from "@headless-tree/core";
import { useTree } from "@headless-tree/react";
import {
CircleXIcon,
FilterIcon,
FolderIcon,
FolderOpenIcon,
} from "lucide-react";
import type React from "react";
import { useEffect, useRef, useState } from "react";
import { Tree, TreeItem, TreeItemLabel } from "@/components/tree";
import { Input } from "@/components/ui/input";
interface Item {
name: string;
children?: string[];
}
const items: Record<string, Item> = {
company: {
name: "Company",
children: ["engineering", "marketing", "operations"],
},
engineering: {
name: "Engineering",
children: ["frontend", "backend", "platform-team"],
},
frontend: { name: "Frontend", children: ["design-system", "web-platform"] },
"design-system": {
name: "Design System",
children: ["components", "tokens", "guidelines"],
},
components: { name: "Components" },
tokens: { name: "Tokens" },
guidelines: { name: "Guidelines" },
"web-platform": { name: "Web Platform" },
backend: { name: "Backend", children: ["apis", "infrastructure"] },
apis: { name: "APIs" },
infrastructure: { name: "Infrastructure" },
"platform-team": { name: "Platform Team" },
marketing: { name: "Marketing", children: ["content", "seo"] },
content: { name: "Content" },
seo: { name: "SEO" },
operations: { name: "Operations", children: ["hr", "finance"] },
hr: { name: "HR" },
finance: { name: "Finance" },
};
const indent = 20;
export default function Component() {
// Store the initial expanded items to reset when search is cleared
const initialExpandedItems = ["engineering", "frontend", "design-system"];
const [state, setState] = useState<Partial<TreeState<Item>>>({});
const [searchValue, setSearchValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const tree = useTree<Item>({
state,
setState,
initialState: {
expandedItems: initialExpandedItems,
},
indent,
rootItemId: "company",
getItemName: (item) => item.getItemData().name,
isItemFolder: (item) => (item.getItemData()?.children?.length ?? 0) > 0,
dataLoader: {
getItem: (itemId) => items[itemId],
getChildren: (itemId) => items[itemId].children ?? [],
},
features: [
syncDataLoaderFeature,
hotkeysCoreFeature,
selectionFeature,
searchFeature,
expandAllFeature,
],
});
// Handle clearing the search
const handleClearSearch = () => {
setSearchValue("");
// Manually trigger the tree's search onChange with an empty value
// to ensure item.isMatchingSearch() is correctly updated.
const searchProps = tree.getSearchInputElementProps();
if (searchProps.onChange) {
const syntheticEvent = {
target: { value: "" },
} as React.ChangeEvent<HTMLInputElement>; // Cast to the expected event type
searchProps.onChange(syntheticEvent);
}
// Reset tree state to initial expanded items
setState((prevState) => ({
...prevState,
expandedItems: initialExpandedItems,
}));
// Clear custom filtered items
setFilteredItems([]);
if (inputRef.current) {
inputRef.current.focus();
// Also clear the internal search input
inputRef.current.value = "";
}
};
// Keep track of filtered items separately from the tree's internal search state
const [filteredItems, setFilteredItems] = useState<string[]>([]);
// This function determines if an item should be visible based on our custom filtering
const shouldShowItem = (itemId: string) => {
if (!searchValue || searchValue.length === 0) return true;
return filteredItems.includes(itemId);
};
// Update filtered items when search value changes
useEffect(() => {
if (!searchValue || searchValue.length === 0) {
setFilteredItems([]);
return;
}
// Get all items
const allItems = tree.getItems();
// First, find direct matches
const directMatches = allItems
.filter((item) => {
const name = item.getItemName().toLowerCase();
return name.includes(searchValue.toLowerCase());
})
.map((item) => item.getId());
// Then, find all parent IDs of matching items
const parentIds = new Set<string>();
directMatches.forEach((matchId) => {
let item = tree.getItems().find((i) => i.getId() === matchId);
while (item?.getParent?.()) {
const parent = item.getParent();
if (parent) {
parentIds.add(parent.getId());
item = parent;
} else {
break;
}
}
});
// Find all children of matching items
const childrenIds = new Set<string>();
directMatches.forEach((matchId) => {
const item = tree.getItems().find((i) => i.getId() === matchId);
if (item?.isFolder()) {
// Get all descendants recursively
const getDescendants = (itemId: string) => {
const children = items[itemId]?.children || [];
children.forEach((childId) => {
childrenIds.add(childId);
if (items[childId]?.children?.length) {
getDescendants(childId);
}
});
};
getDescendants(item.getId());
}
});
// Combine direct matches, parents, and children
setFilteredItems([
...directMatches,
...Array.from(parentIds),
...Array.from(childrenIds),
]);
// Keep all folders expanded during search to ensure all matches are visible
// Store current expanded state first
const currentExpandedItems = tree.getState().expandedItems || [];
// Get all folder IDs that need to be expanded to show matches
const folderIdsToExpand = allItems
.filter((item) => item.isFolder())
.map((item) => item.getId());
// Update expanded items in the tree state
setState((prevState) => ({
...prevState,
expandedItems: [
...new Set([...currentExpandedItems, ...folderIdsToExpand]),
],
}));
}, [searchValue, tree]);
return (
<div className="flex h-full flex-col gap-2 *:nth-2:grow">
<div className="relative">
<Input
ref={inputRef}
className="peer ps-9"
value={searchValue}
onChange={(e) => {
const value = e.target.value;
setSearchValue(value);
// Apply the search to the tree's internal state as well
const searchProps = tree.getSearchInputElementProps();
if (searchProps.onChange) {
searchProps.onChange(e);
}
if (value.length > 0) {
// If input has at least one character, expand all items
tree.expandAll();
} else {
// If input is cleared, reset to initial expanded state
setState((prevState) => ({
...prevState,
expandedItems: initialExpandedItems,
}));
setFilteredItems([]);
}
}}
// Prevent the internal search from being cleared on blur
onBlur={(e) => {
// Prevent default blur behavior
e.preventDefault();
// Re-apply the search to ensure it stays active
if (searchValue && searchValue.length > 0) {
const searchProps = tree.getSearchInputElementProps();
if (searchProps.onChange) {
const syntheticEvent = {
target: { value: searchValue },
} as React.ChangeEvent<HTMLInputElement>;
searchProps.onChange(syntheticEvent);
}
}
}}
type="search"
placeholder="Filter items..."
/>
<div className="text-muted-foreground/80 pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 peer-disabled:opacity-50">
<FilterIcon className="size-4" aria-hidden="true" />
</div>
{searchValue && (
<button
className="text-muted-foreground/80 hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 absolute inset-y-0 end-0 flex h-full w-9 items-center justify-center rounded-e-md transition-[color,box-shadow] outline-none focus:z-10 focus-visible:ring-[3px] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Clear search"
onClick={handleClearSearch}
type="button"
>
<CircleXIcon className="size-4" aria-hidden="true" />
</button>
)}
</div>
<Tree indent={indent} tree={tree}>
{searchValue && filteredItems.length === 0 ? (
<p className="px-3 py-4 text-center text-sm">
No items found for "{searchValue}"
</p>
) : (
tree.getItems().map((item) => {
const isVisible = shouldShowItem(item.getId());
return (
<TreeItem
key={item.getId()}
item={item}
data-visible={isVisible || !searchValue}
className="data-[visible=false]:hidden"
>
<TreeItemLabel>
<span className="flex items-center gap-2">
{item.isFolder() &&
(item.isExpanded() ? (
<FolderOpenIcon className="text-muted-foreground pointer-events-none size-4" />
) : (
<FolderIcon className="text-muted-foreground pointer-events-none size-4" />
))}
{item.getItemName()}
</span>
</TreeItemLabel>
</TreeItem>
);
})
)}
</Tree>
</div>
);
}
+390
View File
@@ -0,0 +1,390 @@
"use client";
import { Search, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { Spinner } from "./ui/spinner";
type Token =
| { type: "text"; value: string; start: number; end: number }
| { type: "filter"; key: string; value?: string; start: number; end: number };
interface InputContext {
mode: "text" | "filterKey" | "filterValue";
activeFilter?: string;
token?: Token;
}
export interface ParsedQuery {
search: string;
filters: Record<string, string>;
}
/** Split text into tokens and track their ranges */
function tokenize(input: string): Token[] {
const tokens: Token[] = [];
let cursor = 0;
const parts = input.split(/(\s+)/);
for (const part of parts) {
const start = cursor;
const end = start + part.length;
cursor = end;
if (!part.trim()) continue;
const colonIndex = part.indexOf(":");
if (colonIndex !== -1) {
const key = part.slice(0, colonIndex);
const val = part.slice(colonIndex + 1);
tokens.push({
type: "filter",
key,
value: val || undefined,
start,
end,
});
} else {
tokens.push({ type: "text", value: part, start, end });
}
}
return tokens;
}
/** Parse query into search text and filters */
function parseQuery(input: string): ParsedQuery {
const tokens = tokenize(input);
const searchParts: string[] = [];
const filters: Record<string, string> = {};
for (const token of tokens) {
if (token.type === "filter" && token.value) {
filters[token.key] = token.value;
} else if (token.type === "text") {
searchParts.push(token.value);
}
}
return {
search: searchParts.join(" ").trim(),
filters,
};
}
/** Determine which token and mode the caret is inside */
function detectContext(input: string, caret: number): InputContext {
const tokens = tokenize(input);
// Find token at caret position
const token = tokens.find((t) => caret >= t.start && caret <= t.end);
if (!token) {
// Check if we're right after a token
const prevToken = tokens.find((t) => t.end === caret);
if (prevToken?.type === "filter" && !prevToken.value) {
return {
mode: "filterValue",
activeFilter: prevToken.key,
token: prevToken,
};
}
return { mode: "text" };
}
if (token.type === "filter") {
const colonPos = input.indexOf(":", token.start);
if (colonPos === -1 || caret <= colonPos) {
return { mode: "filterKey", token };
} else {
return { mode: "filterValue", activeFilter: token.key, token };
}
}
// Check if text token looks like it's starting a filter
if (token.type === "text" && token.value.endsWith(":")) {
const key = token.value.slice(0, -1);
return { mode: "filterValue", activeFilter: key, token };
}
return { mode: "filterKey", token };
}
export type Filter = () => Promise<string[]> | string[];
export type Filters = Record<string, Filter>;
/** Filter colors for highlighting */
const filterColors: string[] = [
"bg-blue-700 text-blue-100",
"bg-purple-700 text-purple-100",
"bg-green-700 text-green-100",
"bg-orange-700 text-orange-100",
"bg-yellow-700 text-yellow-100",
"bg-red-700 text-red-100",
];
/** Get suggestions based on caret context */
async function getSuggestions(input: string, caret: number, filters: Filters) {
const ctx = detectContext(input, caret);
if (ctx.mode === "filterKey") {
const partial =
ctx.token?.type === "text" ? ctx.token.value.toLowerCase() : "";
return Object.keys(filters)
.filter((f) => f.startsWith(partial))
.map((f) => `${f}:`);
}
if (ctx.mode === "filterValue" && ctx.activeFilter) {
const source = filters[ctx.activeFilter];
if (source) {
const values = await source();
const partial =
ctx.token?.type === "filter" && ctx.token.value
? ctx.token.value.toLowerCase()
: "";
return values.filter((v) => v.toLowerCase().includes(partial));
}
}
return [];
}
export default function SearchBox({
onChange = () => {}, // Prevent react screaming about undefined dependencies
onQueryChange = () => {},
filters,
value = "",
className,
}: {
onChange?: (value: string) => void;
onQueryChange?: (value: ParsedQuery) => void;
className?: string;
filters: Filters;
value?: string;
}) {
const [query, setQuery] = useState(value);
useEffect(() => onChange?.(query), [onChange, query]);
const [suggestionLoading, setSuggestionLoading] = useState(false);
const [suggestions, setSuggestions] = useState<string[]>([]);
const [highlight, setHighlight] = useState(0);
const [isOpen, setIsOpen] = useState(false);
const [parsedQuery, setParsedQuery] = useState<ParsedQuery>({
search: "",
filters: {},
});
useEffect(() => onQueryChange?.(parsedQuery), [onQueryChange, parsedQuery]);
const inputRef = useRef<HTMLInputElement>(null);
async function updateSuggestions(newValue: string, caretPos?: number) {
const caret =
caretPos ?? inputRef.current?.selectionStart ?? newValue.length;
setSuggestionLoading(true);
const items = await getSuggestions(newValue, caret, filters).finally(() =>
setSuggestionLoading(false),
);
setSuggestions(items);
setHighlight(0);
setIsOpen(items.length > 0);
setParsedQuery(parseQuery(newValue));
}
function applySuggestion(s: string) {
const caret = inputRef.current?.selectionStart ?? query.length;
const ctx = detectContext(query, caret);
if (!ctx.token) {
setQuery(`${query}${s} `);
setIsOpen(false);
inputRef.current?.focus();
return;
}
const before = query.slice(0, ctx.token.start);
const after = query.slice(ctx.token.end);
let replacement = "";
if (ctx.mode === "filterKey") {
replacement = s;
} else if (ctx.mode === "filterValue") {
replacement = `${ctx.activeFilter}:${s}`;
}
const newQuery = `${before}${replacement}${after}`.trim();
setQuery(`${newQuery} `);
setIsOpen(false);
setParsedQuery(parseQuery(`${newQuery} `));
// Focus back and move caret to end
setTimeout(() => {
inputRef.current?.focus();
const newPos = before.length + replacement.length + 1;
inputRef.current?.setSelectionRange(newPos, newPos);
}, 0);
}
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (inputRef.current && !inputRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Render highlighted input
function renderHighlightedInput() {
const tokens = tokenize(query);
const parts: React.ReactNode[] = [];
let lastEnd = 0;
tokens.forEach((token, i) => {
const idx = i;
// Add any text before this token
if (token.start > lastEnd) {
parts.push(
<span key={`gap-${idx}`}>{query.slice(lastEnd, token.start)}</span>,
);
}
// Add the token
if (token.type === "filter" && token.value) {
const filterId = Object.keys(filters).indexOf(token.key);
const colorClass =
filterId < 0
? "text-gray-500"
: filterColors[filterId % filterColors.length];
parts.push(
<span key={`token-${idx}`} className={`${colorClass} rounded`}>
{query.slice(token.start, token.end)}
</span>,
);
} else {
parts.push(
<span key={`token-${idx}`}>
{query.slice(token.start, token.end)}
</span>,
);
}
lastEnd = token.end;
});
// Add any remaining text
if (lastEnd < query.length) {
parts.push(<span key="end">{query.slice(lastEnd)}</span>);
}
return parts;
}
return (
<div className="w-full max-w-2xl">
<div className="relative">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 z-10" />
{/* Highlighted overlay */}
<div className="absolute inset-0 flex items-center pl-9 pr-9 pointer-events-none overflow-hidden">
<div className="whitespace-pre text-sm">
{renderHighlightedInput()}
</div>
</div>
{/* Actual input (transparent text) */}
<input
ref={inputRef}
value={query}
onChange={(e) => {
setQuery(e.target.value);
updateSuggestions(e.target.value);
}}
onFocus={() => {
if (suggestions.length > 0) setIsOpen(true);
}}
onKeyDown={(e) => {
if (!isOpen) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
updateSuggestions(query);
}
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlight((i) => Math.min(i + 1, suggestions.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlight((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter" && suggestions[highlight]) {
e.preventDefault();
applySuggestion(suggestions[highlight]);
} else if (e.key === "Escape") {
setIsOpen(false);
} else if (e.key === "Tab" && suggestions[highlight]) {
e.preventDefault();
applySuggestion(suggestions[highlight]);
}
}}
className={cn(
"relative w-full h-10 pl-9 pr-9 rounded-md border bg-transparent text-sm text-transparent caret-black ring-offset-gray-400 placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-slate-950 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
placeholder={`Search...${Object.keys(filters).length ? " Try: " : ""}${Object.keys(
filters,
)
.slice(0, 4)
.map((f) => `${f}:`)
.join(" ")}`}
/>
{query && (
<button
type="button"
onClick={() => {
setQuery("");
setSuggestions([]);
setIsOpen(false);
setParsedQuery({ search: "", filters: {} });
inputRef.current?.focus();
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors z-10"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{isOpen && (suggestions.length > 0 || suggestionLoading) && (
<div className="absolute z-50 w-full mt-1 rounded-md border bg-card shadow-lg overflow-hidden">
<ul className="max-h-64 overflow-auto py-1">
{suggestionLoading ? (
<li className="relative flex cursor-pointer select-none items-center rounded-sm px-3 py-2 text-sm outline-none mx-1 hover:brightness-125 gap-2">
<Spinner /> Loading...
</li>
) : (
suggestions.map((s, i) => (
<li
key={`${s}-${i + 1}`}
onClick={() => applySuggestion(s)}
onMouseEnter={() => setHighlight(i)}
className={`
relative flex cursor-pointer select-none items-center rounded-sm px-3 py-2 text-sm outline-none mx-1
${i === highlight ? "bg-muted" : "hover:brightness-125"}
`}
>
{s}
</li>
))
)}
</ul>
</div>
)}
</div>
</div>
);
}
-195
View File
@@ -1,195 +0,0 @@
"use client";
import type { ItemInstance } from "@headless-tree/core";
import { ChevronDownIcon } from "lucide-react";
import { Slot } from "radix-ui";
import * as React from "react";
import { cn } from "@/lib/utils";
interface TreeContextValue<T = any> {
indent: number;
currentItem?: ItemInstance<T>;
tree?: any;
}
const TreeContext = React.createContext<TreeContextValue>({
indent: 20,
currentItem: undefined,
tree: undefined,
});
function useTreeContext<T = any>() {
return React.useContext(TreeContext) as TreeContextValue<T>;
}
interface TreeProps extends React.HTMLAttributes<HTMLDivElement> {
indent?: number;
tree?: any;
}
function Tree({ indent = 20, tree, className, ...props }: TreeProps) {
const containerProps =
tree && typeof tree.getContainerProps === "function"
? tree.getContainerProps()
: {};
const mergedProps = { ...props, ...containerProps };
// Extract style from mergedProps to merge with our custom styles
const { style: propStyle, ...otherProps } = mergedProps;
// Merge styles
const mergedStyle = {
...propStyle,
"--tree-indent": `${indent}px`,
} as React.CSSProperties;
return (
<TreeContext.Provider value={{ indent, tree }}>
<div
data-slot="tree"
style={mergedStyle}
className={cn("flex flex-col", className)}
{...otherProps}
/>
</TreeContext.Provider>
);
}
interface TreeItemProps<T = any>
extends React.HTMLAttributes<HTMLButtonElement> {
item: ItemInstance<T>;
indent?: number;
asChild?: boolean;
}
function TreeItem<T = any>({
item,
className,
asChild,
children,
...props
}: Omit<TreeItemProps<T>, "indent">) {
const { indent } = useTreeContext<T>();
const itemProps = typeof item.getProps === "function" ? item.getProps() : {};
const mergedProps = { ...props, ...itemProps };
// Extract style from mergedProps to merge with our custom styles
const { style: propStyle, ...otherProps } = mergedProps;
// Merge styles
const mergedStyle = {
...propStyle,
"--tree-padding": `${item.getItemMeta().level * indent}px`,
} as React.CSSProperties;
const Comp = asChild ? Slot.Root : "button";
return (
<TreeContext.Provider value={{ indent, currentItem: item }}>
<Comp
data-slot="tree-item"
style={mergedStyle}
className={cn(
"z-10 ps-(--tree-padding) outline-hidden select-none not-last:pb-0.5 focus:z-20 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
data-focus={
typeof item.isFocused === "function"
? item.isFocused() || false
: undefined
}
data-folder={
typeof item.isFolder === "function"
? item.isFolder() || false
: undefined
}
data-selected={
typeof item.isSelected === "function"
? item.isSelected() || false
: undefined
}
data-drag-target={
typeof item.isDragTarget === "function"
? item.isDragTarget() || false
: undefined
}
data-search-match={
typeof item.isMatchingSearch === "function"
? item.isMatchingSearch() || false
: undefined
}
aria-expanded={item.isExpanded()}
{...otherProps}
>
{children}
</Comp>
</TreeContext.Provider>
);
}
interface TreeItemLabelProps<T = any>
extends React.HTMLAttributes<HTMLSpanElement> {
item?: ItemInstance<T>;
}
function TreeItemLabel<T = any>({
item: propItem,
children,
className,
...props
}: TreeItemLabelProps<T>) {
const { currentItem } = useTreeContext<T>();
const item = propItem || currentItem;
if (!item) {
console.warn("TreeItemLabel: No item provided via props or context");
return null;
}
return (
<span
data-slot="tree-item-label"
className={cn(
"in-focus-visible:ring-ring/50 bg-background hover:bg-accent in-data-[selected=true]:bg-accent in-data-[selected=true]:text-accent-foreground in-data-[drag-target=true]:bg-accent flex items-center gap-1 rounded-sm px-2 py-1.5 text-sm transition-colors not-in-data-[folder=true]:ps-7 in-focus-visible:ring-[3px] in-data-[search-match=true]:bg-blue-400/20! [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
{item.isFolder() && (
<ChevronDownIcon className="text-muted-foreground size-4 in-aria-[expanded=false]:-rotate-90" />
)}
{children ||
(typeof item.getItemName === "function" ? item.getItemName() : null)}
</span>
);
}
function TreeDragLine({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
const { tree } = useTreeContext();
if (!tree || typeof tree.getDragLineStyle !== "function") {
console.warn(
"TreeDragLine: No tree provided via context or tree does not have getDragLineStyle method",
);
return null;
}
const dragLine = tree.getDragLineStyle();
return (
<div
style={dragLine}
className={cn(
"bg-primary before:bg-background before:border-primary absolute z-30 -mt-px h-0.5 w-[unset] before:absolute before:-top-[3px] before:left-0 before:size-2 before:rounded-full before:border-2",
className,
)}
{...props}
/>
);
}
export { Tree, TreeItem, TreeItemLabel, TreeDragLine };