refactor(auth): replace dashboard sessions with stateless tokens and session control (#6329)
* refactor(auth): replace dashboard sessions with stateless tokens * feat(auth): harden session issuance and distributed enforcement * fix(proxy): preserve trusted proxy compatibility defaults * refactor: address dashboard auth review feedback * refactor: remove classic frontend and flatten web app
This commit is contained in:
Vendored
+32
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
// System Configuration
|
||||
export { useSystemConfig } from './use-system-config'
|
||||
|
||||
// Navigation
|
||||
export { useTopNavLinks } from './use-top-nav-links'
|
||||
|
||||
// Notifications
|
||||
export { useNotifications } from './use-notifications'
|
||||
|
||||
// Utils
|
||||
export { useDebounce } from './use-debounce'
|
||||
|
||||
// Media Query
|
||||
export { useMediaQuery } from './use-media-query'
|
||||
Vendored
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { ROLE } from '@/lib/roles'
|
||||
/**
|
||||
* Hook for checking admin privileges
|
||||
*/
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
/**
|
||||
* Check if current user has admin privileges
|
||||
*/
|
||||
export function useIsAdmin(): boolean {
|
||||
const { user } = useAuthStore((state) => state.auth)
|
||||
return (user?.role ?? 0) >= ROLE.ADMIN
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { copyToClipboard as copyToClipboardUtil } from '@/lib/copy-to-clipboard'
|
||||
|
||||
type UseCopyToClipboardOptions = {
|
||||
/** Whether to show a global toast notification (default: true) */
|
||||
notify?: boolean
|
||||
/** Success message (default: localized 'Copied to clipboard') */
|
||||
successMessage?: string
|
||||
/** Error message (default: localized 'Failed to copy to clipboard') */
|
||||
errorMessage?: string
|
||||
/** Time to automatically reset copiedText (milliseconds, default: 2000) */
|
||||
resetAfterMs?: number
|
||||
}
|
||||
|
||||
export function useCopyToClipboard(options?: UseCopyToClipboardOptions) {
|
||||
const {
|
||||
notify = true,
|
||||
successMessage,
|
||||
errorMessage,
|
||||
resetAfterMs = 2000,
|
||||
} = options || {}
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [copiedText, setCopiedText] = useState<string | null>(null)
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const copyToClipboard = useCallback(
|
||||
async (text: string): Promise<boolean> => {
|
||||
const resolvedSuccessMessage = successMessage ?? t('Copied to clipboard')
|
||||
const resolvedErrorMessage =
|
||||
errorMessage ?? t('Failed to copy to clipboard')
|
||||
const success = await copyToClipboardUtil(text)
|
||||
|
||||
if (success) {
|
||||
setCopiedText(text)
|
||||
if (notify) {
|
||||
toast.success(resolvedSuccessMessage)
|
||||
}
|
||||
|
||||
// Clear previous timeout
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
|
||||
// Auto-reset after 2 seconds
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setCopiedText(null)
|
||||
}, resetAfterMs)
|
||||
|
||||
return true
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('All copy methods failed')
|
||||
if (notify) {
|
||||
toast.error(resolvedErrorMessage)
|
||||
}
|
||||
setCopiedText(null)
|
||||
return false
|
||||
}
|
||||
},
|
||||
[notify, successMessage, errorMessage, resetAfterMs, t]
|
||||
)
|
||||
|
||||
return { copiedText, copyToClipboard }
|
||||
}
|
||||
Vendored
+74
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
export interface UseCountdownOptions {
|
||||
initialSeconds?: number
|
||||
autoStart?: boolean
|
||||
}
|
||||
|
||||
export function useCountdown(options: UseCountdownOptions = {}) {
|
||||
const { initialSeconds = 30, autoStart = false } = options
|
||||
const [secondsLeft, setSecondsLeft] = useState<number>(initialSeconds)
|
||||
const [isActive, setIsActive] = useState<boolean>(autoStart)
|
||||
const timerRef = useRef<number | null>(null)
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
window.clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const stop = useCallback(() => {
|
||||
clearTimer()
|
||||
setIsActive(false)
|
||||
}, [clearTimer])
|
||||
|
||||
const start = useCallback(
|
||||
(seconds?: number) => {
|
||||
const total = seconds ?? initialSeconds
|
||||
setSecondsLeft(total)
|
||||
setIsActive(true)
|
||||
clearTimer()
|
||||
timerRef.current = window.setInterval(() => {
|
||||
setSecondsLeft((s) => {
|
||||
if (s <= 1) {
|
||||
clearTimer()
|
||||
setIsActive(false)
|
||||
return initialSeconds
|
||||
}
|
||||
return s - 1
|
||||
})
|
||||
}, 1000)
|
||||
},
|
||||
[clearTimer, initialSeconds]
|
||||
)
|
||||
|
||||
const reset = useCallback(() => {
|
||||
stop()
|
||||
setSecondsLeft(initialSeconds)
|
||||
}, [initialSeconds, stop])
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearTimer()
|
||||
}, [clearTimer])
|
||||
|
||||
return { secondsLeft, isActive, start, stop, reset }
|
||||
}
|
||||
Vendored
+41
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Debounce a value by delaying its update
|
||||
* @param value - The value to debounce
|
||||
* @param delay - Delay in milliseconds (default: 500ms)
|
||||
* @returns The debounced value
|
||||
*/
|
||||
export function useDebounce<T>(value: T, delay: number = 500): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value)
|
||||
}, delay)
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler)
|
||||
}
|
||||
}, [value, delay])
|
||||
|
||||
return debouncedValue
|
||||
}
|
||||
Vendored
+186
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
} from 'react'
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface DialogHandlers {
|
||||
open: () => void
|
||||
close: () => void
|
||||
toggle: () => void
|
||||
}
|
||||
|
||||
export interface DialogStateHandlers {
|
||||
reset: () => void
|
||||
isOpen: boolean
|
||||
}
|
||||
|
||||
export interface DialogsHandlers<T extends string> {
|
||||
open: (key: T) => void
|
||||
close: (key: T) => void
|
||||
toggle: (key: T) => void
|
||||
isOpen: (key: T) => boolean
|
||||
closeAll: () => void
|
||||
hasAnyOpen: boolean
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Dialog State Management Hooks
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Simple hook for managing a single dialog state with boolean value
|
||||
* @param initialOpen Initial dialog open state (default: false)
|
||||
* @returns Tuple of [isOpen, handlers]
|
||||
* @example
|
||||
* const [isOpen, handlers] = useDialog()
|
||||
* handlers.open()
|
||||
* handlers.close()
|
||||
* handlers.toggle()
|
||||
*/
|
||||
export function useDialog(
|
||||
initialOpen = false
|
||||
): readonly [boolean, DialogHandlers] {
|
||||
const [isOpen, setIsOpen] = useState(initialOpen)
|
||||
|
||||
const handlers: DialogHandlers = useMemo(
|
||||
() => ({
|
||||
open: () => setIsOpen(true),
|
||||
close: () => setIsOpen(false),
|
||||
toggle: () => setIsOpen((prev) => !prev),
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
return [isOpen, handlers] as const
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing dialog state with custom value types
|
||||
* Useful for dialogs that need to track which item is being edited/viewed
|
||||
* @param initialState Initial dialog state (default: null)
|
||||
* @returns Tuple of [state, setState, handlers]
|
||||
* @example
|
||||
* const [status, setStatus, handlers] = useDialogState<"approve" | "reject">()
|
||||
* setStatus('approve')
|
||||
* if (handlers.isOpen) {}
|
||||
* handlers.reset()
|
||||
*
|
||||
* // Or with objects:
|
||||
* const [user, setUser, handlers] = useDialogState<User>()
|
||||
* setUser({ id: 1, name: 'John' })
|
||||
*/
|
||||
export function useDialogState<T = unknown>(
|
||||
initialState: T | null = null
|
||||
): readonly [
|
||||
T | null,
|
||||
Dispatch<SetStateAction<T | null>>,
|
||||
DialogStateHandlers,
|
||||
] {
|
||||
const [state, setState] = useState<T | null>(initialState)
|
||||
|
||||
const reset = useCallback(() => setState(null), [])
|
||||
|
||||
const handlers: DialogStateHandlers = useMemo(
|
||||
() => ({
|
||||
reset,
|
||||
isOpen: state !== null,
|
||||
}),
|
||||
[state, reset]
|
||||
)
|
||||
|
||||
return [state, setState, handlers] as const
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing multiple independent dialog states
|
||||
* Useful when you have multiple dialogs that can be open simultaneously
|
||||
* @returns Object with methods to manage multiple dialogs
|
||||
* @example
|
||||
* const dialogs = useDialogs<'create' | 'edit' | 'delete'>()
|
||||
* dialogs.open('create')
|
||||
* dialogs.close('edit')
|
||||
* dialogs.toggle('delete')
|
||||
* dialogs.isOpen('create')
|
||||
* dialogs.closeAll()
|
||||
*/
|
||||
export function useDialogs<T extends string>(): DialogsHandlers<T> {
|
||||
const [openDialogs, setOpenDialogs] = useState<Set<T>>(new Set())
|
||||
|
||||
const open = useCallback((key: T) => {
|
||||
setOpenDialogs((prev) => {
|
||||
if (prev.has(key)) return prev
|
||||
const next = new Set(prev)
|
||||
next.add(key)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const close = useCallback((key: T) => {
|
||||
setOpenDialogs((prev) => {
|
||||
if (!prev.has(key)) return prev
|
||||
const next = new Set(prev)
|
||||
next.delete(key)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggle = useCallback((key: T) => {
|
||||
setOpenDialogs((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(key)) {
|
||||
next.delete(key)
|
||||
} else {
|
||||
next.add(key)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const closeAll = useCallback(() => {
|
||||
setOpenDialogs((prev) => (prev.size === 0 ? prev : new Set()))
|
||||
}, [])
|
||||
|
||||
const hasAnyOpen = useMemo(() => openDialogs.size > 0, [openDialogs])
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
isOpen: (key: T) => openDialogs.has(key),
|
||||
closeAll,
|
||||
hasAnyOpen,
|
||||
}),
|
||||
[open, close, toggle, openDialogs, closeAll, hasAnyOpen]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use named import useDialogState instead
|
||||
*/
|
||||
export default useDialogState
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
|
||||
type HiddenClickUnlockOptions = {
|
||||
requiredClicks?: number
|
||||
disabled?: boolean
|
||||
onUnlock?: () => void
|
||||
}
|
||||
|
||||
type HiddenClickUnlockResult = {
|
||||
unlocked: boolean
|
||||
handleClick: () => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
/** Unlock hidden UI after a repeated click gesture. */
|
||||
export function useHiddenClickUnlock(
|
||||
options: HiddenClickUnlockOptions = {}
|
||||
): HiddenClickUnlockResult {
|
||||
const disabled = options.disabled ?? false
|
||||
const requiredClicks = options.requiredClicks ?? 3
|
||||
const onUnlock = options.onUnlock
|
||||
const clickCountRef = useRef(0)
|
||||
const [unlocked, setUnlocked] = useState(false)
|
||||
|
||||
const reset = useCallback((): void => {
|
||||
clickCountRef.current = 0
|
||||
setUnlocked(false)
|
||||
}, [])
|
||||
|
||||
const handleClick = useCallback((): void => {
|
||||
if (disabled || unlocked) return
|
||||
|
||||
const nextClickCount = clickCountRef.current + 1
|
||||
clickCountRef.current = nextClickCount
|
||||
|
||||
if (nextClickCount >= requiredClicks) {
|
||||
clickCountRef.current = 0
|
||||
setUnlocked(true)
|
||||
onUnlock?.()
|
||||
}
|
||||
}, [disabled, onUnlock, requiredClicks, unlocked])
|
||||
|
||||
return {
|
||||
unlocked,
|
||||
handleClick,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
Vendored
+50
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
/**
|
||||
* React hook for responsive media queries
|
||||
* @param query - CSS media query string (e.g., "(max-width: 640px)")
|
||||
* @returns boolean indicating if the query matches
|
||||
*/
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
return useSyncExternalStore(
|
||||
(onStoreChange) => {
|
||||
// Return early if window is not available (SSR)
|
||||
if (typeof window === 'undefined') {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
const media = window.matchMedia(query)
|
||||
media.addEventListener('change', onStoreChange)
|
||||
return () => media.removeEventListener('change', onStoreChange)
|
||||
},
|
||||
() => {
|
||||
// Client-side: return the current match state
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.matchMedia(query).matches
|
||||
}
|
||||
return false
|
||||
},
|
||||
() => {
|
||||
// Server-side: return false as fallback
|
||||
return false
|
||||
}
|
||||
)
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* Ensures a loading skeleton is shown for at least `minimumTime` ms
|
||||
* to prevent flickering when data loads too quickly.
|
||||
*/
|
||||
export function useMinimumLoadingTime(
|
||||
loading: boolean,
|
||||
minimumTime = 1000
|
||||
): boolean {
|
||||
const [showSkeleton, setShowSkeleton] = useState(loading)
|
||||
// eslint-disable-next-line react-hooks/purity
|
||||
const loadingStartRef = useRef(Date.now())
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
loadingStartRef.current = Date.now()
|
||||
setShowSkeleton(true)
|
||||
} else {
|
||||
const elapsed = Date.now() - loadingStartRef.current
|
||||
const remaining = Math.max(0, minimumTime - elapsed)
|
||||
|
||||
if (remaining === 0) {
|
||||
setShowSkeleton(false)
|
||||
} else {
|
||||
const timer = setTimeout(() => setShowSkeleton(false), remaining)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}, [loading, minimumTime])
|
||||
|
||||
return showSkeleton
|
||||
}
|
||||
Vendored
+37
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
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
|
||||
}
|
||||
Vendored
+37
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
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
|
||||
}
|
||||
Vendored
+187
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState, useMemo } from 'react'
|
||||
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { getNotice } from '@/lib/api'
|
||||
import { useNotificationStore } from '@/stores/notification-store'
|
||||
|
||||
function hashString(input: string): string {
|
||||
let hash = 0
|
||||
if (!input) return '0'
|
||||
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
const chr = input.charCodeAt(i)
|
||||
hash = (hash << 5) - hash + chr
|
||||
hash |= 0
|
||||
}
|
||||
|
||||
return hash.toString(36)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique key for an announcement
|
||||
* Prefer backend id, fall back to a content hash so edits register
|
||||
*/
|
||||
function getAnnouncementKey(item: Record<string, unknown>): string {
|
||||
if (!item) return ''
|
||||
|
||||
if (item.id !== undefined && item.id !== null) {
|
||||
return `id:${item.id}`
|
||||
}
|
||||
|
||||
const fingerprint = JSON.stringify({
|
||||
publishDate: (item?.publishDate as string) || '',
|
||||
content: ((item?.content as string) || '').trim(),
|
||||
extra: ((item?.extra as string) || '').trim(),
|
||||
type: (item?.type as string) || '',
|
||||
title: ((item?.title as string) || '').trim(),
|
||||
link: ((item?.link as string) || '').trim(),
|
||||
})
|
||||
return `hash:${hashString(fingerprint)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage notifications (Notice + Announcements)
|
||||
* Provides unread counts and read status management
|
||||
*/
|
||||
export function useNotifications() {
|
||||
const [popoverOpen, setPopoverOpen] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<'notice' | 'announcements'>(
|
||||
'notice'
|
||||
)
|
||||
|
||||
// Fetch Notice from API
|
||||
const {
|
||||
data: noticeResponse,
|
||||
isLoading: noticeLoading,
|
||||
refetch: refetchNotice,
|
||||
} = useQuery({
|
||||
queryKey: ['notice'],
|
||||
queryFn: getNotice,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
})
|
||||
|
||||
// Fetch Announcements from status
|
||||
const { status, loading: statusLoading } = useStatus()
|
||||
const announcementsEnabled = status?.announcements_enabled ?? false
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const announcements: Record<string, unknown>[] = announcementsEnabled
|
||||
? ((status?.announcements || []) as Record<string, unknown>[]).slice(0, 20)
|
||||
: []
|
||||
|
||||
// Notification store
|
||||
const {
|
||||
lastReadNotice,
|
||||
markNoticeRead,
|
||||
markAnnouncementsRead,
|
||||
isAnnouncementRead,
|
||||
} = useNotificationStore()
|
||||
|
||||
// Extract notice content
|
||||
const noticeContent = noticeResponse?.success
|
||||
? (noticeResponse.data || '').trim()
|
||||
: ''
|
||||
|
||||
// Calculate unread counts
|
||||
const unreadCounts = useMemo(() => {
|
||||
const noticeUnread =
|
||||
noticeContent && noticeContent !== lastReadNotice ? 1 : 0
|
||||
|
||||
const announcementsUnread = announcements.filter(
|
||||
(item: Record<string, unknown>) => {
|
||||
const key = getAnnouncementKey(item)
|
||||
return !isAnnouncementRead(key)
|
||||
}
|
||||
).length
|
||||
|
||||
return {
|
||||
notice: noticeUnread,
|
||||
announcements: announcementsUnread,
|
||||
total: noticeUnread + announcementsUnread,
|
||||
}
|
||||
}, [noticeContent, lastReadNotice, announcements, isAnnouncementRead])
|
||||
|
||||
const markAnnouncementsAsRead = () => {
|
||||
if (announcements.length > 0) {
|
||||
const allKeys = announcements.map((item: Record<string, unknown>) =>
|
||||
getAnnouncementKey(item)
|
||||
)
|
||||
markAnnouncementsRead(allKeys)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle popover open
|
||||
const handleOpenPopover = (tab?: 'notice' | 'announcements') => {
|
||||
const nextTab = tab || activeTab
|
||||
|
||||
// Mark currently visible content as read when opening the notification center
|
||||
if (noticeContent) {
|
||||
markNoticeRead(noticeContent)
|
||||
}
|
||||
if (nextTab === 'announcements') {
|
||||
markAnnouncementsAsRead()
|
||||
}
|
||||
|
||||
setActiveTab(nextTab)
|
||||
setPopoverOpen(true)
|
||||
}
|
||||
|
||||
const handlePopoverOpenChange = (open: boolean) => {
|
||||
if (open) {
|
||||
handleOpenPopover(activeTab)
|
||||
return
|
||||
}
|
||||
|
||||
setPopoverOpen(false)
|
||||
}
|
||||
|
||||
// Handle tab change - mark announcements as read when switching to that tab
|
||||
const handleTabChange = (tab: 'notice' | 'announcements') => {
|
||||
setActiveTab(tab)
|
||||
|
||||
if (tab === 'announcements') {
|
||||
markAnnouncementsAsRead()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// Data
|
||||
notice: noticeContent,
|
||||
announcements,
|
||||
loading: noticeLoading || statusLoading,
|
||||
|
||||
// Unread counts
|
||||
unreadCount: unreadCounts.total,
|
||||
unreadNoticeCount: unreadCounts.notice,
|
||||
unreadAnnouncementsCount: unreadCounts.announcements,
|
||||
|
||||
// Popover state
|
||||
popoverOpen,
|
||||
setPopoverOpen: handlePopoverOpenChange,
|
||||
activeTab,
|
||||
setActiveTab: handleTabChange,
|
||||
|
||||
// Actions
|
||||
openPopover: handleOpenPopover,
|
||||
closePopover: () => setPopoverOpen(false),
|
||||
refetchNotice,
|
||||
}
|
||||
}
|
||||
Vendored
+331
@@ -0,0 +1,331 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import type { NavGroup, NavItem } from '@/components/layout/types'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
type SidebarSectionConfig = {
|
||||
enabled: boolean
|
||||
[key: string]: boolean
|
||||
}
|
||||
|
||||
type SidebarModulesAdminConfig = Record<string, SidebarSectionConfig>
|
||||
|
||||
// User-layer config is shape-identical to admin, but may be null
|
||||
// to signal "no narrowing" (empty/invalid/legacy users).
|
||||
type SidebarModulesUserConfig = SidebarModulesAdminConfig | null
|
||||
|
||||
/**
|
||||
* Default sidebar modules configuration
|
||||
*/
|
||||
const DEFAULT_SIDEBAR_MODULES: SidebarModulesAdminConfig = {
|
||||
chat: {
|
||||
enabled: true,
|
||||
playground: true,
|
||||
chat: true,
|
||||
},
|
||||
console: {
|
||||
enabled: true,
|
||||
detail: true,
|
||||
token: true,
|
||||
log: true,
|
||||
midjourney: true,
|
||||
task: true,
|
||||
},
|
||||
personal: {
|
||||
enabled: true,
|
||||
topup: true,
|
||||
personal: true,
|
||||
},
|
||||
admin: {
|
||||
enabled: true,
|
||||
channel: true,
|
||||
models: true,
|
||||
redemption: true,
|
||||
user: true,
|
||||
setting: true,
|
||||
subscription: true,
|
||||
},
|
||||
}
|
||||
|
||||
const mergeWithDefaultSidebarModules = (
|
||||
config: SidebarModulesAdminConfig
|
||||
): SidebarModulesAdminConfig => {
|
||||
const merged: SidebarModulesAdminConfig = { ...config }
|
||||
|
||||
Object.entries(DEFAULT_SIDEBAR_MODULES).forEach(
|
||||
([sectionKey, defaultSection]) => {
|
||||
const existingSection = merged[sectionKey]
|
||||
if (!existingSection) {
|
||||
merged[sectionKey] = { ...defaultSection }
|
||||
return
|
||||
}
|
||||
|
||||
merged[sectionKey] = { ...defaultSection, ...existingSection }
|
||||
Object.keys(defaultSection).forEach((moduleKey) => {
|
||||
if (merged[sectionKey][moduleKey] === undefined) {
|
||||
merged[sectionKey][moduleKey] = defaultSection[moduleKey]
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping from URL to configuration keys
|
||||
*/
|
||||
const URL_TO_CONFIG_MAP: Record<string, { section: string; module: string }> = {
|
||||
'/playground': { section: 'chat', module: 'playground' },
|
||||
'/dashboard': { section: 'console', module: 'detail' },
|
||||
'/dashboard/overview': { section: 'console', module: 'detail' },
|
||||
'/dashboard/models': { section: 'console', module: 'detail' },
|
||||
'/dashboard/users': { section: 'console', module: 'detail' },
|
||||
'/keys': { section: 'console', module: 'token' },
|
||||
'/usage-logs': { section: 'console', module: 'log' },
|
||||
'/usage-logs/common': { section: 'console', module: 'log' },
|
||||
'/usage-logs/drawing': { section: 'console', module: 'midjourney' },
|
||||
'/usage-logs/task': { section: 'console', module: 'task' },
|
||||
'/wallet': { section: 'personal', module: 'topup' },
|
||||
'/profile': { section: 'personal', module: 'personal' },
|
||||
'/channels': { section: 'admin', module: 'channel' },
|
||||
'/models': { section: 'admin', module: 'models' },
|
||||
'/models/metadata': { section: 'admin', module: 'models' },
|
||||
'/models/deployments': { section: 'admin', module: 'models' },
|
||||
'/users': { section: 'admin', module: 'user' },
|
||||
'/redemption-codes': { section: 'admin', module: 'redemption' },
|
||||
'/subscriptions': { section: 'admin', module: 'subscription' },
|
||||
'/system-settings': { section: 'admin', module: 'setting' },
|
||||
'/system-settings/site': { section: 'admin', module: 'setting' },
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse backend SidebarModulesAdmin configuration
|
||||
*/
|
||||
function parseSidebarConfig(
|
||||
value: string | null | undefined
|
||||
): SidebarModulesAdminConfig {
|
||||
// If empty string, null, or undefined, use default config
|
||||
if (!value || value.trim() === '') {
|
||||
return DEFAULT_SIDEBAR_MODULES
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value) as SidebarModulesAdminConfig
|
||||
return mergeWithDefaultSidebarModules(parsed)
|
||||
} catch {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to parse sidebar modules configuration')
|
||||
return DEFAULT_SIDEBAR_MODULES
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse user-level sidebar_modules. Returns null when the value is empty,
|
||||
* invalid, or otherwise unusable — the caller treats null as "do not narrow",
|
||||
* so legacy users with an empty sidebar_modules field keep the full admin view.
|
||||
*/
|
||||
function parseUserSidebarConfig(
|
||||
value: string | null | undefined
|
||||
): SidebarModulesUserConfig {
|
||||
if (!value || value.trim() === '') {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value) as SidebarModulesAdminConfig
|
||||
if (!parsed || typeof parsed !== 'object') return null
|
||||
return parsed
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a module is enabled. Admin config is the first (authoritative)
|
||||
* layer: if admin disables a section/module it is always hidden. User config
|
||||
* is a second narrower layer: it can only further hide what admin allowed.
|
||||
* A null user config means "do not narrow" (legacy/empty users).
|
||||
*/
|
||||
function isModuleEnabled(
|
||||
url: string,
|
||||
adminConfig: SidebarModulesAdminConfig,
|
||||
userConfig: SidebarModulesUserConfig
|
||||
): boolean {
|
||||
const mapping = URL_TO_CONFIG_MAP[url]
|
||||
if (!mapping) {
|
||||
// No mapping config, default to visible (e.g. system settings and new features)
|
||||
return true
|
||||
}
|
||||
|
||||
const { section, module } = mapping
|
||||
const adminSection = adminConfig[section]
|
||||
const adminAllowed = Boolean(
|
||||
adminSection && adminSection.enabled && adminSection[module] === true
|
||||
)
|
||||
if (!adminAllowed) return false
|
||||
|
||||
if (!userConfig) return true
|
||||
|
||||
const userSection = userConfig[section]
|
||||
if (!userSection) return true
|
||||
if (userSection.enabled === false) return false
|
||||
return userSection[module] !== false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a navigation item should be visible
|
||||
*/
|
||||
function isNavItemVisible(
|
||||
item: NavItem,
|
||||
adminConfig: SidebarModulesAdminConfig,
|
||||
userConfig: SidebarModulesUserConfig
|
||||
): boolean {
|
||||
// Handle dynamic chat presets type — also runs the admin × user AND gate
|
||||
if ('type' in item && item.type === 'chat-presets') {
|
||||
const adminChat = adminConfig.chat
|
||||
const adminAllowed = Boolean(adminChat?.enabled && adminChat.chat === true)
|
||||
if (!adminAllowed) return false
|
||||
if (!userConfig) return true
|
||||
const userChat = userConfig.chat
|
||||
if (!userChat) return true
|
||||
if (userChat.enabled === false) return false
|
||||
return userChat.chat !== false
|
||||
}
|
||||
|
||||
// Handle direct link type
|
||||
if ('url' in item && item.url) {
|
||||
const configUrls = item.configUrls ?? [item.url]
|
||||
return configUrls.some((url) =>
|
||||
isModuleEnabled(url as string, adminConfig, userConfig)
|
||||
)
|
||||
}
|
||||
|
||||
// Handle collapsible type (with sub-items)
|
||||
if ('items' in item && item.items) {
|
||||
// If has sub-items, show this collapsible item if at least one sub-item is visible
|
||||
return item.items.some((subItem) =>
|
||||
isModuleEnabled(subItem.url as string, adminConfig, userConfig)
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter navigation items
|
||||
*/
|
||||
function filterNavItems(
|
||||
items: NavItem[],
|
||||
adminConfig: SidebarModulesAdminConfig,
|
||||
userConfig: SidebarModulesUserConfig
|
||||
): NavItem[] {
|
||||
return items
|
||||
.map((item) => {
|
||||
// If collapsible item, also filter its sub-items
|
||||
if ('items' in item && item.items) {
|
||||
const filteredSubItems = item.items.filter((subItem) =>
|
||||
isModuleEnabled(subItem.url as string, adminConfig, userConfig)
|
||||
)
|
||||
|
||||
return {
|
||||
...item,
|
||||
items: filteredSubItems,
|
||||
}
|
||||
}
|
||||
return item
|
||||
})
|
||||
.filter((item) => isNavItemVisible(item, adminConfig, userConfig))
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter sidebar navigation groups by admin × user sidebar_modules config.
|
||||
*
|
||||
* Two layers, AND-combined:
|
||||
* 1. Admin (status.SidebarModulesAdmin) — authoritative, falls back to
|
||||
* DEFAULT_SIDEBAR_MODULES when empty/invalid. Disabling here hides the
|
||||
* item for everyone regardless of user preference.
|
||||
* 2. User (auth.user.sidebar_modules) — narrower overlay, null sentinel
|
||||
* means "don't narrow". A section/module is only hidden if the user
|
||||
* explicitly set it to false; undefined fields default to visible so
|
||||
* legacy users with empty sidebar_modules keep the full admin view.
|
||||
* The overlay is also skipped entirely when the backend tells us the
|
||||
* user cannot configure sidebar_settings (e.g. root accounts), so a
|
||||
* stale historical value cannot lock them out of entries they have no
|
||||
* UI to restore.
|
||||
*/
|
||||
export function useSidebarConfig(navGroups: NavGroup[]): NavGroup[] {
|
||||
const { status } = useStatus()
|
||||
const { auth } = useAuthStore()
|
||||
|
||||
const adminConfig = useMemo(
|
||||
() =>
|
||||
parseSidebarConfig(
|
||||
status?.SidebarModulesAdmin as string | null | undefined
|
||||
),
|
||||
[status?.SidebarModulesAdmin]
|
||||
)
|
||||
|
||||
const userConfig = useMemo(() => {
|
||||
// If the backend marks the user as unable to configure the sidebar
|
||||
// (e.g. root accounts), skip the user overlay entirely — a stale
|
||||
// historical sidebar_modules value from a previous role would otherwise
|
||||
// hide admin entries for someone who has no in-product UI to restore
|
||||
// them.
|
||||
if (auth?.user?.permissions?.sidebar_settings === false) {
|
||||
return null
|
||||
}
|
||||
return parseUserSidebarConfig(auth?.user?.sidebar_modules)
|
||||
}, [auth?.user?.permissions?.sidebar_settings, auth?.user?.sidebar_modules])
|
||||
|
||||
const filteredNavGroups = useMemo(
|
||||
() =>
|
||||
navGroups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: filterNavItems(group.items, adminConfig, userConfig),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0), // Only show navigation groups with visible items
|
||||
[navGroups, adminConfig, userConfig]
|
||||
)
|
||||
|
||||
return filteredNavGroups
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a single route is visible under the current sidebar_modules
|
||||
* config. Used by entries living outside the sidebar (e.g. the profile
|
||||
* dropdown's wallet link) so they honour the same "wallet display" toggle.
|
||||
*/
|
||||
export function useIsSidebarModuleVisible(url: string): boolean {
|
||||
const { status } = useStatus()
|
||||
const { auth } = useAuthStore()
|
||||
|
||||
const adminConfig = parseSidebarConfig(
|
||||
status?.SidebarModulesAdmin as string | null | undefined
|
||||
)
|
||||
const userConfig =
|
||||
auth?.user?.permissions?.sidebar_settings === false
|
||||
? null
|
||||
: parseUserSidebarConfig(auth?.user?.sidebar_modules)
|
||||
|
||||
return isModuleEnabled(url, adminConfig, userConfig)
|
||||
}
|
||||
Vendored
+163
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import {
|
||||
Activity,
|
||||
Box,
|
||||
CreditCard,
|
||||
FileText,
|
||||
FlaskConical,
|
||||
Key,
|
||||
LayoutDashboard,
|
||||
ListTodo,
|
||||
MessageSquare,
|
||||
Radio,
|
||||
ServerCog,
|
||||
Settings,
|
||||
Ticket,
|
||||
User,
|
||||
Users,
|
||||
Wallet,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { type SidebarData } from '@/components/layout/types'
|
||||
import { ROLE } from '@/lib/roles'
|
||||
|
||||
/**
|
||||
* Root navigation groups for the application sidebar.
|
||||
*
|
||||
* These are shown when the URL does not match any nested sidebar view
|
||||
* registered in `layout/lib/sidebar-view-registry.ts`.
|
||||
*/
|
||||
export function useSidebarData(): SidebarData {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return {
|
||||
navGroups: [
|
||||
{
|
||||
id: 'chat',
|
||||
title: t('Chat'),
|
||||
items: [
|
||||
{
|
||||
title: t('Playground'),
|
||||
url: '/playground',
|
||||
icon: FlaskConical,
|
||||
},
|
||||
{
|
||||
title: t('Chat'),
|
||||
icon: MessageSquare,
|
||||
type: 'chat-presets',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'general',
|
||||
title: t('General'),
|
||||
items: [
|
||||
{
|
||||
title: t('Overview'),
|
||||
url: '/dashboard/overview',
|
||||
icon: Activity,
|
||||
},
|
||||
{
|
||||
title: t('Dashboard'),
|
||||
url: '/dashboard/models',
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
title: t('API Keys'),
|
||||
url: '/keys',
|
||||
icon: Key,
|
||||
},
|
||||
{
|
||||
title: t('Usage Logs'),
|
||||
url: '/usage-logs/common',
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
title: t('Task Logs'),
|
||||
url: '/usage-logs/task',
|
||||
activeUrls: ['/usage-logs/drawing'],
|
||||
configUrls: ['/usage-logs/drawing', '/usage-logs/task'],
|
||||
icon: ListTodo,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'personal',
|
||||
title: t('Personal'),
|
||||
items: [
|
||||
{
|
||||
title: t('Wallet'),
|
||||
url: '/wallet',
|
||||
icon: Wallet,
|
||||
},
|
||||
{
|
||||
title: t('Profile'),
|
||||
url: '/profile',
|
||||
icon: User,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'admin',
|
||||
title: t('Admin'),
|
||||
items: [
|
||||
{
|
||||
title: t('Channels'),
|
||||
url: '/channels',
|
||||
icon: Radio,
|
||||
},
|
||||
{
|
||||
title: t('Models'),
|
||||
url: '/models/metadata',
|
||||
icon: Box,
|
||||
},
|
||||
{
|
||||
title: t('Users'),
|
||||
url: '/users',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
title: t('Redemption Codes'),
|
||||
url: '/redemption-codes',
|
||||
icon: Ticket,
|
||||
},
|
||||
{
|
||||
title: t('Subscriptions'),
|
||||
url: '/subscriptions',
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
title: t('System Info'),
|
||||
url: '/system-info',
|
||||
icon: ServerCog,
|
||||
requiredRole: ROLE.SUPER_ADMIN,
|
||||
},
|
||||
{
|
||||
title: t('System Settings'),
|
||||
url: '/system-settings/site',
|
||||
activeUrls: ['/system-settings'],
|
||||
icon: Settings,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
Vendored
+82
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useLocation } from '@tanstack/react-router'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { resolveSidebarView } from '@/components/layout/lib/sidebar-view-registry'
|
||||
import type { NavGroup, ResolvedSidebarView } from '@/components/layout/types'
|
||||
import { ROLE } from '@/lib/roles'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
import { useSidebarConfig } from './use-sidebar-config'
|
||||
import { useSidebarData } from './use-sidebar-data'
|
||||
|
||||
/** Sentinel key used for the root navigation in animation `key=` props */
|
||||
const ROOT_VIEW_KEY = '__root'
|
||||
|
||||
/**
|
||||
* Resolve the active sidebar view for the current location.
|
||||
*
|
||||
* - Returns the matching nested {@link SidebarView} (with its nav
|
||||
* groups) when the URL belongs to a registered drill-in workspace.
|
||||
* - Otherwise returns the root navigation, narrowed by:
|
||||
* · admin-only group visibility (role-based);
|
||||
* · `useSidebarConfig` (admin × user `sidebar_modules` overlay).
|
||||
*
|
||||
* Nested views are intentionally NOT passed through `useSidebarConfig`
|
||||
* — those filters target known dashboard URLs only, and gating is
|
||||
* already enforced at the route level (`beforeLoad` redirects).
|
||||
*/
|
||||
export function useSidebarView(): ResolvedSidebarView {
|
||||
const { t } = useTranslation()
|
||||
const pathname = useLocation({ select: (l) => l.pathname })
|
||||
const userRole = useAuthStore((s) => s.auth.user?.role)
|
||||
const rootSidebarData = useSidebarData()
|
||||
const configFilteredRoot = useSidebarConfig(rootSidebarData.navGroups)
|
||||
|
||||
const rootNavGroups = useMemo<NavGroup[]>(() => {
|
||||
const role = userRole ?? ROLE.GUEST
|
||||
const isAdmin = role >= ROLE.ADMIN
|
||||
return configFilteredRoot
|
||||
.filter((group) => (group.id === 'admin' ? isAdmin : true))
|
||||
.map((group) => {
|
||||
const items = group.items.filter(
|
||||
(item) => item.requiredRole === undefined || role >= item.requiredRole
|
||||
)
|
||||
return items.length === group.items.length ? group : { ...group, items }
|
||||
})
|
||||
}, [configFilteredRoot, userRole])
|
||||
|
||||
const view = resolveSidebarView(pathname)
|
||||
|
||||
if (view) {
|
||||
return {
|
||||
key: view.id,
|
||||
view,
|
||||
navGroups: view.getNavGroups(t),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
key: ROOT_VIEW_KEY,
|
||||
view: null,
|
||||
navGroups: rootNavGroups,
|
||||
}
|
||||
}
|
||||
Vendored
+82
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import type { SystemStatus } from '@/features/auth/types'
|
||||
import { getStatus } from '@/lib/api'
|
||||
import { useSystemConfigStore } from '@/stores/system-config-store'
|
||||
|
||||
import { mapStatusDataToConfig } from './use-system-config'
|
||||
|
||||
// Get initial cache from localStorage
|
||||
function getInitialStatus(): SystemStatus | undefined {
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = window.localStorage.getItem('status')
|
||||
return saved ? (JSON.parse(saved) as SystemStatus) : undefined
|
||||
}
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function useStatus() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['status'],
|
||||
queryFn: async () => {
|
||||
const status = await getStatus()
|
||||
try {
|
||||
if (status) {
|
||||
const { setConfig } = useSystemConfigStore.getState()
|
||||
setConfig(mapStatusDataToConfig(status))
|
||||
}
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
'[useStatus] Failed to sync status to system config',
|
||||
err
|
||||
)
|
||||
}
|
||||
}
|
||||
// Save to localStorage
|
||||
try {
|
||||
if (typeof window !== 'undefined' && status) {
|
||||
window.localStorage.setItem('status', JSON.stringify(status))
|
||||
}
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
return status as SystemStatus | null
|
||||
},
|
||||
// Use localStorage data as initial data
|
||||
placeholderData: getInitialStatus(),
|
||||
// Data becomes stale after 5 minutes
|
||||
staleTime: 5 * 60 * 1000,
|
||||
// Cache expires after 30 minutes
|
||||
gcTime: 30 * 60 * 1000,
|
||||
})
|
||||
|
||||
return {
|
||||
status: data ?? null,
|
||||
loading: isLoading,
|
||||
error,
|
||||
}
|
||||
}
|
||||
Vendored
+204
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useEffect, useCallback } from 'react'
|
||||
|
||||
import { DEFAULT_SYSTEM_NAME, DEFAULT_LOGO } from '@/lib/constants'
|
||||
import { applyFaviconToDom } from '@/lib/dom-utils'
|
||||
import {
|
||||
useSystemConfigStore,
|
||||
type CurrencyConfig,
|
||||
type CurrencyDisplayType,
|
||||
type SystemConfig,
|
||||
DEFAULT_CURRENCY_CONFIG,
|
||||
} from '@/stores/system-config-store'
|
||||
|
||||
interface UseSystemConfigOptions {
|
||||
/** Automatically fetch config from backend (use only in root component) */
|
||||
autoLoad?: boolean
|
||||
}
|
||||
|
||||
interface StatusApiResponse {
|
||||
success: boolean
|
||||
data: {
|
||||
system_name?: string
|
||||
logo?: string
|
||||
footer_html?: string
|
||||
demo_site_enabled?: boolean
|
||||
display_token_stat_enabled?: boolean
|
||||
display_in_currency?: boolean
|
||||
quota_display_type?: CurrencyDisplayType
|
||||
quota_per_unit?: number
|
||||
usd_exchange_rate?: number
|
||||
custom_currency_symbol?: string
|
||||
custom_currency_exchange_rate?: number
|
||||
}
|
||||
}
|
||||
|
||||
function toNumber(value: unknown, fallback: number): number {
|
||||
if (typeof value === 'number' && !Number.isNaN(value)) return value
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isNaN(parsed)) return parsed
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Map `/api/status` response data to our persisted system config structure
|
||||
*/
|
||||
export function mapStatusDataToConfig(
|
||||
data: StatusApiResponse['data'] | undefined
|
||||
): Partial<SystemConfig> {
|
||||
if (!data) return {}
|
||||
|
||||
const quotaDisplayType =
|
||||
(data.quota_display_type as CurrencyDisplayType | undefined) ??
|
||||
DEFAULT_CURRENCY_CONFIG.quotaDisplayType
|
||||
|
||||
const currency: CurrencyConfig = {
|
||||
displayInCurrency:
|
||||
data.display_in_currency ?? DEFAULT_CURRENCY_CONFIG.displayInCurrency,
|
||||
quotaDisplayType,
|
||||
quotaPerUnit: toNumber(
|
||||
data.quota_per_unit,
|
||||
DEFAULT_CURRENCY_CONFIG.quotaPerUnit
|
||||
),
|
||||
usdExchangeRate: toNumber(
|
||||
data.usd_exchange_rate,
|
||||
DEFAULT_CURRENCY_CONFIG.usdExchangeRate
|
||||
),
|
||||
customCurrencySymbol:
|
||||
data.custom_currency_symbol?.trim() ||
|
||||
DEFAULT_CURRENCY_CONFIG.customCurrencySymbol,
|
||||
customCurrencyExchangeRate: toNumber(
|
||||
data.custom_currency_exchange_rate,
|
||||
DEFAULT_CURRENCY_CONFIG.customCurrencyExchangeRate
|
||||
),
|
||||
}
|
||||
|
||||
return {
|
||||
systemName: data.system_name || DEFAULT_SYSTEM_NAME,
|
||||
logo: data.logo || DEFAULT_LOGO,
|
||||
footerHtml: data.footer_html,
|
||||
demoSiteEnabled: data.demo_site_enabled,
|
||||
displayTokenStatEnabled: data.display_token_stat_enabled,
|
||||
currency,
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch system config from API
|
||||
async function fetchSystemConfig(): Promise<Partial<SystemConfig>> {
|
||||
const response = await fetch('/api/status')
|
||||
if (!response.ok) throw new Error('Failed to fetch status')
|
||||
|
||||
const data: StatusApiResponse = await response.json()
|
||||
if (!data.success) throw new Error('API returned error')
|
||||
|
||||
return mapStatusDataToConfig(data.data)
|
||||
}
|
||||
|
||||
// Preload image and return cleanup function
|
||||
function preloadImage(
|
||||
src: string,
|
||||
onLoad: () => void,
|
||||
onError: () => void
|
||||
): () => void {
|
||||
const img = new Image()
|
||||
img.onload = onLoad
|
||||
img.onerror = onError
|
||||
img.src = src
|
||||
|
||||
return () => {
|
||||
img.onload = null
|
||||
img.onerror = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* System configuration hook with auto-loading and logo preloading
|
||||
*
|
||||
* @example
|
||||
* // Root component - auto-load from backend
|
||||
* useSystemConfig({ autoLoad: true })
|
||||
*
|
||||
* @example
|
||||
* // Other components - use cached config
|
||||
* const { systemName, logo, loading } = useSystemConfig()
|
||||
*/
|
||||
export function useSystemConfig(options: UseSystemConfigOptions = {}) {
|
||||
const { autoLoad = false } = options
|
||||
const {
|
||||
config,
|
||||
loading,
|
||||
loadedLogoUrl,
|
||||
setConfig,
|
||||
setLoadedLogoUrl,
|
||||
setLoading,
|
||||
} = useSystemConfigStore()
|
||||
|
||||
// Load config from backend
|
||||
const loadConfig = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const newConfig = await fetchSystemConfig()
|
||||
setConfig(newConfig)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to load system config:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [setConfig, setLoading])
|
||||
|
||||
useEffect(() => {
|
||||
if (autoLoad) loadConfig()
|
||||
}, [autoLoad, loadConfig])
|
||||
|
||||
// Preload logo image when URL changes
|
||||
useEffect(() => {
|
||||
const { logo } = config
|
||||
|
||||
// Skip if logo is already loaded
|
||||
if (!logo || logo === loadedLogoUrl) return
|
||||
|
||||
// Preload new logo
|
||||
return preloadImage(
|
||||
logo,
|
||||
() => {
|
||||
setLoadedLogoUrl(logo)
|
||||
applyFaviconToDom(logo)
|
||||
},
|
||||
() => {
|
||||
if (logo !== DEFAULT_LOGO) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to load logo:', logo)
|
||||
}
|
||||
// Mark as loaded even on error to prevent infinite retry
|
||||
setLoadedLogoUrl(logo)
|
||||
}
|
||||
)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config.logo, loadedLogoUrl, setLoadedLogoUrl])
|
||||
|
||||
return {
|
||||
...config,
|
||||
loading,
|
||||
logoLoaded: config.logo === loadedLogoUrl && !!loadedLogoUrl,
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
const STORAGE_KEY = 'table_compact_modes'
|
||||
|
||||
function getCompactMode(tableKey: string): boolean {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return false
|
||||
const modes = JSON.parse(raw) as Record<string, boolean>
|
||||
return Boolean(modes[tableKey])
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function setCompactMode(value: boolean, tableKey: string) {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
const modes = raw ? (JSON.parse(raw) as Record<string, boolean>) : {}
|
||||
modes[tableKey] = value
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(modes))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages per-table compact mode toggle persisted in localStorage.
|
||||
* Stays in sync across tabs via the storage event.
|
||||
*/
|
||||
export function useTableCompactMode(
|
||||
tableKey = 'global'
|
||||
): [boolean, (value: boolean) => void] {
|
||||
const [compact, setCompactState] = useState(() => getCompactMode(tableKey))
|
||||
|
||||
const setCompact = useCallback(
|
||||
(value: boolean) => {
|
||||
setCompactState(value)
|
||||
setCompactMode(value, tableKey)
|
||||
},
|
||||
[tableKey]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handleStorage = (e: StorageEvent) => {
|
||||
if (e.key === STORAGE_KEY) {
|
||||
try {
|
||||
const modes = JSON.parse(e.newValue || '{}') as Record<
|
||||
string,
|
||||
boolean
|
||||
>
|
||||
setCompactState(Boolean(modes[tableKey]))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener('storage', handleStorage)
|
||||
return () => window.removeEventListener('storage', handleStorage)
|
||||
}, [tableKey])
|
||||
|
||||
return [compact, setCompact]
|
||||
}
|
||||
Vendored
+266
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type {
|
||||
ColumnFiltersState,
|
||||
OnChangeFn,
|
||||
PaginationState,
|
||||
} from '@tanstack/react-table'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
type SearchRecord = Record<string, unknown>
|
||||
|
||||
// Page size persists globally under the established storage key (raw number
|
||||
// string), so the choice is remembered across tables and upgrades.
|
||||
const PAGE_SIZE_STORAGE_KEY = 'page-size'
|
||||
|
||||
function getStoredPageSize(): number | undefined {
|
||||
try {
|
||||
const n = parseInt(localStorage.getItem(PAGE_SIZE_STORAGE_KEY) ?? '', 10)
|
||||
return n > 0 ? n : undefined // n > 0 also rejects NaN
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function setStoredPageSize(size: number) {
|
||||
try {
|
||||
localStorage.setItem(PAGE_SIZE_STORAGE_KEY, String(size))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export type NavigateFn = (opts: {
|
||||
search:
|
||||
| true
|
||||
| SearchRecord
|
||||
| ((prev: SearchRecord) => Partial<SearchRecord> | SearchRecord)
|
||||
replace?: boolean
|
||||
}) => void
|
||||
|
||||
type UseTableUrlStateParams = {
|
||||
search: SearchRecord
|
||||
navigate: NavigateFn
|
||||
pagination?: {
|
||||
pageKey?: string
|
||||
pageSizeKey?: string
|
||||
defaultPage?: number
|
||||
defaultPageSize?: number
|
||||
}
|
||||
globalFilter?: {
|
||||
enabled?: boolean
|
||||
key?: string
|
||||
trim?: boolean
|
||||
}
|
||||
columnFilters?: Array<
|
||||
| {
|
||||
columnId: string
|
||||
searchKey: string
|
||||
type?: 'string'
|
||||
// Optional transformers for custom types
|
||||
serialize?: (value: unknown) => unknown
|
||||
deserialize?: (value: unknown) => unknown
|
||||
}
|
||||
| {
|
||||
columnId: string
|
||||
searchKey: string
|
||||
type: 'array'
|
||||
serialize?: (value: unknown) => unknown
|
||||
deserialize?: (value: unknown) => unknown
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
type UseTableUrlStateReturn = {
|
||||
// Global filter
|
||||
globalFilter?: string
|
||||
onGlobalFilterChange?: OnChangeFn<string>
|
||||
// Column filters
|
||||
columnFilters: ColumnFiltersState
|
||||
onColumnFiltersChange: OnChangeFn<ColumnFiltersState>
|
||||
// Pagination
|
||||
pagination: PaginationState
|
||||
onPaginationChange: OnChangeFn<PaginationState>
|
||||
// Helpers
|
||||
ensurePageInRange: (
|
||||
pageCount: number,
|
||||
opts?: { resetTo?: 'first' | 'last' }
|
||||
) => void
|
||||
}
|
||||
|
||||
export function useTableUrlState(
|
||||
params: UseTableUrlStateParams
|
||||
): UseTableUrlStateReturn {
|
||||
const {
|
||||
search,
|
||||
navigate,
|
||||
pagination: paginationCfg,
|
||||
globalFilter: globalFilterCfg,
|
||||
columnFilters: columnFiltersCfg = [],
|
||||
} = params
|
||||
|
||||
const pageKey = paginationCfg?.pageKey ?? ('page' as string)
|
||||
const pageSizeKey = paginationCfg?.pageSizeKey ?? ('pageSize' as string)
|
||||
const defaultPage = paginationCfg?.defaultPage ?? 1
|
||||
const defaultPageSize = paginationCfg?.defaultPageSize ?? 20
|
||||
|
||||
const globalFilterKey = globalFilterCfg?.key ?? ('filter' as string)
|
||||
const globalFilterEnabled = globalFilterCfg?.enabled ?? true
|
||||
const trimGlobal = globalFilterCfg?.trim ?? true
|
||||
|
||||
// Build initial column filters from the current search params
|
||||
const initialColumnFilters: ColumnFiltersState = useMemo(() => {
|
||||
const collected: ColumnFiltersState = []
|
||||
for (const cfg of columnFiltersCfg) {
|
||||
const raw = (search as SearchRecord)[cfg.searchKey]
|
||||
const deserialize = cfg.deserialize ?? ((v: unknown) => v)
|
||||
if (cfg.type === 'string') {
|
||||
const value = (deserialize(raw) as string) ?? ''
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
collected.push({ id: cfg.columnId, value })
|
||||
}
|
||||
} else {
|
||||
// default to array type
|
||||
const value = (deserialize(raw) as unknown[]) ?? []
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
collected.push({ id: cfg.columnId, value })
|
||||
}
|
||||
}
|
||||
}
|
||||
return collected
|
||||
}, [columnFiltersCfg, search])
|
||||
|
||||
const [columnFilters, setColumnFilters] =
|
||||
useState<ColumnFiltersState>(initialColumnFilters)
|
||||
|
||||
// URL 为单一数据源:仅当 search(URL)变化时同步,避免依赖 initialColumnFilters 造成死循环(config 常为内联引用)
|
||||
useEffect(() => {
|
||||
setColumnFilters(initialColumnFilters)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [search])
|
||||
|
||||
const pagination: PaginationState = useMemo(() => {
|
||||
const rawPage = (search as SearchRecord)[pageKey]
|
||||
const rawPageSize = (search as SearchRecord)[pageSizeKey]
|
||||
const pageNum = typeof rawPage === 'number' ? rawPage : defaultPage
|
||||
const pageSizeNum =
|
||||
typeof rawPageSize === 'number'
|
||||
? rawPageSize
|
||||
: (getStoredPageSize() ?? defaultPageSize)
|
||||
return { pageIndex: Math.max(0, pageNum - 1), pageSize: pageSizeNum }
|
||||
}, [search, pageKey, pageSizeKey, defaultPage, defaultPageSize])
|
||||
|
||||
const onPaginationChange: OnChangeFn<PaginationState> = (updater) => {
|
||||
const next = typeof updater === 'function' ? updater(pagination) : updater
|
||||
const nextPage = next.pageIndex + 1
|
||||
const nextPageSize = next.pageSize
|
||||
if (nextPageSize !== pagination.pageSize) setStoredPageSize(nextPageSize)
|
||||
navigate({
|
||||
search: (prev) => ({
|
||||
...(prev as SearchRecord),
|
||||
[pageKey]: nextPage <= defaultPage ? undefined : nextPage,
|
||||
[pageSizeKey]: nextPageSize,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const [globalFilter, setGlobalFilter] = useState<string | undefined>(() => {
|
||||
if (!globalFilterEnabled) return undefined
|
||||
const raw = (search as SearchRecord)[globalFilterKey]
|
||||
return typeof raw === 'string' ? raw : ''
|
||||
})
|
||||
|
||||
const onGlobalFilterChange: OnChangeFn<string> | undefined =
|
||||
globalFilterEnabled
|
||||
? (updater) => {
|
||||
const next =
|
||||
typeof updater === 'function'
|
||||
? updater(globalFilter ?? '')
|
||||
: updater
|
||||
const value = trimGlobal ? next.trim() : next
|
||||
setGlobalFilter(value)
|
||||
navigate({
|
||||
search: (prev) => ({
|
||||
...(prev as SearchRecord),
|
||||
[pageKey]: undefined,
|
||||
[globalFilterKey]: value ? value : undefined,
|
||||
}),
|
||||
})
|
||||
}
|
||||
: undefined
|
||||
|
||||
const onColumnFiltersChange: OnChangeFn<ColumnFiltersState> = (updater) => {
|
||||
const next =
|
||||
typeof updater === 'function' ? updater(columnFilters) : updater
|
||||
setColumnFilters(next)
|
||||
|
||||
const patch: Record<string, unknown> = {}
|
||||
|
||||
for (const cfg of columnFiltersCfg) {
|
||||
const found = next.find((f) => f.id === cfg.columnId)
|
||||
const serialize = cfg.serialize ?? ((v: unknown) => v)
|
||||
if (cfg.type === 'string') {
|
||||
const value =
|
||||
typeof found?.value === 'string' ? (found.value as string) : ''
|
||||
patch[cfg.searchKey] =
|
||||
value.trim() !== '' ? serialize(value) : undefined
|
||||
} else {
|
||||
const value = Array.isArray(found?.value)
|
||||
? (found!.value as unknown[])
|
||||
: []
|
||||
patch[cfg.searchKey] = value.length > 0 ? serialize(value) : undefined
|
||||
}
|
||||
}
|
||||
|
||||
navigate({
|
||||
search: (prev) => ({
|
||||
...(prev as SearchRecord),
|
||||
[pageKey]: undefined,
|
||||
...patch,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const ensurePageInRange = (
|
||||
pageCount: number,
|
||||
opts: { resetTo?: 'first' | 'last' } = { resetTo: 'first' }
|
||||
) => {
|
||||
const currentPage = (search as SearchRecord)[pageKey]
|
||||
const pageNum = typeof currentPage === 'number' ? currentPage : defaultPage
|
||||
if (pageCount > 0 && pageNum > pageCount) {
|
||||
navigate({
|
||||
replace: true,
|
||||
search: (prev) => ({
|
||||
...(prev as SearchRecord),
|
||||
[pageKey]: opts.resetTo === 'last' ? pageCount : undefined,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
globalFilter: globalFilterEnabled ? (globalFilter ?? '') : undefined,
|
||||
onGlobalFilterChange,
|
||||
columnFilters,
|
||||
onColumnFiltersChange,
|
||||
pagination,
|
||||
onPaginationChange,
|
||||
ensurePageInRange,
|
||||
}
|
||||
}
|
||||
Vendored
+104
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { parseHeaderNavModulesFromStatus } from '@/lib/nav-modules'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
export type TopNavLink = {
|
||||
title: string
|
||||
href: string
|
||||
disabled?: boolean
|
||||
requiresAuth?: boolean
|
||||
external?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate top navigation links based on HeaderNavModules configuration from backend /api/status
|
||||
* Backend format example (stringified JSON):
|
||||
* {
|
||||
* home: true,
|
||||
* console: true,
|
||||
* pricing: { enabled: true, requireAuth: false },
|
||||
* rankings: { enabled: true, requireAuth: false },
|
||||
* docs: true,
|
||||
* about: true
|
||||
* }
|
||||
*/
|
||||
export function useTopNavLinks(): TopNavLink[] {
|
||||
const { t } = useTranslation()
|
||||
const { status } = useStatus()
|
||||
const { auth } = useAuthStore()
|
||||
|
||||
// Parse HeaderNavModules
|
||||
const modules = useMemo(() => {
|
||||
return parseHeaderNavModulesFromStatus(
|
||||
status as Record<string, unknown> | null
|
||||
)
|
||||
}, [status])
|
||||
|
||||
// Documentation link (may be external)
|
||||
const docsLink: string | undefined = status?.docs_link as string | undefined
|
||||
|
||||
const isAuthed = !!auth?.user
|
||||
|
||||
const links: TopNavLink[] = []
|
||||
|
||||
// Home
|
||||
if (modules?.home !== false) {
|
||||
links.push({ title: t('Home'), href: '/' })
|
||||
}
|
||||
|
||||
// Console -> /dashboard (new console path)
|
||||
if (modules?.console !== false) {
|
||||
links.push({ title: t('Console'), href: '/dashboard' })
|
||||
}
|
||||
|
||||
// Pricing
|
||||
const pricing = modules?.pricing
|
||||
if (pricing && typeof pricing === 'object' && pricing.enabled) {
|
||||
const requiresAuth = pricing.requireAuth && !isAuthed
|
||||
links.push({ title: t('Model Square'), href: '/pricing', requiresAuth })
|
||||
}
|
||||
|
||||
// Rankings
|
||||
const rankings = modules?.rankings
|
||||
if (rankings && typeof rankings === 'object' && rankings.enabled) {
|
||||
const requiresAuth = rankings.requireAuth && !isAuthed
|
||||
links.push({ title: t('Rankings'), href: '/rankings', requiresAuth })
|
||||
}
|
||||
|
||||
// Docs (supports external links)
|
||||
if (modules?.docs !== false) {
|
||||
if (docsLink) {
|
||||
links.push({ title: t('Docs'), href: docsLink, external: true })
|
||||
} else {
|
||||
links.push({ title: t('Docs'), href: '/docs' })
|
||||
}
|
||||
}
|
||||
|
||||
// About
|
||||
if (modules?.about !== false) {
|
||||
links.push({ title: t('About'), href: '/about' })
|
||||
}
|
||||
|
||||
return links
|
||||
}
|
||||
Vendored
+75
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { getRoleLabel } from '@/lib/roles'
|
||||
import type { AuthUser } from '@/stores/auth-store'
|
||||
|
||||
/**
|
||||
* Custom hook to format user display information
|
||||
* Centralizes user display logic used across ProfileDropdown and MobileDrawer
|
||||
*/
|
||||
export function useUserDisplay(user: AuthUser | null | undefined) {
|
||||
const { t } = useTranslation()
|
||||
return useMemo(() => {
|
||||
if (!user) {
|
||||
return {
|
||||
displayName: t('User'),
|
||||
secondaryText: '',
|
||||
initials: 'U',
|
||||
roleLabel: '',
|
||||
}
|
||||
}
|
||||
|
||||
// Display name: priority order
|
||||
const displayName = user.display_name || user.username || t('User')
|
||||
|
||||
// Secondary text: first available identifier
|
||||
const secondaryText = (() => {
|
||||
if (user.email) return user.email
|
||||
if (user.github_id) return `GitHub ID: ${user.github_id}`
|
||||
if (user.oidc_id) return `OIDC ID: ${user.oidc_id}`
|
||||
if (user.wechat_id) return `WeChat ID: ${user.wechat_id}`
|
||||
if (user.telegram_id) return `Telegram ID: ${user.telegram_id}`
|
||||
if (user.linux_do_id) return `LinuxDO ID: ${user.linux_do_id}`
|
||||
if (user.username) return user.username
|
||||
if (user.display_name) return user.display_name
|
||||
return ''
|
||||
})()
|
||||
|
||||
// Generate initials from display name
|
||||
const initials = displayName
|
||||
.split(' ')
|
||||
.map((n: string) => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2)
|
||||
|
||||
// Get role label
|
||||
const roleLabel = getRoleLabel(user.role)
|
||||
|
||||
return {
|
||||
displayName,
|
||||
secondaryText,
|
||||
initials,
|
||||
roleLabel,
|
||||
}
|
||||
}, [user, t])
|
||||
}
|
||||
Reference in New Issue
Block a user