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>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from 'react'
|
||||
import { motion } from 'motion/react'
|
||||
import { PageEntry } from '@/components/page-entry'
|
||||
|
||||
import { Plus, Bot, Search, LifeBuoy } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
@@ -205,7 +205,6 @@ export function AgentsPageClient({
|
||||
const showDetail = selectedAgent !== null || isNewAgent
|
||||
|
||||
return (
|
||||
<PageEntry>
|
||||
<>
|
||||
{showDetail ? (
|
||||
<AgentDetailView
|
||||
@@ -334,6 +333,5 @@ export function AgentsPageClient({
|
||||
<AgentHelp onClose={() => setShowHelp(false)} />
|
||||
)}
|
||||
</>
|
||||
</PageEntry>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { detectUserLanguage, parseAcceptLanguage } from "@/lib/i18n/detect-user-
|
||||
import { loadTranslations } from "@/lib/i18n/load-translations";
|
||||
import { getAISettings } from "@/app/actions/ai-settings";
|
||||
import { AIChatLayoutBridge } from "@/components/ai-chat-layout-bridge";
|
||||
import { PageTransition } from "@/components/page-transition";
|
||||
|
||||
|
||||
export default async function MainLayout({
|
||||
children,
|
||||
@@ -38,9 +38,7 @@ export default async function MainLayout({
|
||||
</Suspense>
|
||||
|
||||
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto scroll-smooth bg-memento-paper dark:bg-background">
|
||||
<PageTransition>
|
||||
{children}
|
||||
</PageTransition>
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{showAIAssistant && <AIChatLayoutBridge />}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { toast } from 'sonner'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Globe, Bell } from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
import { PageEntry } from '@/components/page-entry'
|
||||
|
||||
|
||||
interface GeneralSettingsClientProps {
|
||||
initialSettings: {
|
||||
|
||||
16
memento-note/app/(main)/template.tsx
Normal file
16
memento-note/app/(main)/template.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
|
||||
export default function MainTemplate({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { PageEntry } from '@/components/page-entry'
|
||||
|
||||
import {
|
||||
Trash2,
|
||||
RotateCcw,
|
||||
@@ -138,7 +138,6 @@ export function TrashClient({
|
||||
}
|
||||
|
||||
return (
|
||||
<PageEntry>
|
||||
<div className="h-full flex flex-col bg-[#F9F8F6] dark:bg-[#1a1a1a]">
|
||||
<header className="px-12 pt-12 pb-8 flex flex-col gap-6 sticky top-0 bg-[#F9F8F6]/80 dark:bg-[#1a1a1a]/80 backdrop-blur-md z-30 border-b border-border/20">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -289,6 +288,5 @@ export function TrashClient({
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</PageEntry>
|
||||
)
|
||||
}
|
||||
|
||||
17
memento-note/app/(public)/layout.tsx
Normal file
17
memento-note/app/(public)/layout.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { headers } from 'next/headers'
|
||||
import { detectUserLanguage, parseAcceptLanguage } from '@/lib/i18n/detect-user-language'
|
||||
import { loadTranslations } from '@/lib/i18n/load-translations'
|
||||
import { PublicProviders } from '@/components/public-providers'
|
||||
|
||||
export default async function PublicLayout({ children }: { children: React.ReactNode }) {
|
||||
const headersList = await headers()
|
||||
const browserLang = parseAcceptLanguage(headersList.get('accept-language'))
|
||||
const initialLanguage = await detectUserLanguage(browserLang)
|
||||
const initialTranslations = await loadTranslations(initialLanguage)
|
||||
|
||||
return (
|
||||
<PublicProviders initialLanguage={initialLanguage} initialTranslations={initialTranslations}>
|
||||
{children}
|
||||
</PublicProviders>
|
||||
)
|
||||
}
|
||||
5
memento-note/app/(public)/page.tsx
Normal file
5
memento-note/app/(public)/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { LandingPage } from '@/components/landing-page'
|
||||
|
||||
export default function PublicHomePage() {
|
||||
return <LandingPage />
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
import { SubscriptionTier, SubscriptionStatus } from '@prisma/client'
|
||||
|
||||
// Schema pour la création d'utilisateur
|
||||
const CreateUserSchema = z.object({
|
||||
@@ -33,6 +34,13 @@ export async function getUsers() {
|
||||
email: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
subscription: {
|
||||
select: {
|
||||
tier: true,
|
||||
status: true,
|
||||
currentPeriodEnd: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return users
|
||||
@@ -118,3 +126,39 @@ export async function updateUserRole(userId: string, newRole: string) {
|
||||
throw new Error('Failed to update role')
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUserSubscription(userId: string, tier: string) {
|
||||
await checkAdmin()
|
||||
|
||||
const validTiers: string[] = ['BASIC', 'PRO', 'BUSINESS', 'ENTERPRISE']
|
||||
if (!validTiers.includes(tier)) {
|
||||
throw new Error('Invalid tier')
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date()
|
||||
const periodEnd = new Date(now)
|
||||
periodEnd.setFullYear(periodEnd.getFullYear() + 1)
|
||||
|
||||
await prisma.subscription.upsert({
|
||||
where: { userId },
|
||||
update: {
|
||||
tier: tier as SubscriptionTier,
|
||||
status: 'ACTIVE',
|
||||
currentPeriodStart: now,
|
||||
currentPeriodEnd: periodEnd,
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
tier: tier as SubscriptionTier,
|
||||
status: 'ACTIVE' as SubscriptionStatus,
|
||||
currentPeriodStart: now,
|
||||
currentPeriodEnd: periodEnd,
|
||||
},
|
||||
})
|
||||
revalidatePath('/admin')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
throw new Error('Failed to update subscription')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export async function authenticate(
|
||||
await signIn('credentials', {
|
||||
email: formData.get('email'),
|
||||
password: formData.get('password'),
|
||||
redirectTo: '/',
|
||||
redirectTo: '/home',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
|
||||
@@ -128,7 +128,7 @@ export async function respondToBrainstormShare(
|
||||
})
|
||||
}
|
||||
|
||||
revalidatePath('/')
|
||||
revalidatePath('/home')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
@@ -193,6 +193,6 @@ export async function removeBrainstormShare(sessionId: string) {
|
||||
data: { status: 'removed' },
|
||||
})
|
||||
|
||||
revalidatePath('/')
|
||||
revalidatePath('/home')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import DOMPurify from 'isomorphic-dompurify'
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getAIProvider } from '@/lib/ai/factory'
|
||||
import { getChatProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
@@ -58,18 +58,47 @@ export async function generateNoteIllustrationSvg(noteId: string): Promise<{ ok:
|
||||
}
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const provider = getAIProvider(config)
|
||||
const provider = getChatProvider(config)
|
||||
|
||||
const prompt = `Tu es un designer minimaliste. Produis UN SEUL document SVG valide pour une vignette de carte note.
|
||||
Contraintes strictes:
|
||||
- viewBox="0 0 224 168" (rapport 4:3), pas de width/height fixes en px sur la racine ou width="100%" height="100%"
|
||||
- Style architectural / papier, 2–4 formes géométriques ou lignes, palette sobre (noir/gris/une couleur douce), pas de texte lisible
|
||||
- AUCUN script, AUCUNE balise foreignObject, AUCUN lien externe, AUCUN attribut on*
|
||||
- Réponds UNIQUEMENT avec le fragment SVG (commence par <svg ...> et finit par </svg>), sans markdown ni commentaire.
|
||||
const prompt = `Create a small SVG thumbnail that VISUALLY REPRESENTS this note's topic.
|
||||
|
||||
Thème à suggérer visuellement (abstrait, pas littéral):
|
||||
Titre: ${plainTitle || '(sans titre)'}
|
||||
Extrait: ${plainBody.slice(0, 400)}`
|
||||
OUTPUT: Only raw SVG markup. No markdown, no code fences, no comments. Start with <svg and end with </svg>.
|
||||
|
||||
SPECIFICATIONS:
|
||||
- viewBox="0 0 224 168", NO fixed width/height attributes
|
||||
- Maximum 1000 bytes
|
||||
- Background: soft warm beige (#F5F0E8) or transparent
|
||||
- Color palette (pick 2-3): warm charcoal (#2C2C2C), slate gray (#6B7280), soft sage (#A8B5A0), muted ochre (#C4A882), dusty rose (#C9A9A6), teal (#5F9EA0), burgundy (#8B4513)
|
||||
- NO text, NO scripts, NO foreignObject, NO external links
|
||||
|
||||
CRITICAL: The illustration MUST be recognizably related to the topic.
|
||||
Think of it like an ICON or PICTOGRAM for the title. Not abstract random shapes.
|
||||
|
||||
TOPIC: "${plainTitle || 'untitled'}"
|
||||
|
||||
How to illustrate this topic (pick the BEST match):
|
||||
- If the topic is about CODE/DEV: Show angle brackets <>, curly braces {}, a terminal window shape, or circuit-like lines
|
||||
- If the topic is about MUSIC: Show sound waves, musical notes shapes, or speaker icon
|
||||
- If the topic is about FOOD/COOKING: Show a pot shape, utensils, or plate
|
||||
- If the topic is about TRAVEL: Show a path/road, mountain peaks, or compass
|
||||
- If the topic is about SCIENCE: Show atom orbits, flask/beaker, or molecule bonds
|
||||
- If the topic is about BUSINESS/FINANCE: Show ascending chart lines, coins, or briefcase
|
||||
- If the topic is about HEALTH: Show heart shape, pulse line, or leaf
|
||||
- If the topic is about EDUCATION: Show book shape, graduation cap, or pencil
|
||||
- If the topic is about NATURE: Show tree, mountain, water wave, or sun
|
||||
- If the topic is about DESIGN/ART: Show palette, brush stroke, or frame
|
||||
- If the topic is about PEOPLE/TEAM: Show overlapping circles, handshake, or connected nodes
|
||||
- If the topic is about ARCHITECTURE: Show building outline, blueprint grid, or columns
|
||||
- Otherwise: Extract the KEY CONCEPT from the title and draw its SIMPLEST iconic representation
|
||||
|
||||
TECHNICAL RULES:
|
||||
- Use simple shapes: <circle>, <rect>, <line>, <path>, <ellipse>, <polygon>, <g>
|
||||
- Keep it FLAT and MINIMAL — 2-4 elements max
|
||||
- Use opacity for depth (0.3-0.8)
|
||||
- The icon should be immediately recognizable even at small size
|
||||
|
||||
Additional context from the note:
|
||||
${plainBody.slice(0, 200)}`
|
||||
|
||||
const raw = await provider.generateText(prompt)
|
||||
const extracted = extractSvgSnippet(raw)
|
||||
@@ -90,7 +119,7 @@ Extrait: ${plainBody.slice(0, 400)}`
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath('/')
|
||||
revalidatePath('/home')
|
||||
return { ok: true }
|
||||
} catch (e) {
|
||||
console.error('[note-illustration]', e)
|
||||
|
||||
@@ -434,7 +434,7 @@ export async function restoreNoteVersion(noteId: string, historyEntryId: string)
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath('/')
|
||||
revalidatePath('/home')
|
||||
|
||||
return parseNote(restored)
|
||||
}
|
||||
@@ -603,7 +603,7 @@ export async function createNote(data: {
|
||||
|
||||
if (!data.skipRevalidation) {
|
||||
// Revalidate main page (handles both inbox and notebook views via query params)
|
||||
revalidatePath('/')
|
||||
revalidatePath('/home')
|
||||
}
|
||||
|
||||
// Fire-and-forget: run AI operations in background without blocking the response
|
||||
@@ -690,7 +690,7 @@ export async function createNote(data: {
|
||||
const merged = [...new Set([...existingNames, ...appliedLabels])]
|
||||
await syncNoteLabels(noteId, merged, notebookId ?? null, userId)
|
||||
if (!data.skipRevalidation) {
|
||||
revalidatePath('/')
|
||||
revalidatePath('/home')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -853,7 +853,7 @@ export async function updateNote(id: string, data: {
|
||||
|
||||
if (!options?.skipRevalidation) {
|
||||
try { revalidatePath(`/note/${id}`) } catch {}
|
||||
try { revalidatePath('/') } catch {}
|
||||
try { revalidatePath('/home') } catch {}
|
||||
}
|
||||
|
||||
if (isStructuralChange) {
|
||||
@@ -895,7 +895,7 @@ export async function deleteNote(id: string, options?: { skipRevalidation?: bool
|
||||
})
|
||||
|
||||
if (!options?.skipRevalidation) {
|
||||
revalidatePath('/')
|
||||
revalidatePath('/home')
|
||||
}
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
@@ -915,7 +915,7 @@ export async function trashNote(id: string, options?: { skipRevalidation?: boole
|
||||
data: { trashedAt: new Date() }
|
||||
})
|
||||
if (!options?.skipRevalidation) {
|
||||
revalidatePath('/')
|
||||
revalidatePath('/home')
|
||||
}
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
@@ -933,7 +933,7 @@ export async function restoreNote(id: string) {
|
||||
where: { id, userId: session.user.id },
|
||||
data: { trashedAt: null }
|
||||
})
|
||||
revalidatePath('/')
|
||||
revalidatePath('/home')
|
||||
revalidatePath('/trash')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
@@ -984,7 +984,7 @@ export async function permanentDeleteNote(id: string) {
|
||||
|
||||
await syncLabels(session.user.id, [])
|
||||
revalidatePath('/trash')
|
||||
revalidatePath('/')
|
||||
revalidatePath('/home')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Error permanently deleting note:', error)
|
||||
@@ -1028,7 +1028,7 @@ export async function emptyTrash() {
|
||||
|
||||
await syncLabels(session.user.id, [])
|
||||
revalidatePath('/trash')
|
||||
revalidatePath('/')
|
||||
revalidatePath('/home')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Error emptying trash:', error)
|
||||
@@ -1182,7 +1182,7 @@ export async function reorderNotes(draggedId: string, targetId: string) {
|
||||
prisma.note.update({ where: { id: note.id }, data: { order: index } })
|
||||
)
|
||||
await prisma.$transaction(updates)
|
||||
revalidatePath('/')
|
||||
revalidatePath('/home')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
throw new Error('Failed to reorder notes')
|
||||
@@ -1198,7 +1198,7 @@ export async function updateFullOrder(ids: string[]) {
|
||||
prisma.note.update({ where: { id, userId }, data: { order: index } })
|
||||
)
|
||||
await prisma.$transaction(updates)
|
||||
revalidatePath('/')
|
||||
revalidatePath('/home')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
throw new Error('Failed to update order')
|
||||
@@ -1314,7 +1314,7 @@ export async function cleanupAllOrphans() {
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath('/')
|
||||
revalidatePath('/home')
|
||||
revalidatePath('/settings')
|
||||
return {
|
||||
success: true,
|
||||
@@ -1487,7 +1487,7 @@ export async function dismissFromRecent(id: string) {
|
||||
data: { dismissedFromRecent: true }
|
||||
})
|
||||
|
||||
// revalidatePath('/') // Removed to prevent immediate refill of the list
|
||||
// revalidatePath('/home') // Removed to prevent immediate refill of the list
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Error dismissing note from recent:', error)
|
||||
@@ -1895,7 +1895,7 @@ export async function respondToShareRequest(shareId: string, action: 'accept' |
|
||||
});
|
||||
|
||||
// Revalidate all relevant cache tags
|
||||
revalidatePath('/');
|
||||
revalidatePath('/home');
|
||||
|
||||
return { success: true, share: updatedShare };
|
||||
} catch (error: any) {
|
||||
@@ -1957,7 +1957,7 @@ export async function removeSharedNoteFromView(shareId: string) {
|
||||
}
|
||||
});
|
||||
|
||||
revalidatePath('/');
|
||||
revalidatePath('/home');
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
console.error('Error removing shared note from view:', error);
|
||||
@@ -2018,7 +2018,7 @@ export async function leaveSharedNote(noteId: string) {
|
||||
}
|
||||
});
|
||||
|
||||
revalidatePath('/');
|
||||
revalidatePath('/home');
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
console.error('Error leaving shared note:', error);
|
||||
|
||||
@@ -271,7 +271,7 @@ export async function executeNotebookOrganization(plan: OrganizationPlan): Promi
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath('/')
|
||||
revalidatePath('/home')
|
||||
return { success: true, created, moved }
|
||||
} catch (err) {
|
||||
console.error('[organize-notebook] Execute error:', err)
|
||||
|
||||
@@ -96,7 +96,7 @@ export async function updateTheme(theme: string) {
|
||||
where: { id: session.user.id },
|
||||
data: { theme: normalized },
|
||||
})
|
||||
revalidatePath('/')
|
||||
revalidatePath('/home')
|
||||
revalidatePath('/settings/profile')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
@@ -123,7 +123,7 @@ export async function updateLanguage(language: string) {
|
||||
|
||||
// Note: The language will be applied on next page load
|
||||
// The client component should handle updating localStorage and reloading
|
||||
revalidatePath('/')
|
||||
revalidatePath('/home')
|
||||
revalidatePath('/settings/profile')
|
||||
return { success: true, language }
|
||||
} catch (error) {
|
||||
@@ -168,7 +168,7 @@ export async function updateFontSize(fontSize: string) {
|
||||
})
|
||||
}
|
||||
|
||||
revalidatePath('/')
|
||||
revalidatePath('/home')
|
||||
revalidatePath('/settings/profile')
|
||||
return { success: true, fontSize }
|
||||
} catch (error) {
|
||||
@@ -194,7 +194,7 @@ export async function updateShowRecentNotes(showRecentNotes: boolean) {
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath('/')
|
||||
revalidatePath('/home')
|
||||
revalidatePath('/settings/profile')
|
||||
return { success: true, showRecentNotes }
|
||||
} catch (error) {
|
||||
|
||||
@@ -99,7 +99,7 @@ export async function applyTitleSuggestion(
|
||||
console.error('[HISTORY] Failed to create snapshot after title suggestion:', snapshotError)
|
||||
}
|
||||
|
||||
revalidatePath('/')
|
||||
revalidatePath('/home')
|
||||
revalidatePath(`/note/${noteId}`)
|
||||
} catch (error) {
|
||||
console.error('Error applying title suggestion:', error)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { paragraphRefactorService } from '@/lib/ai/services/paragraph-refactor.service'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -52,9 +53,27 @@ export async function POST(request: NextRequest) {
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// Check quota
|
||||
try {
|
||||
await checkEntitlementOrThrow(session.user.id, 'reformulate')
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaExceededError) {
|
||||
const isTierLocked = err.currentQuota === 0
|
||||
return NextResponse.json({
|
||||
error: isTierLocked ? 'feature_locked' : 'quota_exceeded',
|
||||
errorKey: isTierLocked ? 'ai.featureLocked' : 'ai.quotaExceeded',
|
||||
upgradeTier: err.upgradeTier,
|
||||
quotaExceeded: true,
|
||||
}, { status: 402 })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
// Use the ParagraphRefactorService
|
||||
const result = await paragraphRefactorService.refactor(text, mode, format === 'html' ? 'html' : 'markdown', language)
|
||||
|
||||
incrementUsageAsync(session.user.id, 'reformulate')
|
||||
|
||||
return NextResponse.json({
|
||||
originalText: result.original,
|
||||
reformulatedText: result.refactored,
|
||||
|
||||
@@ -67,7 +67,7 @@ async function getParentContext(
|
||||
FROM "NoteEmbedding" e
|
||||
JOIN "Note" n ON n.id = e."noteId"
|
||||
WHERE n."userId" = $1 AND n."trashedAt" IS NULL AND n.id NOT IN (${excludeList})
|
||||
ORDER BY e.embedding <=> $2::vector
|
||||
ORDER BY e.embedding::vector <=> $2::vector
|
||||
LIMIT 5`,
|
||||
hostUserId, vectorStr
|
||||
) as any[]
|
||||
|
||||
@@ -131,7 +131,7 @@ export async function POST(
|
||||
FROM "NoteEmbedding" e
|
||||
JOIN "Note" n ON n.id = e."noteId"
|
||||
WHERE n.id IN (${idList}) AND n."trashedAt" IS NULL
|
||||
ORDER BY e.embedding <=> $1::vector
|
||||
ORDER BY e.embedding::vector <=> $1::vector
|
||||
LIMIT 3`,
|
||||
vectorStr
|
||||
) as any[]
|
||||
@@ -142,7 +142,7 @@ export async function POST(
|
||||
FROM "NoteEmbedding" e
|
||||
JOIN "Note" n ON n.id = e."noteId"
|
||||
WHERE n."userId" = $1 AND n."trashedAt" IS NULL
|
||||
ORDER BY e.embedding <=> $2::vector
|
||||
ORDER BY e.embedding::vector <=> $2::vector
|
||||
LIMIT 3`,
|
||||
aiUserId, vectorStr
|
||||
) as any[]
|
||||
|
||||
@@ -44,7 +44,7 @@ async function autoContextSearch(
|
||||
FROM "NoteEmbedding" e
|
||||
JOIN "Note" n ON n.id = e."noteId"
|
||||
WHERE n."userId" = $1 AND n."trashedAt" IS NULL
|
||||
ORDER BY e.embedding <=> $2::vector
|
||||
ORDER BY e.embedding::vector <=> $2::vector
|
||||
LIMIT 8`,
|
||||
userId, vectorStr
|
||||
) as any[]
|
||||
|
||||
@@ -146,7 +146,7 @@ export async function PUT(
|
||||
})
|
||||
|
||||
// Revalidate to refresh UI
|
||||
revalidatePath('/')
|
||||
revalidatePath('/home')
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
@@ -253,7 +253,7 @@ export async function DELETE(
|
||||
})
|
||||
|
||||
// Revalidate to refresh UI
|
||||
revalidatePath('/')
|
||||
revalidatePath('/home')
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -6,7 +6,7 @@ export async function GET() {
|
||||
name: "Memento Notes",
|
||||
short_name: "Memento",
|
||||
description: "A smart, local-first note taking app with AI capabilities.",
|
||||
start_url: "/",
|
||||
start_url: "/home",
|
||||
display: "standalone",
|
||||
background_color: "#F2F0E9",
|
||||
theme_color: "#1C1C1C",
|
||||
|
||||
@@ -98,7 +98,7 @@ export async function PATCH(
|
||||
})
|
||||
}
|
||||
|
||||
try { revalidatePath('/') } catch {}
|
||||
try { revalidatePath('/home') } catch {}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
@@ -143,7 +143,7 @@ export async function DELETE(
|
||||
|
||||
await prisma.notebook.delete({ where: { id } })
|
||||
|
||||
try { revalidatePath('/') } catch {}
|
||||
try { revalidatePath('/home') } catch {}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -47,7 +47,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
await prisma.$transaction(updates)
|
||||
|
||||
revalidatePath('/')
|
||||
revalidatePath('/home')
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -105,7 +105,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
})
|
||||
|
||||
try { revalidatePath('/') } catch {}
|
||||
try { revalidatePath('/home') } catch {}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -90,7 +90,7 @@ export async function POST(
|
||||
console.error('[HISTORY] Failed to create snapshot after notebook move:', snapshotError)
|
||||
}
|
||||
|
||||
// No revalidatePath('/') here — the client-side triggerRefresh() in
|
||||
// No revalidatePath('/home') here — the client-side triggerRefresh() in
|
||||
// notebooks-context.tsx handles the refresh. Avoiding server-side
|
||||
// revalidation prevents a double-refresh (server + client).
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ export async function POST(req: NextRequest) {
|
||||
})
|
||||
|
||||
// Revalidate paths
|
||||
revalidatePath('/')
|
||||
revalidatePath('/home')
|
||||
revalidatePath('/settings/data')
|
||||
|
||||
// Await cleanup in background (don't block response)
|
||||
|
||||
@@ -222,7 +222,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath('/')
|
||||
revalidatePath('/home')
|
||||
revalidatePath('/settings/data')
|
||||
|
||||
return NextResponse.json({ success: true, ...stats })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { getUserQuotas } from '@/lib/entitlements';
|
||||
import { getUserQuotas, getEffectiveTier } from '@/lib/entitlements';
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
@@ -10,8 +10,11 @@ export async function GET() {
|
||||
}
|
||||
|
||||
try {
|
||||
const quotas = await getUserQuotas(session.user.id);
|
||||
return NextResponse.json({ quotas });
|
||||
const [quotas, tier] = await Promise.all([
|
||||
getUserQuotas(session.user.id),
|
||||
getEffectiveTier(session.user.id),
|
||||
]);
|
||||
return NextResponse.json({ quotas, tier });
|
||||
} catch (error) {
|
||||
console.error('[usage/current] Failed to fetch quotas:', error);
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -8,6 +8,10 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if ((session.user as any)?.role !== 'ADMIN') {
|
||||
return NextResponse.json({ users: [] })
|
||||
}
|
||||
|
||||
const q = request.nextUrl.searchParams.get('q') || ''
|
||||
if (q.length < 2) {
|
||||
return NextResponse.json({ users: [] })
|
||||
|
||||
Reference in New Issue
Block a user