first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:52:23 +03:00
commit 880f412e2c
2662 changed files with 866266 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Columns3, RotateCcw } from "lucide-react";
import type { ColumnConfigEntry } from "./hooks/useColumnConfig";
interface ColumnConfigDropdownProps {
entries: ColumnConfigEntry[];
labels?: Record<string, string>;
onToggleVisibility: (columnId: string) => void;
onReset: () => void;
}
function formatColumnId(id: string): string {
return id
.replace(/^metadata_/, "")
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
export function ColumnConfigDropdown({ entries, labels = {}, onToggleVisibility, onReset }: ColumnConfigDropdownProps) {
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-7.5 w-7.5" data-testid="column-config-trigger" aria-label="Column configuration">
<Columns3 className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-2" align="end">
<div className="space-y-1">
<div className="text-muted-foreground px-1 pb-1 text-xs font-medium">Toggle Columns</div>
{entries.map((entry) => (
<label key={entry.id} className="hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-1 py-1">
<Checkbox
checked={entry.visible}
onCheckedChange={() => onToggleVisibility(entry.id)}
data-testid={`column-visibility-${entry.id}`}
/>
<span className="truncate text-sm">{labels[entry.id] ?? formatColumnId(entry.id)}</span>
</label>
))}
<div className="border-t pt-1">
<Button
type="button"
onClick={onReset}
variant="ghost"
className="w-full justify-start text-sm"
data-testid="column-reset-default"
>
<RotateCcw className="h-3 w-3" />
Reset to default
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,94 @@
import { Column, ColumnPinningState } from "@tanstack/react-table";
import { CSSProperties, useLayoutEffect, useRef, useState } from "react";
/**
* Measures actual header cell DOM widths and computes pixel-perfect
* sticky offsets for pinned columns.
*/
export function usePinOffsets(
headerCellRefs: React.MutableRefObject<Map<string, HTMLTableCellElement>>,
columnPinning: ColumnPinningState,
) {
const [offsets, setOffsets] = useState<Map<string, number>>(new Map());
// Serialize pinning arrays to stable strings so the effect only fires
// when the actual pinned column IDs change, not on every render.
const leftKey = (columnPinning.left ?? []).join(",");
const rightKey = (columnPinning.right ?? []).join(",");
useLayoutEffect(() => {
const leftPinned = leftKey ? leftKey.split(",") : [];
const rightPinned = rightKey ? rightKey.split(",") : [];
const next = new Map<string, number>();
let left = 0;
for (const id of leftPinned) {
next.set(id, left);
const el = headerCellRefs.current.get(id);
if (el) left += el.getBoundingClientRect().width;
}
let right = 0;
for (let i = rightPinned.length - 1; i >= 0; i--) {
const id = rightPinned[i];
next.set(id, right);
const el = headerCellRefs.current.get(id);
if (el) right += el.getBoundingClientRect().width;
}
// Only update if offsets actually changed to avoid infinite re-render loops
setOffsets((prev) => {
if (prev.size === next.size) {
let same = true;
for (const [k, v] of next) {
if (prev.get(k) !== v) {
same = false;
break;
}
}
if (same) return prev;
}
return next;
});
}, [leftKey, rightKey, headerCellRefs]);
return offsets;
}
/**
* Returns a ref callback setter and the refs map for header cell measurement.
*/
export function useHeaderCellRefs() {
const refs = useRef<Map<string, HTMLTableCellElement>>(new Map());
const setRef = (columnId: string) => (el: HTMLTableCellElement | null) => {
if (el) refs.current.set(columnId, el);
else refs.current.delete(columnId);
};
return { headerCellRefs: refs, setHeaderCellRef: setRef };
}
/**
* Builds a CSS style object for a pinned column using measured offsets.
*/
export function buildPinStyle<T>(column: Column<T>, offsets: Map<string, number>): CSSProperties {
const pinned = column.getIsPinned();
if (!pinned) return {};
const px = offsets.get(column.id) ?? 0;
return {
position: "sticky",
left: pinned === "left" ? `${px}px` : undefined,
right: pinned === "right" ? `${px}px` : undefined,
zIndex: 1,
};
}
/**
* CSS class for the shadow on the last left-pinned or first right-pinned column.
* Uses an `after` pseudo-element so it isn't clipped by overflow on the table container.
*/
export const PIN_SHADOW_LEFT =
"after:pointer-events-none after:absolute after:top-0 after:-right-6 after:h-full after:w-6 after:shadow-[inset_6px_0_6px_-6px_rgba(0,0,0,0.15)] dark:after:shadow-[inset_6px_0_6px_-6px_rgba(0,0,0,0.5)]";
export const PIN_SHADOW_RIGHT =
"before:pointer-events-none before:absolute before:top-0 before:-left-6 before:h-full before:w-6 before:shadow-[inset_-6px_0_6px_-6px_rgba(0,0,0,0.15)] dark:before:shadow-[inset_-6px_0_6px_-6px_rgba(0,0,0,0.5)]";

View File

@@ -0,0 +1,130 @@
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdownMenu";
import { cn } from "@/lib/utils";
import { Header, flexRender } from "@tanstack/react-table";
import { ArrowLeftToLine, ArrowRightToLine, Ellipsis, EyeOff, PinOff } from "lucide-react";
import { CSSProperties, useState } from "react";
export const TH_CLASS = "text-foreground h-10 px-4 text-left align-middle font-medium whitespace-nowrap";
export function DraggableColumnHeader<TData>({
header,
isConfigurable,
pinStyle,
pinnedHeaderClassName,
className: extraClassName,
onHide,
onPin,
onDrop,
cellRef,
}: {
header: Header<TData, unknown>;
isConfigurable: boolean;
pinStyle: CSSProperties;
pinnedHeaderClassName?: string;
className?: string;
onHide: (id: string) => void;
onPin: (id: string, position: "left" | "right") => void;
onDrop: (draggedId: string, targetId: string) => void;
cellRef: (el: HTMLTableCellElement | null) => void;
}) {
const [isDragging, setIsDragging] = useState(false);
const [isDropTarget, setIsDropTarget] = useState(false);
const pinned = header.column.getIsPinned();
const size = header.getSize();
return (
<th
ref={cellRef}
style={{ width: size, minWidth: size, maxWidth: size, ...pinStyle }}
className={cn(
TH_CLASS,
pinned && (pinnedHeaderClassName ?? "bg-card"),
isDragging && "opacity-50",
isDropTarget && "ring-primary ring-inset ring-1",
isConfigurable && "cursor-grab active:cursor-grabbing",
extraClassName,
)}
draggable={isConfigurable}
onDragStart={(e) => {
setIsDragging(true);
e.dataTransfer.setData("text/plain", header.column.id);
e.dataTransfer.effectAllowed = "move";
}}
onDragEnd={() => setIsDragging(false)}
onDragOver={(e) => {
if (!isConfigurable) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setIsDropTarget(true);
}}
onDragLeave={() => setIsDropTarget(false)}
onDrop={(e) => {
e.preventDefault();
setIsDropTarget(false);
const draggedId = e.dataTransfer.getData("text/plain");
if (draggedId && draggedId !== header.column.id) {
onDrop(draggedId, header.column.id);
}
}}
>
{header.isPlaceholder ? null : (
<div className="group/col flex items-center">
<div className="flex-1">{flexRender(header.column.columnDef.header, header.getContext())}</div>
{isConfigurable && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="ml-1 shrink-0 opacity-0 transition-opacity group-hover/col:opacity-100 focus-visible:opacity-100"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
aria-label="Column actions"
>
<Ellipsis className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="bottom">
<DropdownMenuItem onClick={() => onHide(header.column.id)}>
<EyeOff className="h-4 w-4" />
Hide column
</DropdownMenuItem>
<DropdownMenuSeparator />
{pinned === "left" ? (
<DropdownMenuItem onClick={() => onPin(header.column.id, "left")}>
<PinOff className="h-4 w-4" />
Unpin
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => onPin(header.column.id, "left")}>
<ArrowLeftToLine className="h-4 w-4" />
Pin to left
</DropdownMenuItem>
)}
{pinned === "right" ? (
<DropdownMenuItem onClick={() => onPin(header.column.id, "right")}>
<PinOff className="h-4 w-4" />
Unpin
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => onPin(header.column.id, "right")}>
<ArrowRightToLine className="h-4 w-4" />
Pin to right
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)}
</th>
);
}

View File

@@ -0,0 +1,143 @@
import { ColumnPinningState, VisibilityState } from "@tanstack/react-table";
import { parseAsString, useQueryState } from "nuqs";
import { useCallback, useMemo } from "react";
export interface ColumnConfigEntry {
id: string;
visible: boolean;
pinned: "left" | "right" | false;
}
interface UseColumnConfigOptions {
/** All available column IDs in their default order */
columnIds: string[];
/** URL query param name for persistence */
paramName?: string;
/** Columns excluded from configuration (always visible, always in position) */
fixedColumns?: { left?: string[]; right?: string[] };
}
// URL format: col1,col2:h,col3:l,col4:r
// no suffix = visible & unpinned, :h = hidden, :l = pinned left, :r = pinned right
function serialize(entries: ColumnConfigEntry[]): string {
return entries
.map((e) => {
let flags = "";
if (!e.visible) flags += "h";
if (e.pinned === "left") flags += "l";
if (e.pinned === "right") flags += "r";
const encoded = encodeURIComponent(e.id);
return flags ? `${encoded}:${flags}` : encoded;
})
.join(",");
}
function deserialize(str: string): ColumnConfigEntry[] {
if (!str) return [];
return str.split(",").map((part) => {
const [rawId, flags = ""] = part.split(":");
const id = decodeURIComponent(rawId);
return {
id,
visible: !flags.includes("h"),
pinned: flags.includes("l") ? ("left" as const) : flags.includes("r") ? ("right" as const) : false,
};
});
}
export function useColumnConfig({ columnIds, paramName = "cols", fixedColumns }: UseColumnConfigOptions) {
const [raw, setRaw] = useQueryState(paramName, parseAsString.withDefault(""));
const fixedLeft = fixedColumns?.left ?? [];
const fixedRight = fixedColumns?.right ?? [];
const fixedSet = useMemo(() => new Set([...fixedLeft, ...fixedRight]), [fixedLeft, fixedRight]);
const configurableIds = useMemo(() => columnIds.filter((id) => !fixedSet.has(id)), [columnIds, fixedSet]);
// Merge URL config with available columns (handles added/removed columns)
const entries = useMemo(() => {
const parsed = deserialize(raw);
const result: ColumnConfigEntry[] = [];
const seen = new Set<string>();
// Columns present in URL config that still exist
for (const entry of parsed) {
if (configurableIds.includes(entry.id) && !seen.has(entry.id)) {
result.push(entry);
seen.add(entry.id);
}
}
// New columns not yet in URL config
for (const id of configurableIds) {
if (!seen.has(id)) {
result.push({ id, visible: true, pinned: false });
}
}
return result;
}, [raw, configurableIds]);
// TanStack table state
const columnOrder = useMemo(() => [...fixedLeft, ...entries.map((e) => e.id), ...fixedRight], [entries, fixedLeft, fixedRight]);
const columnVisibility = useMemo(() => {
const vis: VisibilityState = {};
for (const entry of entries) {
if (!entry.visible) vis[entry.id] = false;
}
return vis;
}, [entries]);
const columnPinning = useMemo(
(): ColumnPinningState => ({
left: [...fixedLeft, ...entries.filter((e) => e.pinned === "left").map((e) => e.id)],
right: [...entries.filter((e) => e.pinned === "right").map((e) => e.id), ...fixedRight],
}),
[entries, fixedLeft, fixedRight],
);
const persist = useCallback(
(newEntries: ColumnConfigEntry[]) => {
const serialized = serialize(newEntries);
setRaw(serialized || null);
},
[setRaw],
);
const toggleVisibility = useCallback(
(columnId: string) => {
persist(entries.map((e) => (e.id === columnId ? { ...e, visible: !e.visible } : e)));
},
[entries, persist],
);
const togglePin = useCallback(
(columnId: string, position: "left" | "right") => {
persist(entries.map((e) => (e.id === columnId ? { ...e, pinned: e.pinned === position ? false : position } : e)));
},
[entries, persist],
);
const reorder = useCallback(
(newEntries: ColumnConfigEntry[]) => {
persist(newEntries);
},
[persist],
);
const reset = useCallback(() => {
setRaw(null);
}, [setRaw]);
return {
entries,
columnOrder,
columnVisibility,
columnPinning,
toggleVisibility,
togglePin,
reorder,
reset,
};
}

View File

@@ -0,0 +1,5 @@
export { ColumnConfigDropdown } from "./columnConfigDropdown";
export { buildPinStyle, PIN_SHADOW_LEFT, PIN_SHADOW_RIGHT, useHeaderCellRefs, usePinOffsets } from "./columnPinning";
export { DraggableColumnHeader, TH_CLASS } from "./draggableColumnHeader";
export { useColumnConfig } from "./hooks/useColumnConfig";
export type { ColumnConfigEntry } from "./hooks/useColumnConfig";