fix: brainstorm infinite loop, ghost cursor, embedding ::vector cast, semantic search, billing stats, usage meter accordion
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 5s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 5s
- Fix useBrainstormSocket: stable guestId via useRef, remove setState in cleanup - Fix GhostCursor: direct DOM manipulation via refs, no useState re-renders - Fix all SQL embedding queries: add ::vector cast on text columns - Fix embedding truncation to 15000 chars (under 8192 token limit) - Fix NoteEmbedding INSERT: remove non-existent updatedAt column - Fix billing page: show all quota stats in grid instead of single metric - Fix usage meter: accordion expand/collapse, per-feature detail - Fix semantic search: rebuild 103 note embeddings, ::vector cast on vectorSearch - Fix brainstorm expand/manual-idea/create: ::vector cast on embedding SQL
This commit is contained in:
@@ -1,21 +1,103 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { deleteUser, updateUserRole } from '@/app/actions/admin'
|
||||
import { deleteUser, updateUserRole, updateUserSubscription } from '@/app/actions/admin'
|
||||
import { toast } from 'sonner'
|
||||
import { Trash2, Shield, ShieldOff } from 'lucide-react'
|
||||
import { Trash2, Shield, ShieldOff, Crown, ChevronDown } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
const TIERS = ['BASIC', 'PRO', 'BUSINESS', 'ENTERPRISE'] as const
|
||||
|
||||
const tierColors: Record<string, string> = {
|
||||
BASIC: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
||||
PRO: 'bg-brand-accent/10 text-brand-accent',
|
||||
BUSINESS: 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
ENTERPRISE: 'bg-purple-50 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
|
||||
}
|
||||
|
||||
function TierDropdown({ userId, tier, isOpen, onToggle, onClose, onChange }: {
|
||||
userId: string
|
||||
tier: string
|
||||
isOpen: boolean
|
||||
onToggle: () => void
|
||||
onClose: () => void
|
||||
onChange: (tier: string) => void
|
||||
}) {
|
||||
const btnRef = useRef<HTMLButtonElement>(null)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
|
||||
const updatePos = useCallback(() => {
|
||||
if (btnRef.current) {
|
||||
const r = btnRef.current.getBoundingClientRect()
|
||||
setPos({ top: r.bottom + 4, left: r.left })
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
updatePos()
|
||||
window.addEventListener('scroll', updatePos, true)
|
||||
window.addEventListener('resize', updatePos)
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener('scroll', updatePos, true)
|
||||
window.removeEventListener('resize', updatePos)
|
||||
}
|
||||
}, [isOpen, updatePos])
|
||||
|
||||
return (
|
||||
<td className="p-4 align-middle">
|
||||
<button
|
||||
ref={btnRef}
|
||||
onClick={(e) => { e.stopPropagation(); onToggle() }}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors cursor-pointer hover:opacity-80 ${tierColors[tier] || tierColors.BASIC}`}
|
||||
>
|
||||
{tier === 'ENTERPRISE' && <Crown size={10} />}
|
||||
{tier}
|
||||
<ChevronDown size={10} />
|
||||
</button>
|
||||
{isOpen && typeof document !== 'undefined' && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9998]" onClick={onClose} />
|
||||
<div
|
||||
className="fixed z-[9999] bg-white dark:bg-zinc-900 border border-border rounded-xl shadow-xl py-1 min-w-[140px]"
|
||||
style={{ top: pos.top, left: pos.left }}
|
||||
>
|
||||
{TIERS.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={(e) => { e.stopPropagation(); onChange(t) }}
|
||||
className={`w-full text-left px-4 py-2 text-xs font-medium transition-colors hover:bg-muted flex items-center gap-2 ${tier === t ? 'text-brand-accent font-bold' : 'text-foreground'}`}
|
||||
>
|
||||
{tier === t && '✓'}
|
||||
<span className={`inline-flex items-center gap-1 ${tier === t ? '' : 'ml-4'}`}>
|
||||
{t === 'ENTERPRISE' && <Crown size={10} />}
|
||||
{t}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
</td>
|
||||
)
|
||||
}
|
||||
|
||||
export function UserList({ initialUsers }: { initialUsers: any[] }) {
|
||||
const { t } = useLanguage()
|
||||
const [users, setUsers] = useState(initialUsers)
|
||||
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm(t('admin.users.confirmDelete'))) return
|
||||
try {
|
||||
await deleteUser(id)
|
||||
toast.success(t('admin.users.deleteSuccess'))
|
||||
setUsers(prev => prev.filter(u => u.id !== id))
|
||||
} catch (e) {
|
||||
toast.error(t('admin.users.deleteFailed'))
|
||||
}
|
||||
@@ -26,11 +108,25 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
|
||||
try {
|
||||
await updateUserRole(user.id, newRole)
|
||||
toast.success(t('admin.users.roleUpdateSuccess', { role: newRole }))
|
||||
setUsers(prev => prev.map(u => u.id === user.id ? { ...u, role: newRole } : u))
|
||||
} catch (e) {
|
||||
toast.error(t('admin.users.roleUpdateFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleTierChange = async (userId: string, tier: string) => {
|
||||
setOpenDropdown(null)
|
||||
try {
|
||||
await updateUserSubscription(userId, tier)
|
||||
toast.success(t('admin.users.tierUpdateSuccess', { tier }))
|
||||
setUsers(prev => prev.map(u => u.id === userId ? { ...u, subscription: { tier, status: 'ACTIVE' } } : u))
|
||||
} catch (e) {
|
||||
toast.error(t('admin.users.tierUpdateFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const getUserTier = (user: any) => user.subscription?.tier || 'BASIC'
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-auto">
|
||||
<table className="w-full caption-bottom text-sm text-left">
|
||||
@@ -39,43 +135,55 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
|
||||
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.name')}</th>
|
||||
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.email')}</th>
|
||||
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.role')}</th>
|
||||
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.subscription') || 'Abonnement'}</th>
|
||||
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.createdAt')}</th>
|
||||
<th className="h-12 px-4 align-middle font-medium text-muted-foreground text-right">{t('admin.users.table.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="[&_tr:last-child]:border-0">
|
||||
{initialUsers.map((user) => (
|
||||
<tr key={user.id} className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
|
||||
<td className="p-4 align-middle font-medium">{user.name || t('common.notAvailable')}</td>
|
||||
<td className="p-4 align-middle">{user.email}</td>
|
||||
<td className="p-4 align-middle">
|
||||
<span className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 ${user.role === 'ADMIN' ? 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80' : 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80'}`}>
|
||||
{user.role === 'ADMIN' ? t('admin.users.roles.admin') : t('admin.users.roles.user')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4 align-middle">{format(new Date(user.createdAt), 'PP')}</td>
|
||||
<td className="p-4 align-middle text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRoleToggle(user)}
|
||||
title={user.role === 'ADMIN' ? t('admin.users.demote') : t('admin.users.promote')}
|
||||
>
|
||||
{user.role === 'ADMIN' ? <ShieldOff className="h-4 w-4" /> : <Shield className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(user.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{users.map((user) => {
|
||||
const tier = getUserTier(user)
|
||||
return (
|
||||
<tr key={user.id} className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
|
||||
<td className="p-4 align-middle font-medium">{user.name || t('common.notAvailable')}</td>
|
||||
<td className="p-4 align-middle">{user.email}</td>
|
||||
<td className="p-4 align-middle">
|
||||
<span className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 ${user.role === 'ADMIN' ? 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80' : 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80'}`}>
|
||||
{user.role === 'ADMIN' ? t('admin.users.roles.admin') : t('admin.users.roles.user')}
|
||||
</span>
|
||||
</td>
|
||||
<TierDropdown
|
||||
userId={user.id}
|
||||
tier={tier}
|
||||
isOpen={openDropdown === user.id}
|
||||
onToggle={() => setOpenDropdown(openDropdown === user.id ? null : user.id)}
|
||||
onClose={() => setOpenDropdown(null)}
|
||||
onChange={(t) => handleTierChange(user.id, t)}
|
||||
/>
|
||||
<td className="p-4 align-middle">{format(new Date(user.createdAt), 'PP')}</td>
|
||||
<td className="p-4 align-middle text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRoleToggle(user)}
|
||||
title={user.role === 'ADMIN' ? t('admin.users.demote') : t('admin.users.promote')}
|
||||
>
|
||||
{user.role === 'ADMIN' ? <ShieldOff className="h-4 w-4" /> : <Shield className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(user.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user