fix: resolve React Error #310 and refactor admin section
Some checks failed
Deploy to Production / Build and Deploy (push) Has been cancelled

- Fix React bug #33580: remove Suspense boundaries co-located with Link components
- Delete settings/loading.tsx and admin/loading.tsx (root cause of race condition)
- Convert all admin navigation from Next.js Link to anchor tags
- Move admin pages to dedicated (admin) route group
- Add AdminHeader matching main header visual design
- Add AdminSidebar with anchor-based navigation
- Add /api/admin/models route handler (replaces server actions for GET)
- Add /api/debug/client-error for server-side browser error reporting
- Add useNoteRefreshOptional() to fix crash in AdminHeader
- Hide Admin Dashboard menu for non-admin users
- Change app icons from yellow to blue (#3A7CA5) matching brand primary
- Fix admin search bar width to match main header

Made-with: Cursor
This commit is contained in:
2026-04-25 20:46:10 +02:00
parent 1d53c16cc2
commit 986d438738
49 changed files with 1277 additions and 358 deletions

View File

@@ -1,6 +1,6 @@
'use client'
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
import { createContext, useContext, useState, useEffect, useCallback, useMemo, ReactNode } from 'react'
import { LabelColorName, LABEL_COLORS } from '@/lib/types'
import { getHashColor } from '@/lib/utils'
@@ -32,13 +32,12 @@ export function LabelProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(true)
const [notebookId, setNotebookId] = useState<string | null>(null)
const fetchLabels = async () => {
const fetchLabels = useCallback(async (nbId: string | null) => {
try {
setLoading(true)
// Build URL with notebookId filter
const url = new URL('/api/labels', window.location.origin)
if (notebookId) {
url.searchParams.set('notebookId', notebookId)
if (nbId) {
url.searchParams.set('notebookId', nbId)
}
const response = await fetch(url.toString(), {
@@ -54,14 +53,13 @@ export function LabelProvider({ children }: { children: ReactNode }) {
} finally {
setLoading(false)
}
}
}, [])
// Re-fetch labels when notebookId changes
useEffect(() => {
fetchLabels()
}, [notebookId])
fetchLabels(notebookId)
}, [notebookId, fetchLabels])
const addLabel = async (name: string, color?: LabelColorName, labelNotebookId?: string | null) => {
const addLabel = useCallback(async (name: string, color?: LabelColorName, labelNotebookId?: string | null) => {
try {
const labelColor = color || getHashColor(name);
const finalNotebookId = labelNotebookId || notebookId
@@ -79,9 +77,9 @@ export function LabelProvider({ children }: { children: ReactNode }) {
console.error('Failed to add label:', error)
throw error
}
}
}, [notebookId])
const updateLabel = async (id: string, updates: Partial<Pick<Label, 'name' | 'color'>>) => {
const updateLabel = useCallback(async (id: string, updates: Partial<Pick<Label, 'name' | 'color'>>) => {
try {
const response = await fetch(`/api/labels/${id}`, {
method: 'PUT',
@@ -90,7 +88,7 @@ export function LabelProvider({ children }: { children: ReactNode }) {
})
const data = await response.json()
if (data.success && data.data) {
setLabels(prev => prev.map(label =>
setLabels(prev => prev.map(label =>
label.id === id ? { ...label, ...data.data } : label
))
}
@@ -98,9 +96,9 @@ export function LabelProvider({ children }: { children: ReactNode }) {
console.error('Failed to update label:', error)
throw error
}
}
}, [])
const deleteLabel = async (id: string) => {
const deleteLabel = useCallback(async (id: string) => {
try {
const response = await fetch(`/api/labels/${id}`, {
method: 'DELETE',
@@ -112,18 +110,18 @@ export function LabelProvider({ children }: { children: ReactNode }) {
console.error('Failed to delete label:', error)
throw error
}
}
}, [])
const getLabelColorHelper = (name: string): LabelColorName => {
const getLabelColor = useCallback((name: string): LabelColorName => {
const label = labels.find(l => l.name.toLowerCase() === name.toLowerCase())
return label?.color || 'gray'
}
}, [labels])
const refreshLabels = async () => {
await fetchLabels()
}
const refreshLabels = useCallback(async () => {
await fetchLabels(notebookId)
}, [fetchLabels, notebookId])
const value: LabelContextType = {
const value = useMemo<LabelContextType>(() => ({
labels,
loading,
notebookId,
@@ -131,9 +129,9 @@ export function LabelProvider({ children }: { children: ReactNode }) {
addLabel,
updateLabel,
deleteLabel,
getLabelColor: getLabelColorHelper,
getLabelColor,
refreshLabels,
}
}), [labels, loading, notebookId, addLabel, updateLabel, deleteLabel, getLabelColor, refreshLabels])
return <LabelContext.Provider value={value}>{children}</LabelContext.Provider>
}
@@ -144,4 +142,4 @@ export function useLabels() {
throw new Error('useLabels must be used within a LabelProvider')
}
return context
}
}

View File

@@ -1,6 +1,6 @@
'use client'
import { createContext, useContext, useState, useCallback } from 'react'
import { createContext, useContext, useState, useCallback, useMemo } from 'react'
interface NoteRefreshContextType {
refreshKey: number
@@ -16,8 +16,10 @@ export function NoteRefreshProvider({ children }: { children: React.ReactNode })
setRefreshKey(prev => prev + 1)
}, [])
const value = useMemo(() => ({ refreshKey, triggerRefresh }), [refreshKey, triggerRefresh])
return (
<NoteRefreshContext.Provider value={{ refreshKey, triggerRefresh }}>
<NoteRefreshContext.Provider value={value}>
{children}
</NoteRefreshContext.Provider>
)
@@ -30,3 +32,12 @@ export function useNoteRefresh() {
}
return context
}
/**
* Same as useNoteRefresh but tolerates being called outside the provider
* (e.g. shared header rendered in admin pages). Returns a no-op when absent.
*/
export function useNoteRefreshOptional(): NoteRefreshContextType {
const context = useContext(NoteRefreshContext)
return context ?? { refreshKey: 0, triggerRefresh: () => {} }
}

View File

@@ -1,6 +1,6 @@
'use client'
import { createContext, useContext, useState, useCallback, ReactNode } from 'react'
import { createContext, useContext, useState, useCallback, useMemo, ReactNode } from 'react'
interface NotebookDragContextValue {
draggedNoteId: string | null
@@ -46,18 +46,18 @@ export function NotebookDragProvider({ children }: NotebookDragProviderProps) {
const isDragging = draggedNoteId !== null
const isDragOver = dragOverNotebookId !== null
const value = useMemo(() => ({
draggedNoteId,
dragOverNotebookId,
startDrag,
endDrag,
dragOver,
isDragging,
isDragOver,
}), [draggedNoteId, dragOverNotebookId, startDrag, endDrag, dragOver, isDragging, isDragOver])
return (
<NotebookDragContext.Provider
value={{
draggedNoteId,
dragOverNotebookId,
startDrag,
endDrag,
dragOver,
isDragging,
isDragOver,
}}
>
<NotebookDragContext.Provider value={value}>
{children}
</NotebookDragContext.Provider>
)