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:
Calcium-Ion
2026-07-20 16:48:43 +08:00
committed by GitHub
parent 5a6c53d496
commit 31d70fca39
1605 changed files with 17511 additions and 147913 deletions
+32
View File
@@ -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'
+31
View File
@@ -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
View File
@@ -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 }
}
+74
View File
@@ -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 }
}
+41
View File
@@ -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
}
+186
View File
@@ -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
View File
@@ -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,
}
}
+50
View File
@@ -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
View File
@@ -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
}
+37
View File
@@ -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
}
+37
View File
@@ -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
}
+187
View File
@@ -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,
}
}
+331
View File
@@ -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)
}
+163
View File
@@ -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,
},
],
},
],
}
}
+82
View File
@@ -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,
}
}
+82
View File
@@ -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,
}
}
+204
View File
@@ -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
View File
@@ -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]
}
+266
View File
@@ -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,
}
}
+104
View File
@@ -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
}
+75
View File
@@ -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])
}