Refactor UI imports and update component paths
Some checks failed
Build Frontend / build (push) Failing after 7s
Some checks failed
Build Frontend / build (push) Failing after 7s
Replaces imports from 'components/ui' with 'components/common' across the app and dashboard pages, and updates model and API imports to use new paths under 'lib'. Removes redundant authentication checks from several dashboard pages. Adds new dashboard components and utility files, and reorganizes hooks and services into the 'lib' directory for improved structure.
This commit is contained in:
160
lib/hooks/use-chromebook-keyboard.tsx
Normal file
160
lib/hooks/use-chromebook-keyboard.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Hook for enhanced keyboard navigation on Chromebooks
|
||||
*/
|
||||
export function useChromebookKeyboard() {
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
// Enhanced keyboard shortcuts for Chromebooks
|
||||
const { key, ctrlKey, metaKey, altKey, shiftKey } = e;
|
||||
|
||||
// Chromebook-specific shortcuts
|
||||
if (metaKey || ctrlKey) {
|
||||
switch (key) {
|
||||
case 'k':
|
||||
// Focus search or command palette
|
||||
e.preventDefault();
|
||||
const searchInput = document.querySelector('input[type="search"], input[placeholder*="search" i]') as HTMLInputElement;
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
searchInput.select();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Enter':
|
||||
// Submit forms with Ctrl/Cmd + Enter
|
||||
e.preventDefault();
|
||||
const form = document.querySelector('form') as HTMLFormElement;
|
||||
if (form) {
|
||||
const submitButton = form.querySelector('button[type="submit"]') as HTMLButtonElement;
|
||||
if (submitButton && !submitButton.disabled) {
|
||||
submitButton.click();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
case 'ArrowDown':
|
||||
// Navigate through messages or list items
|
||||
e.preventDefault();
|
||||
const focusableElements = document.querySelectorAll(
|
||||
'button, input, textarea, [tabindex]:not([tabindex="-1"]), [role="button"], [role="tab"]'
|
||||
) as NodeListOf<HTMLElement>;
|
||||
|
||||
const currentIndex = Array.from(focusableElements).indexOf(document.activeElement as HTMLElement);
|
||||
let nextIndex;
|
||||
|
||||
if (key === 'ArrowUp') {
|
||||
nextIndex = currentIndex > 0 ? currentIndex - 1 : focusableElements.length - 1;
|
||||
} else {
|
||||
nextIndex = currentIndex < focusableElements.length - 1 ? currentIndex + 1 : 0;
|
||||
}
|
||||
|
||||
focusableElements[nextIndex]?.focus();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Escape key handling
|
||||
if (key === 'Escape') {
|
||||
// Close modals, clear inputs, or go back
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
|
||||
if (activeElement?.tagName === 'INPUT' || activeElement?.tagName === 'TEXTAREA') {
|
||||
// Clear input on Escape
|
||||
(activeElement as HTMLInputElement).value = '';
|
||||
activeElement.blur();
|
||||
} else {
|
||||
// Look for close buttons or back buttons
|
||||
const closeButton = document.querySelector('[aria-label*="close" i], [aria-label*="back" i]') as HTMLElement;
|
||||
if (closeButton) {
|
||||
closeButton.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tab navigation enhancement
|
||||
if (key === 'Tab') {
|
||||
// Ensure proper tab order for Chromebooks
|
||||
const focusableElements = document.querySelectorAll(
|
||||
'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), [role="button"]:not([disabled]), [role="tab"]'
|
||||
);
|
||||
|
||||
// Add visual focus indicators
|
||||
const addFocusIndicator = (element: Element) => {
|
||||
element.classList.add('keyboard-focus');
|
||||
};
|
||||
|
||||
const removeFocusIndicator = (element: Element) => {
|
||||
element.classList.remove('keyboard-focus');
|
||||
};
|
||||
|
||||
// Handle focus events
|
||||
focusableElements.forEach(element => {
|
||||
element.addEventListener('focus', () => addFocusIndicator(element));
|
||||
element.addEventListener('blur', () => removeFocusIndicator(element));
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Add global keyboard event listener
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [handleKeyDown]);
|
||||
|
||||
return {
|
||||
handleKeyDown
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing focus in chat interfaces
|
||||
*/
|
||||
export function useChatFocus() {
|
||||
const focusMessageInput = useCallback(() => {
|
||||
const messageInput = document.querySelector('input[aria-label*="message" i], textarea[aria-label*="message" i]') as HTMLInputElement;
|
||||
if (messageInput) {
|
||||
messageInput.focus();
|
||||
messageInput.select();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const focusNextMessage = useCallback(() => {
|
||||
const messages = document.querySelectorAll('[role="article"]');
|
||||
const currentMessage = document.activeElement?.closest('[role="article"]');
|
||||
|
||||
if (currentMessage) {
|
||||
const currentIndex = Array.from(messages).indexOf(currentMessage);
|
||||
const nextMessage = messages[currentIndex + 1] as HTMLElement;
|
||||
if (nextMessage) {
|
||||
nextMessage.focus();
|
||||
}
|
||||
} else if (messages.length > 0) {
|
||||
(messages[0] as HTMLElement).focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const focusPreviousMessage = useCallback(() => {
|
||||
const messages = document.querySelectorAll('[role="article"]');
|
||||
const currentMessage = document.activeElement?.closest('[role="article"]');
|
||||
|
||||
if (currentMessage) {
|
||||
const currentIndex = Array.from(messages).indexOf(currentMessage);
|
||||
const previousMessage = messages[currentIndex - 1] as HTMLElement;
|
||||
if (previousMessage) {
|
||||
previousMessage.focus();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
focusMessageInput,
|
||||
focusNextMessage,
|
||||
focusPreviousMessage
|
||||
};
|
||||
}
|
||||
86
lib/hooks/use-chromebook-scroll.tsx
Normal file
86
lib/hooks/use-chromebook-scroll.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Hook to enhance scrolling behavior for Chromebooks and touch devices
|
||||
*/
|
||||
export function useChromebookScroll() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// Enhanced scrolling for Chromebooks
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
// Prevent default touch behavior that might interfere with scrolling
|
||||
if (e.touches.length === 1) {
|
||||
// Single touch - allow normal scrolling
|
||||
return;
|
||||
}
|
||||
|
||||
// Multi-touch - prevent zoom gestures
|
||||
if (e.touches.length > 1) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
// Allow momentum scrolling on Chromebooks
|
||||
if (e.touches.length === 1) {
|
||||
// Single touch scrolling - allow default behavior
|
||||
return;
|
||||
}
|
||||
|
||||
// Multi-touch - prevent zoom
|
||||
if (e.touches.length > 1) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
// Enhanced wheel scrolling for Chromebook trackpads
|
||||
const delta = e.deltaY;
|
||||
const container = e.currentTarget as HTMLElement;
|
||||
|
||||
// Smooth scrolling for Chromebook trackpads
|
||||
if (Math.abs(delta) > 0) {
|
||||
container.scrollBy({
|
||||
top: delta * 0.5, // Reduce scroll speed for better control
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Add event listeners
|
||||
container.addEventListener('touchstart', handleTouchStart, { passive: false });
|
||||
container.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
container.addEventListener('wheel', handleWheel, { passive: true });
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
container.removeEventListener('touchstart', handleTouchStart);
|
||||
container.removeEventListener('touchmove', handleTouchMove);
|
||||
container.removeEventListener('wheel', handleWheel);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return containerRef;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for smooth scrolling to bottom (useful for chat interfaces)
|
||||
*/
|
||||
export function useSmoothScrollToBottom() {
|
||||
const scrollToBottom = (container: HTMLElement) => {
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
};
|
||||
|
||||
const scrollToBottomInstant = (container: HTMLElement) => {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
};
|
||||
|
||||
return { scrollToBottom, scrollToBottomInstant };
|
||||
}
|
||||
52
lib/hooks/use-mobile.tsx
Normal file
52
lib/hooks/use-mobile.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
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
|
||||
}
|
||||
|
||||
export function useIsTouchDevice() {
|
||||
const [isTouch, setIsTouch] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const checkTouch = () => {
|
||||
const hasTouch = 'ontouchstart' in window ||
|
||||
navigator.maxTouchPoints > 0 ||
|
||||
(navigator.userAgent.includes('CrOS') && 'ontouchstart' in window) ||
|
||||
window.matchMedia('(pointer: coarse)').matches ||
|
||||
!window.matchMedia('(hover: hover)').matches
|
||||
|
||||
setIsTouch(hasTouch)
|
||||
}
|
||||
|
||||
checkTouch()
|
||||
|
||||
const mediaQuery = window.matchMedia('(pointer: coarse)')
|
||||
const hoverQuery = window.matchMedia('(hover: hover)')
|
||||
|
||||
const handleChange = () => checkTouch()
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange)
|
||||
hoverQuery.addEventListener('change', handleChange)
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', handleChange)
|
||||
hoverQuery.removeEventListener('change', handleChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return !!isTouch
|
||||
}
|
||||
194
lib/hooks/use-toast.ts
Normal file
194
lib/hooks/use-toast.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
"use client"
|
||||
|
||||
// Inspired by react-hot-toast library
|
||||
import * as React from "react"
|
||||
|
||||
import type {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from "@/components/common/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 }
|
||||
165
lib/hooks/useFilterState.ts
Normal file
165
lib/hooks/useFilterState.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { usePathname, useSearchParams, useRouter } from "next/navigation"
|
||||
import { DateRange } from "react-day-picker"
|
||||
|
||||
interface FilterState {
|
||||
searchQuery?: string
|
||||
statusFilter?: string
|
||||
dateRange?: DateRange
|
||||
page?: number
|
||||
itemsPerPage?: number
|
||||
sortColumn?: string
|
||||
sortDirection?: "asc" | "desc"
|
||||
}
|
||||
|
||||
interface UseFilterStateOptions {
|
||||
/** Unique key for this page's filter state */
|
||||
storageKey: string
|
||||
/** Initialize from URL params on mount */
|
||||
syncWithUrl?: boolean
|
||||
/** Default values */
|
||||
defaults?: Partial<FilterState>
|
||||
}
|
||||
|
||||
/**
|
||||
* useFilterState - Persist filter state across navigation
|
||||
* Uses sessionStorage to remember filters per page
|
||||
*/
|
||||
export function useFilterState({
|
||||
storageKey,
|
||||
syncWithUrl = false,
|
||||
defaults = {}
|
||||
}: UseFilterStateOptions) {
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
|
||||
const fullKey = `filterState:${storageKey}`
|
||||
|
||||
// Initialize state from sessionStorage or URL params
|
||||
const getInitialState = (): FilterState => {
|
||||
if (typeof window === "undefined") return defaults
|
||||
|
||||
// First try URL params if syncWithUrl is enabled
|
||||
if (syncWithUrl && searchParams) {
|
||||
const urlState: FilterState = {}
|
||||
const status = searchParams.get("status")
|
||||
const search = searchParams.get("search")
|
||||
const page = searchParams.get("page")
|
||||
|
||||
if (status) urlState.statusFilter = status
|
||||
if (search) urlState.searchQuery = search
|
||||
if (page) urlState.page = parseInt(page)
|
||||
|
||||
if (Object.keys(urlState).length > 0) {
|
||||
return { ...defaults, ...urlState }
|
||||
}
|
||||
}
|
||||
|
||||
// Then try sessionStorage
|
||||
try {
|
||||
const stored = sessionStorage.getItem(fullKey)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored)
|
||||
// Restore dateRange as Date objects
|
||||
if (parsed.dateRange) {
|
||||
if (parsed.dateRange.from) parsed.dateRange.from = new Date(parsed.dateRange.from)
|
||||
if (parsed.dateRange.to) parsed.dateRange.to = new Date(parsed.dateRange.to)
|
||||
}
|
||||
return { ...defaults, ...parsed }
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to load filter state from storage:", e)
|
||||
}
|
||||
|
||||
return defaults
|
||||
}
|
||||
|
||||
const [filterState, setFilterState] = useState<FilterState>(getInitialState)
|
||||
|
||||
// Save to sessionStorage whenever state changes
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(fullKey, JSON.stringify(filterState))
|
||||
} catch (e) {
|
||||
console.warn("Failed to save filter state to storage:", e)
|
||||
}
|
||||
}, [filterState, fullKey])
|
||||
|
||||
// Update URL if syncWithUrl is enabled
|
||||
useEffect(() => {
|
||||
if (!syncWithUrl) return
|
||||
|
||||
const params = new URLSearchParams()
|
||||
if (filterState.statusFilter && filterState.statusFilter !== "all") {
|
||||
params.set("status", filterState.statusFilter)
|
||||
}
|
||||
if (filterState.searchQuery) {
|
||||
params.set("search", filterState.searchQuery)
|
||||
}
|
||||
if (filterState.page && filterState.page > 1) {
|
||||
params.set("page", filterState.page.toString())
|
||||
}
|
||||
|
||||
const queryString = params.toString()
|
||||
const newUrl = queryString ? `${pathname}?${queryString}` : pathname
|
||||
|
||||
// Only update if URL would change
|
||||
const currentQuery = searchParams?.toString() || ""
|
||||
if (queryString !== currentQuery) {
|
||||
router.replace(newUrl, { scroll: false })
|
||||
}
|
||||
}, [filterState, syncWithUrl, pathname, router, searchParams])
|
||||
|
||||
// Convenience setters
|
||||
const setSearchQuery = useCallback((query: string) => {
|
||||
setFilterState(prev => ({ ...prev, searchQuery: query, page: 1 }))
|
||||
}, [])
|
||||
|
||||
const setStatusFilter = useCallback((status: string) => {
|
||||
setFilterState(prev => ({ ...prev, statusFilter: status, page: 1 }))
|
||||
}, [])
|
||||
|
||||
const setDateRange = useCallback((range: DateRange | undefined) => {
|
||||
setFilterState(prev => ({ ...prev, dateRange: range, page: 1 }))
|
||||
}, [])
|
||||
|
||||
const setPage = useCallback((page: number) => {
|
||||
setFilterState(prev => ({ ...prev, page }))
|
||||
}, [])
|
||||
|
||||
const setItemsPerPage = useCallback((count: number) => {
|
||||
setFilterState(prev => ({ ...prev, itemsPerPage: count, page: 1 }))
|
||||
}, [])
|
||||
|
||||
const setSort = useCallback((column: string, direction: "asc" | "desc") => {
|
||||
setFilterState(prev => ({ ...prev, sortColumn: column, sortDirection: direction }))
|
||||
}, [])
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setFilterState(defaults)
|
||||
}, [defaults])
|
||||
|
||||
const hasActiveFilters = Boolean(
|
||||
filterState.searchQuery ||
|
||||
(filterState.statusFilter && filterState.statusFilter !== "all") ||
|
||||
filterState.dateRange?.from
|
||||
)
|
||||
|
||||
return {
|
||||
...filterState,
|
||||
setFilterState,
|
||||
setSearchQuery,
|
||||
setStatusFilter,
|
||||
setDateRange,
|
||||
setPage,
|
||||
setItemsPerPage,
|
||||
setSort,
|
||||
clearFilters,
|
||||
hasActiveFilters
|
||||
}
|
||||
}
|
||||
57
lib/hooks/useKeepOnline.ts
Normal file
57
lib/hooks/useKeepOnline.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { clientFetch } from "@/lib/api";
|
||||
|
||||
interface UseKeepOnlineOptions {
|
||||
interval?: number; // in milliseconds
|
||||
enabled?: boolean;
|
||||
onError?: (error: any) => void;
|
||||
}
|
||||
|
||||
export const useKeepOnline = (options: UseKeepOnlineOptions = {}) => {
|
||||
const {
|
||||
interval = 1000 * 60 * 1,
|
||||
enabled = true,
|
||||
onError
|
||||
} = options;
|
||||
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateOnlineStatus = async () => {
|
||||
try {
|
||||
console.log("Updating online status...");
|
||||
await clientFetch('/auth/me');
|
||||
} catch (error) {
|
||||
console.error("Failed to update online status:", error);
|
||||
onError?.(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial call
|
||||
updateOnlineStatus();
|
||||
|
||||
// Set up interval
|
||||
intervalRef.current = setInterval(updateOnlineStatus, interval);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [enabled, interval, onError]);
|
||||
|
||||
return {
|
||||
isActive: enabled && intervalRef.current !== null,
|
||||
stop: () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
50
lib/hooks/useUser.ts
Normal file
50
lib/hooks/useUser.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { clientFetch } from '@/lib/api/api-client'
|
||||
|
||||
interface Vendor {
|
||||
_id: string;
|
||||
username: string;
|
||||
storeId: string;
|
||||
pgpKey: string;
|
||||
__v: number;
|
||||
}
|
||||
|
||||
interface User {
|
||||
vendor: Vendor;
|
||||
}
|
||||
|
||||
export function useUser() {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const userData = await clientFetch<User>("/auth/me")
|
||||
setUser(userData)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch user:", err)
|
||||
setError("Failed to fetch user data")
|
||||
setUser(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchUser()
|
||||
}, [])
|
||||
|
||||
const isAdmin = user?.vendor?.username === 'admin1'
|
||||
|
||||
return {
|
||||
user,
|
||||
loading,
|
||||
error,
|
||||
isAdmin
|
||||
}
|
||||
}
|
||||
138
lib/hooks/useWidgetLayout.ts
Normal file
138
lib/hooks/useWidgetLayout.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import type { WidgetConfig, WidgetSettings } from "@/lib/types/dashboard"
|
||||
|
||||
const DEFAULT_WIDGETS: WidgetConfig[] = [
|
||||
{ id: "quick-actions", title: "Quick Actions", visible: true, order: 0 },
|
||||
{ id: "overview", title: "Overview", visible: true, order: 1, settings: { showChange: false } },
|
||||
{ id: "recent-activity", title: "Recent Activity", visible: true, order: 2, settings: { itemCount: 10 } },
|
||||
{ id: "top-products", title: "Top Products", visible: true, order: 3, settings: { itemCount: 5, showRevenue: true } },
|
||||
{ id: "revenue-chart", title: "Revenue Chart", visible: false, order: 4, settings: { days: 7, showComparison: false } },
|
||||
{ id: "low-stock", title: "Low Stock Alerts", visible: false, order: 5, settings: { threshold: 5, itemCount: 5 } },
|
||||
{ id: "recent-customers", title: "Recent Customers", visible: false, order: 6, settings: { itemCount: 5, showSpent: true } },
|
||||
{ id: "pending-chats", title: "Pending Chats", visible: false, order: 7, settings: { showPreview: true } },
|
||||
]
|
||||
|
||||
const STORAGE_KEY = "dashboard-widget-layout-v4"
|
||||
|
||||
/**
|
||||
* useWidgetLayout - Persist and manage dashboard widget visibility, order, and settings
|
||||
*/
|
||||
export function useWidgetLayout() {
|
||||
const [widgets, setWidgets] = useState<WidgetConfig[]>(DEFAULT_WIDGETS)
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
|
||||
// Load from localStorage on mount
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as WidgetConfig[]
|
||||
// Merge with defaults to handle new widgets added in future
|
||||
const merged = DEFAULT_WIDGETS.map(defaultWidget => {
|
||||
const savedWidget = parsed.find(w => w.id === defaultWidget.id)
|
||||
return savedWidget
|
||||
? { ...defaultWidget, ...savedWidget, settings: { ...defaultWidget.settings, ...savedWidget.settings } }
|
||||
: defaultWidget
|
||||
})
|
||||
setWidgets(merged.sort((a, b) => a.order - b.order))
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to load widget layout:", e)
|
||||
}
|
||||
setIsLoaded(true)
|
||||
}, [])
|
||||
|
||||
// Save to localStorage whenever widgets change
|
||||
useEffect(() => {
|
||||
if (!isLoaded || typeof window === "undefined") return
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(widgets))
|
||||
} catch (e) {
|
||||
console.warn("Failed to save widget layout:", e)
|
||||
}
|
||||
}, [widgets, isLoaded])
|
||||
|
||||
const toggleWidget = useCallback((widgetId: string) => {
|
||||
setWidgets(prev =>
|
||||
prev.map(w => w.id === widgetId ? { ...w, visible: !w.visible } : w)
|
||||
)
|
||||
}, [])
|
||||
|
||||
const moveWidget = useCallback((widgetId: string, direction: "up" | "down") => {
|
||||
setWidgets(prev => {
|
||||
const index = prev.findIndex(w => w.id === widgetId)
|
||||
if (index === -1) return prev
|
||||
|
||||
const newIndex = direction === "up" ? index - 1 : index + 1
|
||||
if (newIndex < 0 || newIndex >= prev.length) return prev
|
||||
|
||||
const newWidgets = [...prev]
|
||||
const [widget] = newWidgets.splice(index, 1)
|
||||
newWidgets.splice(newIndex, 0, widget)
|
||||
|
||||
// Update order values
|
||||
return newWidgets.map((w, i) => ({ ...w, order: i }))
|
||||
})
|
||||
}, [])
|
||||
|
||||
const updateWidgetSettings = useCallback((widgetId: string, newSettings: Record<string, any>) => {
|
||||
setWidgets(prev =>
|
||||
prev.map(w => w.id === widgetId
|
||||
? { ...w, settings: { ...w.settings, ...newSettings } }
|
||||
: w
|
||||
)
|
||||
)
|
||||
}, [])
|
||||
|
||||
|
||||
const getWidgetSettings = useCallback(<T extends Record<string, any>>(widgetId: string): T | undefined => {
|
||||
return widgets.find(w => w.id === widgetId)?.settings as T | undefined
|
||||
}, [widgets])
|
||||
|
||||
const resetLayout = useCallback(() => {
|
||||
setWidgets(DEFAULT_WIDGETS)
|
||||
}, [])
|
||||
|
||||
const getVisibleWidgets = useCallback(() => {
|
||||
return widgets.filter(w => w.visible).sort((a, b) => a.order - b.order)
|
||||
}, [widgets])
|
||||
|
||||
const isWidgetVisible = useCallback((widgetId: string) => {
|
||||
return widgets.find(w => w.id === widgetId)?.visible ?? true
|
||||
}, [widgets])
|
||||
|
||||
// Reorder widgets by moving activeId to overId's position
|
||||
const reorderWidgets = useCallback((activeId: string, overId: string) => {
|
||||
setWidgets(prev => {
|
||||
const oldIndex = prev.findIndex(w => w.id === activeId)
|
||||
const newIndex = prev.findIndex(w => w.id === overId)
|
||||
|
||||
if (oldIndex === -1 || newIndex === -1) return prev
|
||||
|
||||
const newWidgets = [...prev]
|
||||
const [removed] = newWidgets.splice(oldIndex, 1)
|
||||
newWidgets.splice(newIndex, 0, removed)
|
||||
|
||||
// Update order values
|
||||
return newWidgets.map((w, i) => ({ ...w, order: i }))
|
||||
})
|
||||
}, [])
|
||||
|
||||
return {
|
||||
widgets,
|
||||
toggleWidget,
|
||||
moveWidget,
|
||||
reorderWidgets,
|
||||
updateWidgetSettings,
|
||||
getWidgetSettings,
|
||||
resetLayout,
|
||||
getVisibleWidgets,
|
||||
isWidgetVisible,
|
||||
isLoaded
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user