summaryrefslogtreecommitdiff
path: root/hooks
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-03-25 15:55:45 +0900
committerjoonhoekim <26rote@gmail.com>2025-03-25 15:55:45 +0900
commit1a2241c40e10193c5ff7008a7b7b36cc1d855d96 (patch)
tree8a5587f10ca55b162d7e3254cb088b323a34c41b /hooks
initial commit
Diffstat (limited to 'hooks')
-rw-r--r--hooks/use-callback-ref.ts27
-rw-r--r--hooks/use-data-table.ts334
-rw-r--r--hooks/use-debounce.ts15
-rw-r--r--hooks/use-debounced-callback.ts29
-rw-r--r--hooks/use-form-schema.ts113
-rw-r--r--hooks/use-media-query.ts19
-rw-r--r--hooks/use-meta-color.ts25
-rw-r--r--hooks/use-mobile.tsx19
-rw-r--r--hooks/use-query-string.ts24
-rw-r--r--hooks/use-toast.ts194
-rw-r--r--hooks/useAutoSizeColumns.ts86
11 files changed, 885 insertions, 0 deletions
diff --git a/hooks/use-callback-ref.ts b/hooks/use-callback-ref.ts
new file mode 100644
index 00000000..f379a9c3
--- /dev/null
+++ b/hooks/use-callback-ref.ts
@@ -0,0 +1,27 @@
+import * as React from "react"
+
+/**
+ * @see https://github.com/radix-ui/primitives/blob/main/packages/react/use-callback-ref/src/useCallbackRef.tsx
+ */
+
+/**
+ * A custom hook that converts a callback to a ref to avoid triggering re-renders when passed as a
+ * prop or avoid re-executing effects when passed as a dependency
+ */
+function useCallbackRef<T extends (...args: never[]) => unknown>(
+ callback: T | undefined
+): T {
+ const callbackRef = React.useRef(callback)
+
+ React.useEffect(() => {
+ callbackRef.current = callback
+ })
+
+ // https://github.com/facebook/react/issues/19240
+ return React.useMemo(
+ () => ((...args) => callbackRef.current?.(...args)) as T,
+ []
+ )
+}
+
+export { useCallbackRef }
diff --git a/hooks/use-data-table.ts b/hooks/use-data-table.ts
new file mode 100644
index 00000000..a3301067
--- /dev/null
+++ b/hooks/use-data-table.ts
@@ -0,0 +1,334 @@
+"use client"
+
+import * as React from "react"
+import type { DataTableFilterField, ExtendedSortingState } from "@/types/table"
+import {
+ getCoreRowModel,
+ getFacetedRowModel,
+ getFacetedUniqueValues,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ getGroupedRowModel,
+ getExpandedRowModel,
+ useReactTable,
+ type ColumnFiltersState,
+ type PaginationState,
+ type RowSelectionState,
+ type SortingState,
+ type TableOptions,
+ type TableState,
+ type Updater,
+ type VisibilityState,
+ type ExpandedState,
+} from "@tanstack/react-table"
+import {
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ useQueryState,
+ useQueryStates,
+ type Parser,
+ type UseQueryStateOptions,
+} from "nuqs"
+
+import { getSortingStateParser } from "@/lib/parsers"
+import { useDebouncedCallback } from "@/hooks/use-debounced-callback"
+
+interface UseDataTableProps<TData>
+ extends Omit<
+ TableOptions<TData>,
+ | "state"
+ | "pageCount"
+ | "getCoreRowModel"
+ | "manualFiltering"
+ | "manualPagination"
+ | "manualSorting"
+ | "onGroupingChange"
+ | "onExpandedChange"
+ | "getExpandedRowModel"
+ >,
+ Required<Pick<TableOptions<TData>, "pageCount">> {
+ filterFields?: DataTableFilterField<TData>[]
+ enableAdvancedFilter?: boolean
+ history?: "push" | "replace"
+ scroll?: boolean
+ shallow?: boolean
+ throttleMs?: number
+ debounceMs?: number
+ startTransition?: React.TransitionStartFunction
+ clearOnDefault?: boolean
+ initialState?: Omit<Partial<TableState>, "sorting"> & {
+ sorting?: ExtendedSortingState<TData>
+ /**
+ * 기본 그룹핑 컬럼 배열 (멀티 그룹핑)
+ */
+ grouping?: string[]
+ /**
+ * 그룹 확장/접기 상태
+ */
+ expanded?: Record<string, boolean>
+ }
+}
+
+export function useDataTable<TData>({
+ pageCount = -1,
+ filterFields = [],
+ enableAdvancedFilter = false,
+ history = "replace",
+ scroll = false,
+ shallow = true,
+ throttleMs = 50,
+ debounceMs = 300,
+ clearOnDefault = false,
+ startTransition,
+ initialState,
+ ...props
+}: UseDataTableProps<TData>) {
+ // 공통 URL QueryState 옵션
+ const queryStateOptions = React.useMemo<
+ Omit<UseQueryStateOptions<string>, "parse">
+ >(() => {
+ return {
+ history,
+ scroll,
+ shallow,
+ throttleMs,
+ debounceMs,
+ clearOnDefault,
+ startTransition,
+ }
+ }, [
+ history,
+ scroll,
+ shallow,
+ throttleMs,
+ debounceMs,
+ clearOnDefault,
+ startTransition,
+ ])
+
+ // -------- RowSelection & ColumnVisibility는 URL 동기화 없이 로컬 상태 ----------
+ const [rowSelection, setRowSelection] = React.useState<RowSelectionState>(
+ initialState?.rowSelection ?? {}
+ )
+ const [columnVisibility, setColumnVisibility] =
+ React.useState<VisibilityState>(initialState?.columnVisibility ?? {})
+
+ // -------- Pagination (page, perPage) URL 동기화 --------
+ const [page, setPage] = useQueryState(
+ "page",
+ parseAsInteger.withOptions(queryStateOptions).withDefault(1)
+ )
+ const [perPage, setPerPage] = useQueryState(
+ "perPage",
+ parseAsInteger
+ .withOptions(queryStateOptions)
+ .withDefault(initialState?.pagination?.pageSize ?? 10)
+ )
+
+ // -------- Sorting (sort) URL 동기화 --------
+ const [sorting, setSorting] = useQueryState(
+ "sort",
+ getSortingStateParser<TData>()
+ .withOptions(queryStateOptions)
+ .withDefault(initialState?.sorting ?? [])
+ )
+ function onSortingChange(updaterOrValue: Updater<SortingState>) {
+ if (typeof updaterOrValue === "function") {
+ const newSorting = updaterOrValue(sorting) as ExtendedSortingState<TData>
+ void setSorting(newSorting)
+ } else {
+ void setSorting(updaterOrValue as ExtendedSortingState<TData>)
+ }
+ }
+
+ // -------- Grouping (group) URL 동기화 (멀티 컬럼) --------
+ const [grouping, setGrouping] = useQueryState(
+ "group",
+ parseAsArrayOf(parseAsString, ",")
+ .withOptions(queryStateOptions)
+ .withDefault(initialState?.grouping ?? [])
+ )
+ function onGroupingChange(updaterOrValue: Updater<string[]>) {
+ if (typeof updaterOrValue === "function") {
+ const newGrouping = updaterOrValue(grouping)
+ void setGrouping(newGrouping)
+ } else {
+ void setGrouping(updaterOrValue)
+ }
+ }
+
+ // -------- Group Expand/Collapse --------
+ const [expanded, setExpanded] = React.useState<ExpandedState>({}) // or true/false
+
+ function onExpandedChange(updater: Updater<ExpandedState>) {
+ setExpanded((old) => (typeof updater === "function" ? updater(old) : updater))
+ }
+
+ // -------- Filters (search/faceted) URL 동기화 --------
+ const filterParsers = React.useMemo(() => {
+ return filterFields.reduce<
+ Record<string, Parser<string> | Parser<string[]>>
+ >((acc, field) => {
+ if (field.options) {
+ // Faceted filter -> 여러 값 가능
+ acc[field.id] = parseAsArrayOf(parseAsString, ",").withOptions(
+ queryStateOptions
+ )
+ } else {
+ // Search filter -> 단일 값
+ acc[field.id] = parseAsString.withOptions(queryStateOptions)
+ }
+ return acc
+ }, {})
+ }, [filterFields, queryStateOptions])
+
+ const [filterValues, setFilterValues] = useQueryStates(filterParsers)
+ const debouncedSetFilterValues = useDebouncedCallback(
+ setFilterValues,
+ debounceMs
+ )
+
+ // -------- PaginationState 객체 --------
+ const pagination: PaginationState = {
+ pageIndex: page - 1,
+ pageSize: perPage,
+ }
+ function onPaginationChange(updaterOrValue: Updater<PaginationState>) {
+ if (typeof updaterOrValue === "function") {
+ const newPagination = updaterOrValue(pagination)
+ void setPage(newPagination.pageIndex + 1)
+ void setPerPage(newPagination.pageSize)
+ } else {
+ void setPage(updaterOrValue.pageIndex + 1)
+ void setPerPage(updaterOrValue.pageSize)
+ }
+ }
+
+ // -------- ColumnFiltersState --------
+ const initialColumnFilters: ColumnFiltersState = React.useMemo(() => {
+ // AdvancedFilter 모드가 아니면 URL에서 온 filterValues를 그대로 적용
+ return enableAdvancedFilter
+ ? []
+ : Object.entries(filterValues).reduce<ColumnFiltersState>(
+ (filters, [key, value]) => {
+ if (value !== null) {
+ filters.push({
+ id: key,
+ value: Array.isArray(value) ? value : [value],
+ })
+ }
+ return filters
+ },
+ []
+ )
+ }, [filterValues, enableAdvancedFilter])
+
+ const [columnFilters, setColumnFilters] =
+ React.useState<ColumnFiltersState>(initialColumnFilters)
+
+ // 검색용 / Facet 필터용 컬럼 구분
+ const { searchableColumns, filterableColumns } = React.useMemo(() => {
+ return enableAdvancedFilter
+ ? { searchableColumns: [], filterableColumns: [] }
+ : {
+ searchableColumns: filterFields.filter((field) => !field.options),
+ filterableColumns: filterFields.filter((field) => field.options),
+ }
+ }, [filterFields, enableAdvancedFilter])
+
+ const onColumnFiltersChange = React.useCallback(
+ (updaterOrValue: Updater<ColumnFiltersState>) => {
+ if (enableAdvancedFilter) return
+ setColumnFilters((prev) => {
+ const next =
+ typeof updaterOrValue === "function"
+ ? updaterOrValue(prev)
+ : updaterOrValue
+
+ const filterUpdates = next.reduce<
+ Record<string, string | string[] | null>
+ >((acc, filter) => {
+ if (searchableColumns.find((col) => col.id === filter.id)) {
+ acc[filter.id] = filter.value as string
+ } else if (filterableColumns.find((col) => col.id === filter.id)) {
+ acc[filter.id] = filter.value as string[]
+ }
+ return acc
+ }, {})
+
+ // 빠진 필터는 null로 설정
+ prev.forEach((prevFilter) => {
+ if (!next.some((filter) => filter.id === prevFilter.id)) {
+ filterUpdates[prevFilter.id] = null
+ }
+ })
+
+ // 필터가 바뀌면 첫 페이지로
+ void setPage(1)
+ debouncedSetFilterValues(filterUpdates)
+ return next
+ })
+ },
+ [
+ debouncedSetFilterValues,
+ enableAdvancedFilter,
+ filterableColumns,
+ searchableColumns,
+ setPage,
+ ]
+ )
+
+ // -------- TanStack Table 인스턴스 생성 --------
+ const table = useReactTable({
+ ...props,
+ initialState,
+ pageCount,
+ state: {
+ pagination,
+ sorting,
+ columnVisibility,
+ rowSelection,
+ columnFilters: enableAdvancedFilter ? [] : columnFilters,
+ grouping,
+ expanded,
+ },
+
+ // 콜백들
+ onRowSelectionChange: setRowSelection,
+ onPaginationChange,
+ onSortingChange,
+ onColumnFiltersChange,
+ onColumnVisibilityChange: setColumnVisibility,
+ onGroupingChange,
+ onExpandedChange,
+
+ // 기본 모델
+ getCoreRowModel: getCoreRowModel(),
+ // 필터 (Advanced 모드 아니면 사용)
+ getFilteredRowModel: enableAdvancedFilter ? undefined : getFilteredRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ // 그룹 + 확장
+ getGroupedRowModel: getGroupedRowModel(),
+ getExpandedRowModel: getExpandedRowModel(),
+
+ // Faceted (Facet 필터용)
+ getFacetedRowModel: enableAdvancedFilter ? undefined : getFacetedRowModel(),
+ getFacetedUniqueValues: enableAdvancedFilter
+ ? undefined
+ : getFacetedUniqueValues(),
+
+ // 서버 사이드 사용
+ manualPagination: true,
+ manualSorting: true,
+ manualFiltering: true,
+ // 그룹핑도 서버에서 처리한다면 별도 로직이 필요하지만,
+ // TanStack Table v8에는 manualGrouping 옵션은 없음
+ // (그룹핑을 서버에서 이미 해서 내려받는다면 row 구조 처리 필요)
+
+ })
+
+ return { table }
+} \ No newline at end of file
diff --git a/hooks/use-debounce.ts b/hooks/use-debounce.ts
new file mode 100644
index 00000000..5c0b504a
--- /dev/null
+++ b/hooks/use-debounce.ts
@@ -0,0 +1,15 @@
+import * as React from "react"
+
+export function useDebounce<T>(value: T, delay?: number): T {
+ const [debouncedValue, setDebouncedValue] = React.useState<T>(value)
+
+ React.useEffect(() => {
+ const timer = setTimeout(() => setDebouncedValue(value), delay ?? 500)
+
+ return () => {
+ clearTimeout(timer)
+ }
+ }, [value, delay])
+
+ return debouncedValue
+}
diff --git a/hooks/use-debounced-callback.ts b/hooks/use-debounced-callback.ts
new file mode 100644
index 00000000..51f5dd17
--- /dev/null
+++ b/hooks/use-debounced-callback.ts
@@ -0,0 +1,29 @@
+/**
+ * @see https://github.com/mantinedev/mantine/blob/master/packages/@mantine/hooks/src/use-debounced-callback/use-debounced-callback.ts
+ */
+
+import * as React from "react"
+
+import { useCallbackRef } from "@/hooks/use-callback-ref"
+
+export function useDebouncedCallback<T extends (...args: never[]) => unknown>(
+ callback: T,
+ delay: number
+) {
+ const handleCallback = useCallbackRef(callback)
+ const debounceTimerRef = React.useRef(0)
+ React.useEffect(() => () => window.clearTimeout(debounceTimerRef.current), [])
+
+ const setValue = React.useCallback(
+ (...args: Parameters<T>) => {
+ window.clearTimeout(debounceTimerRef.current)
+ debounceTimerRef.current = window.setTimeout(
+ () => handleCallback(...args),
+ delay
+ )
+ },
+ [handleCallback, delay]
+ )
+
+ return setValue
+}
diff --git a/hooks/use-form-schema.ts b/hooks/use-form-schema.ts
new file mode 100644
index 00000000..a4d8f3c6
--- /dev/null
+++ b/hooks/use-form-schema.ts
@@ -0,0 +1,113 @@
+"use client"
+
+import useSWR from "swr"
+
+// ------------------ 폼 스키마 구조 ------------------
+export interface FormField {
+ label: string
+ name: string
+ type: string // "text" | "number" | "select" | "date" | ...
+ options?: string[] // select 등에 필요한 옵션
+}
+
+export interface FormSchema {
+ formType: "table" | "form"
+ fields: FormField[]
+}
+
+// ------------------ 실제 요청 함수 (mock) ------------------
+async function fetchFormSchema(itemName: string) {
+ // 실제 API 요청 예시:
+ // const res = await fetch(`/api/form-schema?item=${encodeURIComponent(itemName)}`)
+ // return await res.json()
+
+ // 여기서는 예시 데이터(하드코딩)
+ if (itemName === "Mechanical Data Sheet") {
+ // 예: 테이블 형태 (설비 리스트)
+ return {
+ formType: "table",
+ fields: [
+ { label: "Equipment Name", name: "equipmentName", type: "text" },
+ { label: "Tag No.", name: "tagNo", type: "text" },
+ { label: "Capacity", name: "capacity", type: "number" },
+ { label: "Manufacturer", name: "manufacturer", type: "text" },
+ { label: "Model", name: "model", type: "text" },
+ { label: "Weight (kg)", name: "weight", type: "number" },
+ { label: "Power (kW)", name: "power", type: "number" },
+ // 필요하다면 옵션이 있는 select 필드 등도 추가
+ // { label: "Power Source", name: "powerSource", type: "select", options: ["AC380V", "AC220V", "DC", "Other"] },
+ ],
+ }
+ } else if (itemName === "Room List") {
+ // 예: 또 다른 테이블 형태 (건물 방 리스트)
+ return {
+ formType: "table",
+ fields: [
+ { label: "Room Name", name: "roomName", type: "text" },
+ { label: "Area (m²)", name: "area", type: "number" },
+ { label: "Floor No.", name: "floorNo", type: "number" },
+ { label: "Ceiling Type", name: "ceiling", type: "text" },
+ { label: "Wall Finish", name: "wallFinish",type: "text" },
+ { label: "Floor Finish", name: "floorFinish", type: "text" },
+ // select 예시
+ { label: "Usage", name: "usage", type: "select", options: ["Office", "Laboratory", "Storage", "Other"] },
+ ],
+ }
+ } else if (itemName === "Piping Line List") {
+ // 예: 테이블 형태 (배관 정보)
+ return {
+ formType: "table",
+ fields: [
+ { label: "Line Number", name: "lineNumber", type: "text" },
+ { label: "Size (inches)", name: "size", type: "number" },
+ { label: "Service", name: "service", type: "text" },
+ { label: "Material Spec", name: "materialSpec",type: "text" },
+ { label: "Insulation", name: "insulation", type: "text" },
+ { label: "Design Temp (°C)", name: "designTemp", type: "number" },
+ { label: "Design Press (bar)", name: "designPress", type: "number" },
+ ],
+ }
+ } else if (itemName === "Electrical Load List") {
+ // 추가 예시: 테이블 or form (여기선 테이블로 예시)
+ return {
+ formType: "table",
+ fields: [
+ { label: "Load Name", name: "loadName", type: "text" },
+ { label: "Voltage (V)", name: "voltage", type: "number" },
+ { label: "Phase", name: "phase", type: "select", options: ["Single-phase", "Three-phase"] },
+ { label: "Load (kW)", name: "loadKw", type: "number" },
+ { label: "Power Factor (%)", name: "powerFactor", type: "number" },
+ { label: "Location", name: "location", type: "text" },
+ ],
+ }
+ } else {
+ // 디폴트: formType: "form" (단일 폼)
+ // 예: 임시로 "Vendor Contact Info" 라고 가정
+ return {
+ formType: "form",
+ fields: [
+ { label: "Vendor Name", name: "vendorName", type: "text" },
+ { label: "Contact Name", name: "contactName", type: "text" },
+ { label: "Phone Number", name: "phoneNumber", type: "text" },
+ { label: "Email", name: "email", type: "text" },
+ // date 예시
+ { label: "Contract Date", name: "contractDate", type: "date" },
+ ],
+ }
+ }
+}
+
+// ------------------ SWR 훅 ------------------
+export function useFormSchema(itemName: string | null) {
+ // itemName이 없으면(선택 안 됨) 요청하지 않음
+ const { data, error, isLoading } = useSWR(
+ itemName ? `/api/form-schema?item=${encodeURIComponent(itemName)}` : null,
+ () => (itemName ? fetchFormSchema(itemName) : null)
+ )
+
+ return {
+ schema: data as FormSchema | undefined,
+ isLoading,
+ isError: !!error,
+ }
+} \ No newline at end of file
diff --git a/hooks/use-media-query.ts b/hooks/use-media-query.ts
new file mode 100644
index 00000000..95e552cd
--- /dev/null
+++ b/hooks/use-media-query.ts
@@ -0,0 +1,19 @@
+import * as React from "react"
+
+export function useMediaQuery(query: string) {
+ const [value, setValue] = React.useState(false)
+
+ React.useEffect(() => {
+ function onChange(event: MediaQueryListEvent) {
+ setValue(event.matches)
+ }
+
+ const result = matchMedia(query)
+ result.addEventListener("change", onChange)
+ setValue(result.matches)
+
+ return () => result.removeEventListener("change", onChange)
+ }, [query])
+
+ return value
+}
diff --git a/hooks/use-meta-color.ts b/hooks/use-meta-color.ts
new file mode 100644
index 00000000..65d33433
--- /dev/null
+++ b/hooks/use-meta-color.ts
@@ -0,0 +1,25 @@
+import * as React from "react"
+import { useTheme } from "next-themes"
+
+import { META_THEME_COLORS } from "@/config/site"
+
+export function useMetaColor() {
+ const { resolvedTheme } = useTheme()
+
+ const metaColor = React.useMemo(() => {
+ return resolvedTheme !== "dark"
+ ? META_THEME_COLORS.light
+ : META_THEME_COLORS.dark
+ }, [resolvedTheme])
+
+ const setMetaColor = React.useCallback((color: string) => {
+ document
+ .querySelector('meta[name="theme-color"]')
+ ?.setAttribute("content", color)
+ }, [])
+
+ return {
+ metaColor,
+ setMetaColor,
+ }
+} \ No newline at end of file
diff --git a/hooks/use-mobile.tsx b/hooks/use-mobile.tsx
new file mode 100644
index 00000000..2b0fe1df
--- /dev/null
+++ b/hooks/use-mobile.tsx
@@ -0,0 +1,19 @@
+import * as React from "react"
+
+const MOBILE_BREAKPOINT = 768
+
+export function useIsMobile() {
+ const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
+
+ React.useEffect(() => {
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
+ const onChange = () => {
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+ }
+ mql.addEventListener("change", onChange)
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+ return () => mql.removeEventListener("change", onChange)
+ }, [])
+
+ return !!isMobile
+}
diff --git a/hooks/use-query-string.ts b/hooks/use-query-string.ts
new file mode 100644
index 00000000..5f523230
--- /dev/null
+++ b/hooks/use-query-string.ts
@@ -0,0 +1,24 @@
+import * as React from "react"
+
+type QueryParams = Record<string, string | number | null>
+
+export function useQueryString(searchParams: URLSearchParams) {
+ const createQueryString = React.useCallback(
+ (params: QueryParams) => {
+ const newSearchParams = new URLSearchParams(searchParams?.toString())
+
+ for (const [key, value] of Object.entries(params)) {
+ if (value === null) {
+ newSearchParams.delete(key)
+ } else {
+ newSearchParams.set(key, String(value))
+ }
+ }
+
+ return newSearchParams.toString()
+ },
+ [searchParams]
+ )
+
+ return { createQueryString }
+}
diff --git a/hooks/use-toast.ts b/hooks/use-toast.ts
new file mode 100644
index 00000000..02e111d8
--- /dev/null
+++ b/hooks/use-toast.ts
@@ -0,0 +1,194 @@
+"use client"
+
+// Inspired by react-hot-toast library
+import * as React from "react"
+
+import type {
+ ToastActionElement,
+ ToastProps,
+} from "@/components/ui/toast"
+
+const TOAST_LIMIT = 1
+const TOAST_REMOVE_DELAY = 1000000
+
+type ToasterToast = ToastProps & {
+ id: string
+ title?: React.ReactNode
+ description?: React.ReactNode
+ action?: ToastActionElement
+}
+
+const actionTypes = {
+ ADD_TOAST: "ADD_TOAST",
+ UPDATE_TOAST: "UPDATE_TOAST",
+ DISMISS_TOAST: "DISMISS_TOAST",
+ REMOVE_TOAST: "REMOVE_TOAST",
+} as const
+
+let count = 0
+
+function genId() {
+ count = (count + 1) % Number.MAX_SAFE_INTEGER
+ return count.toString()
+}
+
+type ActionType = typeof actionTypes
+
+type Action =
+ | {
+ type: ActionType["ADD_TOAST"]
+ toast: ToasterToast
+ }
+ | {
+ type: ActionType["UPDATE_TOAST"]
+ toast: Partial<ToasterToast>
+ }
+ | {
+ type: ActionType["DISMISS_TOAST"]
+ toastId?: ToasterToast["id"]
+ }
+ | {
+ type: ActionType["REMOVE_TOAST"]
+ toastId?: ToasterToast["id"]
+ }
+
+interface State {
+ toasts: ToasterToast[]
+}
+
+const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
+
+const addToRemoveQueue = (toastId: string) => {
+ if (toastTimeouts.has(toastId)) {
+ return
+ }
+
+ const timeout = setTimeout(() => {
+ toastTimeouts.delete(toastId)
+ dispatch({
+ type: "REMOVE_TOAST",
+ toastId: toastId,
+ })
+ }, TOAST_REMOVE_DELAY)
+
+ toastTimeouts.set(toastId, timeout)
+}
+
+export const reducer = (state: State, action: Action): State => {
+ switch (action.type) {
+ case "ADD_TOAST":
+ return {
+ ...state,
+ toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
+ }
+
+ case "UPDATE_TOAST":
+ return {
+ ...state,
+ toasts: state.toasts.map((t) =>
+ t.id === action.toast.id ? { ...t, ...action.toast } : t
+ ),
+ }
+
+ case "DISMISS_TOAST": {
+ const { toastId } = action
+
+ // ! Side effects ! - This could be extracted into a dismissToast() action,
+ // but I'll keep it here for simplicity
+ if (toastId) {
+ addToRemoveQueue(toastId)
+ } else {
+ state.toasts.forEach((toast) => {
+ addToRemoveQueue(toast.id)
+ })
+ }
+
+ return {
+ ...state,
+ toasts: state.toasts.map((t) =>
+ t.id === toastId || toastId === undefined
+ ? {
+ ...t,
+ open: false,
+ }
+ : t
+ ),
+ }
+ }
+ case "REMOVE_TOAST":
+ if (action.toastId === undefined) {
+ return {
+ ...state,
+ toasts: [],
+ }
+ }
+ return {
+ ...state,
+ toasts: state.toasts.filter((t) => t.id !== action.toastId),
+ }
+ }
+}
+
+const listeners: Array<(state: State) => void> = []
+
+let memoryState: State = { toasts: [] }
+
+function dispatch(action: Action) {
+ memoryState = reducer(memoryState, action)
+ listeners.forEach((listener) => {
+ listener(memoryState)
+ })
+}
+
+type Toast = Omit<ToasterToast, "id">
+
+function toast({ ...props }: Toast) {
+ const id = genId()
+
+ const update = (props: ToasterToast) =>
+ dispatch({
+ type: "UPDATE_TOAST",
+ toast: { ...props, id },
+ })
+ const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
+
+ dispatch({
+ type: "ADD_TOAST",
+ toast: {
+ ...props,
+ id,
+ open: true,
+ onOpenChange: (open) => {
+ if (!open) dismiss()
+ },
+ },
+ })
+
+ return {
+ id: id,
+ dismiss,
+ update,
+ }
+}
+
+function useToast() {
+ const [state, setState] = React.useState<State>(memoryState)
+
+ React.useEffect(() => {
+ listeners.push(setState)
+ return () => {
+ const index = listeners.indexOf(setState)
+ if (index > -1) {
+ listeners.splice(index, 1)
+ }
+ }
+ }, [state])
+
+ return {
+ ...state,
+ toast,
+ dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
+ }
+}
+
+export { useToast, toast }
diff --git a/hooks/useAutoSizeColumns.ts b/hooks/useAutoSizeColumns.ts
new file mode 100644
index 00000000..3750de97
--- /dev/null
+++ b/hooks/useAutoSizeColumns.ts
@@ -0,0 +1,86 @@
+// hooks/useAutoSizeColumns.ts
+import { useEffect, useRef } from "react"
+import { Table } from "@tanstack/react-table"
+
+export function useAutoSizeColumns<T>(
+ table: Table<T>,
+ enabled: boolean = true
+) {
+ const hasResized = useRef(false)
+
+ useEffect(() => {
+ if (!enabled || hasResized.current) return
+
+ // Wait for the table to render
+ const timer = setTimeout(() => {
+ const calculateColumnWidths = () => {
+ const defaultMinWidth = 80
+ const defaultPadding = 24
+
+ const newColumnSizing: Record<string, number> = {}
+
+ // Process each visible column
+ table.getAllColumns().forEach(column => {
+ if (column.id === 'select' || column.id === 'actions') {
+ // Fixed width for utility columns
+ newColumnSizing[column.id] = column.id === 'select' ? 40 : 60
+ return
+ }
+
+ const columnId = column.id
+ // Get column-specific padding from meta if available
+ const paddingFactor = column.columnDef.meta?.paddingFactor as number || 1
+ const extraPadding = paddingFactor * defaultPadding
+
+ // Get all cells for this column
+ const headerElement = document.querySelector(`th[data-column-id="${columnId}"]`)
+ const cells = document.querySelectorAll(`tbody td[data-column-id="${columnId}"]`)
+
+ // Create measuring element
+ const measureElement = document.createElement('div')
+ measureElement.style.position = 'absolute'
+ measureElement.style.visibility = 'hidden'
+ measureElement.style.whiteSpace = 'nowrap'
+ measureElement.style.font = headerElement
+ ? window.getComputedStyle(headerElement).font
+ : window.getComputedStyle(document.body).font
+ document.body.appendChild(measureElement)
+
+ // Measure header
+ const headerText = headerElement?.textContent || ""
+ measureElement.textContent = headerText
+ let maxWidth = measureElement.getBoundingClientRect().width
+
+ // Measure cells (limit to first 20 for performance)
+ Array.from(cells).slice(0, 20).forEach(cell => {
+ const cellText = cell.textContent || ""
+ measureElement.textContent = cellText
+ const cellWidth = measureElement.getBoundingClientRect().width
+ maxWidth = Math.max(maxWidth, cellWidth)
+ })
+
+ // Clean up measuring element
+ document.body.removeChild(measureElement)
+
+ // Calculate final width
+ let finalWidth = maxWidth + extraPadding
+ const minWidth = column.columnDef.minSize || defaultMinWidth
+ finalWidth = Math.max(finalWidth, minWidth)
+
+ // Add to sizing object
+ newColumnSizing[columnId] = finalWidth
+ })
+
+ // Apply all column sizes at once
+ table.setColumnSizing(newColumnSizing)
+ hasResized.current = true
+ }
+
+ calculateColumnWidths()
+ }, 100) // Short delay to ensure DOM is ready
+
+ return () => clearTimeout(timer)
+ }, [table, enabled])
+
+ return { resetAutoSize: () => { hasResized.current = false } }
+} \ No newline at end of file