fix: resolve React Error #310 and refactor admin section
Some checks failed
Deploy to Production / Build and Deploy (push) Has been cancelled
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:
@@ -2,7 +2,6 @@
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft, TestTube } from 'lucide-react'
|
||||
import { AI_TESTER } from './ai-tester'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
@@ -14,11 +13,11 @@ export default function AITestPage() {
|
||||
<div className="container mx-auto py-10 px-4 max-w-6xl">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin/settings">
|
||||
<a href="/admin/settings">
|
||||
<Button variant="outline" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</a>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold flex items-center gap-2">
|
||||
<TestTube className="h-8 w-8" />
|
||||
@@ -1,7 +1,6 @@
|
||||
import { AdminMetrics } from '@/components/admin-metrics'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Zap, Settings, Activity, TrendingUp } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
export default async function AdminAIPage() {
|
||||
@@ -63,12 +62,12 @@ export default async function AdminAIPage() {
|
||||
Monitor and configure AI features
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/admin/settings">
|
||||
<a href="/admin/settings">
|
||||
<Button variant="outline">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Configure
|
||||
</Button>
|
||||
</Link>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<AdminMetrics metrics={aiMetrics} />
|
||||
30
memento-note/app/(admin)/admin/error.tsx
Normal file
30
memento-note/app/(admin)/admin/error.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export default function AdminError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error('Admin route error:', error)
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl rounded-lg border border-red-200 bg-red-50 p-6 text-red-900 dark:border-red-900 dark:bg-red-950/30 dark:text-red-100">
|
||||
<h2 className="text-lg font-semibold">Une erreur est survenue dans l'administration</h2>
|
||||
<p className="mt-2 text-sm opacity-90">
|
||||
Le rendu de cette page a echoue. Vous pouvez reessayer sans recharger toute l'application.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Button type="button" onClick={reset}>
|
||||
Reessayer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
22
memento-note/app/(admin)/admin/layout.tsx
Normal file
22
memento-note/app/(admin)/admin/layout.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { AdminHeader } from '@/components/admin-header'
|
||||
import { AdminSidebar } from '@/components/admin-sidebar'
|
||||
import { AdminContentArea } from '@/components/admin-content-area'
|
||||
|
||||
// Auth is enforced solely by middleware (auth.config.ts → authorized callback).
|
||||
// All cross-group navigation (admin ↔ main) uses <a> tags (full page reload)
|
||||
// to avoid React Error #310 caused by Next.js 16.x route-group transition bug.
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-background-light dark:bg-background-dark font-display text-slate-900 dark:text-white overflow-hidden h-screen flex flex-col">
|
||||
<AdminHeader />
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<AdminSidebar />
|
||||
<AdminContentArea>{children}</AdminContentArea>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,11 +7,8 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Combobox } from '@/components/ui/combobox'
|
||||
import { updateSystemConfig, testEmail } from '@/app/actions/admin-settings'
|
||||
import { getOllamaModels } from '@/app/actions/ollama'
|
||||
import { getCustomModels, getCustomEmbeddingModels } from '@/app/actions/custom-provider'
|
||||
import { toast } from 'sonner'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { TestTube, ExternalLink, RefreshCw } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
@@ -73,7 +70,8 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
const [isLoadingCustomEmbeddingsModels, setIsLoadingCustomEmbeddingsModels] = useState(false)
|
||||
const [isLoadingCustomChatModels, setIsLoadingCustomChatModels] = useState(false)
|
||||
|
||||
// Fetch Ollama models
|
||||
// Fetch Ollama models via Route API (not Server Action) to avoid App Router
|
||||
// action queue dispatch during render, which causes React Error #310.
|
||||
const fetchOllamaModels = useCallback(async (type: 'tags' | 'embeddings' | 'chat', url: string) => {
|
||||
if (!url) return
|
||||
|
||||
@@ -82,7 +80,9 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
else setIsLoadingChatModels(true)
|
||||
|
||||
try {
|
||||
const result = await getOllamaModels(url)
|
||||
const params = new URLSearchParams({ type: 'ollama', url })
|
||||
const res = await fetch(`/api/admin/models?${params}`)
|
||||
const result = await res.json()
|
||||
|
||||
if (result.success) {
|
||||
if (type === 'tags') setOllamaTagsModels(result.models)
|
||||
@@ -101,7 +101,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Fetch Custom provider models — tags use /v1/models, embeddings use /v1/embeddings/models
|
||||
// Fetch Custom provider models via Route API (not Server Action).
|
||||
const fetchCustomModels = useCallback(async (type: 'tags' | 'embeddings' | 'chat', url: string, apiKey?: string) => {
|
||||
if (!url) return
|
||||
|
||||
@@ -110,9 +110,10 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
else setIsLoadingCustomChatModels(true)
|
||||
|
||||
try {
|
||||
const result = type === 'embeddings'
|
||||
? await getCustomEmbeddingModels(url, apiKey)
|
||||
: await getCustomModels(url, apiKey)
|
||||
const params = new URLSearchParams({ type: 'custom', url, kind: type })
|
||||
if (apiKey) params.set('key', apiKey)
|
||||
const res = await fetch(`/api/admin/models?${params}`)
|
||||
const result = await res.json()
|
||||
|
||||
if (result.success && result.models.length > 0) {
|
||||
if (type === 'tags') setCustomTagsModels(result.models)
|
||||
@@ -131,47 +132,38 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Initial fetch for Ollama models if provider is selected
|
||||
// Single consolidated effect for initial model fetching.
|
||||
// Batching all provider checks into ONE effect prevents multiple Server Actions
|
||||
// from being dispatched simultaneously, which would cause React Error #310 by
|
||||
// flooding the App Router's action queue during an ongoing navigation transition.
|
||||
useEffect(() => {
|
||||
if (tagsProvider === 'ollama') {
|
||||
const url = config.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL || 'http://localhost:11434'
|
||||
fetchOllamaModels('tags', url)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tagsProvider])
|
||||
const fetchInitialModels = async () => {
|
||||
const ollamaBase = config.OLLAMA_BASE_URL || 'http://localhost:11434'
|
||||
const customUrl = config.CUSTOM_OPENAI_BASE_URL || ''
|
||||
const customKey = config.CUSTOM_OPENAI_API_KEY || ''
|
||||
|
||||
useEffect(() => {
|
||||
if (embeddingsProvider === 'ollama') {
|
||||
const url = config.OLLAMA_BASE_URL_EMBEDDING || config.OLLAMA_BASE_URL || 'http://localhost:11434'
|
||||
fetchOllamaModels('embeddings', url)
|
||||
} else if (embeddingsProvider === 'custom') {
|
||||
const url = config.CUSTOM_OPENAI_BASE_URL || ''
|
||||
const key = config.CUSTOM_OPENAI_API_KEY || ''
|
||||
if (url) fetchCustomModels('embeddings', url, key)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [embeddingsProvider])
|
||||
if (tagsProvider === 'ollama') {
|
||||
await fetchOllamaModels('tags', config.OLLAMA_BASE_URL_TAGS || ollamaBase)
|
||||
} else if (tagsProvider === 'custom' && customUrl) {
|
||||
await fetchCustomModels('tags', customUrl, customKey)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (tagsProvider === 'custom') {
|
||||
const url = config.CUSTOM_OPENAI_BASE_URL || ''
|
||||
const key = config.CUSTOM_OPENAI_API_KEY || ''
|
||||
if (url) fetchCustomModels('tags', url, key)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tagsProvider])
|
||||
if (embeddingsProvider === 'ollama') {
|
||||
await fetchOllamaModels('embeddings', config.OLLAMA_BASE_URL_EMBEDDING || ollamaBase)
|
||||
} else if (embeddingsProvider === 'custom' && customUrl) {
|
||||
await fetchCustomModels('embeddings', customUrl, customKey)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (chatProvider === 'ollama') {
|
||||
const url = config.OLLAMA_BASE_URL_CHAT || config.OLLAMA_BASE_URL || 'http://localhost:11434'
|
||||
fetchOllamaModels('chat', url)
|
||||
} else if (chatProvider === 'custom') {
|
||||
const url = config.CUSTOM_OPENAI_BASE_URL || ''
|
||||
const key = config.CUSTOM_OPENAI_API_KEY || ''
|
||||
if (url) fetchCustomModels('chat', url, key)
|
||||
if (chatProvider === 'ollama') {
|
||||
await fetchOllamaModels('chat', config.OLLAMA_BASE_URL_CHAT || ollamaBase)
|
||||
} else if (chatProvider === 'custom' && customUrl) {
|
||||
await fetchCustomModels('chat', customUrl, customKey)
|
||||
}
|
||||
}
|
||||
|
||||
fetchInitialModels()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chatProvider])
|
||||
}, [])
|
||||
|
||||
const handleSaveSecurity = async (formData: FormData) => {
|
||||
setIsSaving(true)
|
||||
@@ -876,13 +868,13 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between pt-6">
|
||||
<Button type="submit" disabled={isSaving}>{isSaving ? t('admin.ai.saving') : t('admin.ai.saveSettings')}</Button>
|
||||
<Link href="/admin/ai-test">
|
||||
<a href="/admin/ai-test">
|
||||
<Button type="button" variant="outline" className="gap-2">
|
||||
<TestTube className="h-4 w-4" />
|
||||
{t('admin.ai.openTestPanel')}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
</a>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
24
memento-note/app/(admin)/layout.tsx
Normal file
24
memento-note/app/(admin)/layout.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { AdminProvidersWrapper } from '@/components/admin-providers-wrapper'
|
||||
import { detectUserLanguage } from '@/lib/i18n/detect-user-language'
|
||||
import { loadTranslations } from '@/lib/i18n/load-translations'
|
||||
|
||||
// No <Suspense> here intentionally: combining a Suspense boundary with <Link>
|
||||
// components inside the subtree triggers a React production-only bug (React
|
||||
// issue #33580, Next.js issue #63388) causing Error #310 "too many re-renders".
|
||||
export default async function AdminGroupLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const initialLanguage = await detectUserLanguage()
|
||||
const initialTranslations = await loadTranslations(initialLanguage)
|
||||
|
||||
return (
|
||||
<AdminProvidersWrapper
|
||||
initialLanguage={initialLanguage}
|
||||
initialTranslations={initialTranslations}
|
||||
>
|
||||
{children}
|
||||
</AdminProvidersWrapper>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { AdminSidebar } from '@/components/admin-sidebar'
|
||||
import { AdminContentArea } from '@/components/admin-content-area'
|
||||
import { auth } from '@/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const session = await auth()
|
||||
|
||||
if ((session?.user as any)?.role !== 'ADMIN') {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full bg-gray-50 dark:bg-zinc-950">
|
||||
<AdminSidebar />
|
||||
<AdminContentArea>{children}</AdminContentArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
export default function AdminLoading() {
|
||||
return (
|
||||
<div className="space-y-6 animate-pulse">
|
||||
<div>
|
||||
<div className="h-9 w-48 bg-muted rounded-md mb-2" />
|
||||
<div className="h-4 w-72 bg-muted rounded-md" />
|
||||
</div>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="rounded-lg border border-border bg-white dark:bg-zinc-900 p-6 space-y-4">
|
||||
<div className="h-5 w-40 bg-muted rounded" />
|
||||
<div className="h-px bg-border" />
|
||||
<div className="space-y-3">
|
||||
<div className="h-4 w-full bg-muted rounded" />
|
||||
<div className="h-4 w-3/4 bg-muted rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Suspense } from "react";
|
||||
import { HeaderWrapper } from "@/components/header-wrapper";
|
||||
import { Sidebar } from "@/components/sidebar";
|
||||
import { ProvidersWrapper } from "@/components/providers-wrapper";
|
||||
@@ -28,7 +29,9 @@ export default async function MainLayout({
|
||||
{/* Main Layout */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Sidebar Navigation - Style Keep */}
|
||||
<Sidebar className="w-64 flex-none flex-col bg-white dark:bg-[#1e2128] border-e border-slate-200 dark:border-slate-800 overflow-y-auto hidden md:flex" user={session?.user} />
|
||||
<Suspense fallback={<div className="w-64 flex-none hidden md:flex" />}>
|
||||
<Sidebar className="w-64 flex-none flex-col bg-white dark:bg-[#1e2128] border-e border-slate-200 dark:border-slate-800 overflow-y-auto hidden md:flex" user={session?.user} />
|
||||
</Suspense>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto bg-background-light dark:bg-background-dark p-4 scroll-smooth">
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
export default function SettingsLoading() {
|
||||
return (
|
||||
<div className="space-y-6 animate-pulse">
|
||||
<div>
|
||||
<div className="h-9 w-64 bg-muted rounded-md mb-2" />
|
||||
<div className="h-4 w-96 bg-muted rounded-md" />
|
||||
</div>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="rounded-lg border border-border p-6 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 bg-muted rounded-full" />
|
||||
<div className="h-5 w-40 bg-muted rounded-md" />
|
||||
</div>
|
||||
<div className="h-px bg-border" />
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/30">
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-32 bg-muted rounded" />
|
||||
<div className="h-3 w-56 bg-muted rounded" />
|
||||
</div>
|
||||
<div className="h-6 w-11 bg-muted rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -41,11 +41,15 @@ export async function register(prevState: string | undefined, formData: FormData
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
const adminEmail = process.env.ADMIN_EMAIL?.toLowerCase();
|
||||
const role = adminEmail && email.toLowerCase() === adminEmail ? 'ADMIN' : 'USER';
|
||||
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email: email.toLowerCase(),
|
||||
password: hashedPassword,
|
||||
name,
|
||||
role,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
107
memento-note/app/api/admin/models/route.ts
Normal file
107
memento-note/app/api/admin/models/route.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id || (session.user as any).role !== 'ADMIN') return null
|
||||
return session
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/models?type=ollama&url=<base_url>
|
||||
* GET /api/admin/models?type=custom&url=<base_url>&key=<api_key>&kind=tags|embeddings
|
||||
*
|
||||
* Route API (not a Server Action) for fetching AI model lists from Ollama or
|
||||
* OpenAI-compatible providers. Using a Route Handler instead of a Server Action
|
||||
* is the correct architecture for client-side GET requests: Server Actions are
|
||||
* for data mutations, and calling them from useEffect pushes items into the
|
||||
* App Router's internal action queue, which is drained during render (inside
|
||||
* AppRouter's useMemo), triggering React Error #310 when multiple calls stack up.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!(await requireAdmin())) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = request.nextUrl
|
||||
const type = searchParams.get('type')
|
||||
const rawUrl = searchParams.get('url') ?? ''
|
||||
const apiKey = searchParams.get('key') ?? undefined
|
||||
const kind = searchParams.get('kind') ?? 'tags'
|
||||
|
||||
if (!rawUrl) {
|
||||
return NextResponse.json({ success: false, models: [], error: 'url parameter is required' })
|
||||
}
|
||||
|
||||
const baseUrl = rawUrl.replace(/\/$/, '').replace(/\/v1$/, '')
|
||||
|
||||
try {
|
||||
if (type === 'ollama') {
|
||||
const res = await fetch(`${baseUrl}/api/tags`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ success: false, models: [], error: `Ollama ${res.status}` })
|
||||
}
|
||||
const data = await res.json()
|
||||
const models: string[] = (data.models ?? []).map((m: { name: string }) => m.name)
|
||||
return NextResponse.json({ success: true, models })
|
||||
}
|
||||
|
||||
if (type === 'custom') {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
|
||||
|
||||
if (kind === 'embeddings') {
|
||||
// Try provider-specific embeddings endpoint first (e.g. OpenRouter)
|
||||
try {
|
||||
const embRes = await fetch(`${baseUrl}/v1/embeddings/models`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(8000),
|
||||
})
|
||||
if (embRes.ok) {
|
||||
const embData = await embRes.json()
|
||||
const embModels: string[] = (embData.data ?? [])
|
||||
.map((m: { id: string }) => m.id)
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
if (embModels.length > 0) {
|
||||
return NextResponse.json({ success: true, models: embModels })
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall through to /v1/models with keyword filter
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(`${baseUrl}/v1/models`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(8000),
|
||||
})
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ success: false, models: [], error: `Provider ${res.status}` })
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
let models: string[] = (data.data ?? [])
|
||||
.map((m: { id: string }) => m.id)
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
|
||||
if (kind === 'embeddings') {
|
||||
const keywords = ['embed', 'embedding', 'ada', 'e5', 'bge', 'gte', 'minilm']
|
||||
const filtered = models.filter((id) =>
|
||||
keywords.some((kw) => id.toLowerCase().includes(kw))
|
||||
)
|
||||
if (filtered.length > 0) models = filtered
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, models })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: false, models: [], error: `Unknown type: ${type}` })
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ success: false, models: [], error: err.message ?? 'Request failed' })
|
||||
}
|
||||
}
|
||||
12
memento-note/app/api/debug/client-error/route.ts
Normal file
12
memento-note/app/api/debug/client-error/route.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
// Visible directement dans `docker logs memento-web`
|
||||
console.error('[CLIENT-ERROR-REPORT]', JSON.stringify(body, null, 2))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { getAISettings } from "@/app/actions/ai-settings";
|
||||
import { getUserSettings } from "@/app/actions/user-settings";
|
||||
import { ThemeInitializer } from "@/components/theme-initializer";
|
||||
import { DirectionInitializer } from "@/components/direction-initializer";
|
||||
import { ErrorReporter } from "@/components/error-reporter";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
const inter = Inter({
|
||||
@@ -29,7 +30,7 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#f59e0b",
|
||||
themeColor: "#3A7CA5",
|
||||
};
|
||||
|
||||
function getHtmlClass(theme?: string): string {
|
||||
@@ -70,9 +71,12 @@ export default async function RootLayout({
|
||||
|
||||
return (
|
||||
<html suppressHydrationWarning className={getHtmlClass(userSettings.theme)}>
|
||||
<head />
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: `if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then(function(rs){rs.forEach(function(r){r.unregister()})})}` }} />
|
||||
</head>
|
||||
<body className={inter.className}>
|
||||
<SessionProviderWrapper>
|
||||
<ErrorReporter />
|
||||
<DirectionInitializer />
|
||||
<ThemeInitializer theme={userSettings.theme} fontSize={aiSettings.fontSize} />
|
||||
{children}
|
||||
|
||||
Reference in New Issue
Block a user