feat: design system overhaul — sidebar, AI chats, settings, brainstorm, color cleanup
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
- Sidebar: dynamic brand-accent colors, brainstorm section restyled - AI chat general: popup panel with expand/collapse, hides when contextual AI open - AI chat contextual: tabs reordered (Actions first), X close button, height fix - Settings: all tabs restyled, 6 new color presets (sage, terracotta, iron, etc.) - Global color cleanup: emerald/orange hardcoded → brand-accent dynamic - Brainstorm page: orange → brand-accent throughout - PageEntry animation component added to key pages - Floating AI button: bg-brand-accent instead of hardcoded black - i18n: all 15 locales updated with new AI/billing keys - Billing: freemium quota tracking, BYOK, stripe subscription scaffolding - Admin: integrated into new design - AGENTS.md + CLAUDE.md project rules added
This commit is contained in:
@@ -75,3 +75,28 @@ NEXTAUTH_URL="http://localhost:3000"
|
||||
# -----------------------------------------------------------------------------
|
||||
# MCP_SERVER_MODE="disabled"
|
||||
# MCP_SERVER_URL=""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# BYOK encryption (Story 3.5 — required in production when users save API keys)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Generate: openssl rand -base64 32 (minimum 32 characters)
|
||||
# MASTER_ENCRYPTION_KEY=""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stripe (Story 3.6 — subscription tiers)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Server-side secrets — NEVER expose on client
|
||||
# STRIPE_SECRET_KEY="sk_test_..."
|
||||
# STRIPE_WEBHOOK_SECRET="whsec_..."
|
||||
|
||||
# Price IDs from Stripe Dashboard (create products/prices there first)
|
||||
# STRIPE_PRICE_PRO_MONTHLY="price_..."
|
||||
# STRIPE_PRICE_PRO_ANNUAL="price_..."
|
||||
# STRIPE_PRICE_BUSINESS_MONTHLY="price_..."
|
||||
# STRIPE_PRICE_BUSINESS_ANNUAL="price_..."
|
||||
|
||||
# Client-side publishable key (safe to expose)
|
||||
# NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..."
|
||||
|
||||
# Feature flag — set to "true" to enable billing UI (default: false)
|
||||
# NEXT_PUBLIC_FEATURE_BILLING_ENABLED="false"
|
||||
|
||||
@@ -174,6 +174,10 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
const [selectedEmbeddingModel, setSelectedEmbeddingModel] = useState<string>(config.AI_MODEL_EMBEDDING || '')
|
||||
const [selectedChatModel, setSelectedChatModel] = useState<string>(config.AI_MODEL_CHAT || '')
|
||||
|
||||
const [tagsFallbackProvider, setTagsFallbackProvider] = useState<string>(config.AI_PROVIDER_TAGS_FALLBACK || '')
|
||||
const [embedFallbackProvider, setEmbedFallbackProvider] = useState<string>(config.AI_PROVIDER_EMBEDDING_FALLBACK || '')
|
||||
const [chatFallbackProvider, setChatFallbackProvider] = useState<string>(config.AI_PROVIDER_CHAT_FALLBACK || '')
|
||||
|
||||
// Dynamic Models State (for local providers with /api/tags or /v1/models endpoints)
|
||||
const [dynamicModels, setDynamicModels] = useState<Record<ModelPurpose, string[]>>({
|
||||
tags: [],
|
||||
@@ -309,6 +313,11 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
const tagsModel = formData.get('AI_MODEL_TAGS') as string
|
||||
if (tagsModel) data.AI_MODEL_TAGS = tagsModel
|
||||
|
||||
const tagsFallbackProv = formData.get('AI_PROVIDER_TAGS_FALLBACK') as string
|
||||
data.AI_PROVIDER_TAGS_FALLBACK = tagsFallbackProv?.trim() ?? ''
|
||||
const tagsFallbackModel = formData.get('AI_MODEL_TAGS_FALLBACK') as string
|
||||
if (tagsFallbackModel) data.AI_MODEL_TAGS_FALLBACK = tagsFallbackModel
|
||||
|
||||
// Save provider-specific config for tags
|
||||
if (tagsProv === 'ollama') {
|
||||
const ollamaUrl = formData.get('BASE_URL_ollama_tags') as string
|
||||
@@ -337,6 +346,11 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
const embedModel = formData.get('AI_MODEL_EMBEDDING') as string
|
||||
if (embedModel) data.AI_MODEL_EMBEDDING = embedModel
|
||||
|
||||
const embedFallbackProv = formData.get('AI_PROVIDER_EMBEDDING_FALLBACK') as string
|
||||
data.AI_PROVIDER_EMBEDDING_FALLBACK = embedFallbackProv?.trim() ?? ''
|
||||
const embedFallbackModel = formData.get('AI_MODEL_EMBEDDING_FALLBACK') as string
|
||||
if (embedFallbackModel) data.AI_MODEL_EMBEDDING_FALLBACK = embedFallbackModel
|
||||
|
||||
// Save provider-specific config for embeddings
|
||||
if (embedProv === 'ollama') {
|
||||
const ollamaUrl = formData.get('BASE_URL_ollama_embeddings') as string
|
||||
@@ -365,6 +379,11 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
const chatModel = formData.get('AI_MODEL_CHAT') as string
|
||||
if (chatModel) data.AI_MODEL_CHAT = chatModel
|
||||
|
||||
const chatFallbackProv = formData.get('AI_PROVIDER_CHAT_FALLBACK') as string
|
||||
data.AI_PROVIDER_CHAT_FALLBACK = chatFallbackProv?.trim() ?? ''
|
||||
const chatFallbackModel = formData.get('AI_MODEL_CHAT_FALLBACK') as string
|
||||
if (chatFallbackModel) data.AI_MODEL_CHAT_FALLBACK = chatFallbackModel
|
||||
|
||||
// Save provider-specific config for chat
|
||||
if (chatProv === 'ollama') {
|
||||
const ollamaUrl = formData.get('BASE_URL_ollama_chat') as string
|
||||
@@ -717,6 +736,33 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
|
||||
<input type="hidden" name="AI_MODEL_TAGS" value={selectedTagsModel} />
|
||||
{renderProviderConfig(tagsProvider, 'tags', selectedTagsModel, setSelectedTagsModel)}
|
||||
|
||||
<div className="space-y-2 pt-3 border-t border-border/40">
|
||||
<p className="text-xs font-medium text-muted-foreground">{t('admin.ai.fallbackSectionTitle')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.ai.fallbackSectionDescription')}</p>
|
||||
<Label htmlFor="AI_PROVIDER_TAGS_FALLBACK">{t('admin.ai.fallbackProvider')}</Label>
|
||||
<select
|
||||
id="AI_PROVIDER_TAGS_FALLBACK"
|
||||
name="AI_PROVIDER_TAGS_FALLBACK"
|
||||
value={tagsFallbackProvider}
|
||||
onChange={(e) => setTagsFallbackProvider(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">{t('admin.ai.fallbackNone')}</option>
|
||||
{providerOptions.map((opt) => (
|
||||
<option key={`fb-tags-${opt.value}`} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Label htmlFor="AI_MODEL_TAGS_FALLBACK">{t('admin.ai.fallbackModel')}</Label>
|
||||
<Input
|
||||
id="AI_MODEL_TAGS_FALLBACK"
|
||||
name="AI_MODEL_TAGS_FALLBACK"
|
||||
defaultValue={config.AI_MODEL_TAGS_FALLBACK || ''}
|
||||
placeholder={t('admin.ai.fallbackModelPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Embeddings Provider */}
|
||||
@@ -752,6 +798,33 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
|
||||
<input type="hidden" name="AI_MODEL_EMBEDDING" value={selectedEmbeddingModel} />
|
||||
{renderProviderConfig(embeddingsProvider, 'embeddings', selectedEmbeddingModel, setSelectedEmbeddingModel)}
|
||||
|
||||
<div className="space-y-2 pt-3 border-t border-border/40">
|
||||
<p className="text-xs font-medium text-muted-foreground">{t('admin.ai.fallbackSectionTitle')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.ai.fallbackSectionDescription')}</p>
|
||||
<Label htmlFor="AI_PROVIDER_EMBEDDING_FALLBACK">{t('admin.ai.fallbackProvider')}</Label>
|
||||
<select
|
||||
id="AI_PROVIDER_EMBEDDING_FALLBACK"
|
||||
name="AI_PROVIDER_EMBEDDING_FALLBACK"
|
||||
value={embedFallbackProvider}
|
||||
onChange={(e) => setEmbedFallbackProvider(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">{t('admin.ai.fallbackNone')}</option>
|
||||
{embeddingsProviderOptions.map((opt) => (
|
||||
<option key={`fb-embed-${opt.value}`} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Label htmlFor="AI_MODEL_EMBEDDING_FALLBACK">{t('admin.ai.fallbackModel')}</Label>
|
||||
<Input
|
||||
id="AI_MODEL_EMBEDDING_FALLBACK"
|
||||
name="AI_MODEL_EMBEDDING_FALLBACK"
|
||||
defaultValue={config.AI_MODEL_EMBEDDING_FALLBACK || ''}
|
||||
placeholder={t('admin.ai.fallbackModelPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat Provider */}
|
||||
@@ -782,6 +855,33 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
|
||||
<input type="hidden" name="AI_MODEL_CHAT" value={selectedChatModel} />
|
||||
{renderProviderConfig(chatProvider, 'chat', selectedChatModel, setSelectedChatModel)}
|
||||
|
||||
<div className="space-y-2 pt-3 border-t border-border/40">
|
||||
<p className="text-xs font-medium text-muted-foreground">{t('admin.ai.fallbackSectionTitle')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.ai.fallbackSectionDescription')}</p>
|
||||
<Label htmlFor="AI_PROVIDER_CHAT_FALLBACK">{t('admin.ai.fallbackProvider')}</Label>
|
||||
<select
|
||||
id="AI_PROVIDER_CHAT_FALLBACK"
|
||||
name="AI_PROVIDER_CHAT_FALLBACK"
|
||||
value={chatFallbackProvider}
|
||||
onChange={(e) => setChatFallbackProvider(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">{t('admin.ai.fallbackNone')}</option>
|
||||
{providerOptions.map((opt) => (
|
||||
<option key={`fb-chat-${opt.value}`} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Label htmlFor="AI_MODEL_CHAT_FALLBACK">{t('admin.ai.fallbackModel')}</Label>
|
||||
<Input
|
||||
id="AI_MODEL_CHAT_FALLBACK"
|
||||
name="AI_MODEL_CHAT_FALLBACK"
|
||||
defaultValue={config.AI_MODEL_CHAT_FALLBACK || ''}
|
||||
placeholder={t('admin.ai.fallbackModelPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 pb-6 flex flex-col sm:flex-row gap-3 sm:justify-between pt-6">
|
||||
|
||||
@@ -2,6 +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'
|
||||
@@ -204,6 +205,7 @@ export function AgentsPageClient({
|
||||
const showDetail = selectedAgent !== null || isNewAgent
|
||||
|
||||
return (
|
||||
<PageEntry>
|
||||
<>
|
||||
{showDetail ? (
|
||||
<AgentDetailView
|
||||
@@ -332,5 +334,6 @@ export function AgentsPageClient({
|
||||
<AgentHelp onClose={() => setShowHelp(false)} />
|
||||
)}
|
||||
</>
|
||||
</PageEntry>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +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,
|
||||
@@ -37,7 +38,9 @@ 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">
|
||||
{children}
|
||||
<PageTransition>
|
||||
{children}
|
||||
</PageTransition>
|
||||
</main>
|
||||
|
||||
{showAIAssistant && <AIChatLayoutBridge />}
|
||||
|
||||
@@ -6,8 +6,8 @@ export function AISettingsHeader() {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
|
||||
<h3 className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete opacity-60">
|
||||
{t('aiSettings.description')}
|
||||
</p>
|
||||
</h3>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { auth } from '@/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { AISettingsPanel } from '@/components/ai/ai-settings-panel'
|
||||
import { ByokSettingsPanel } from '@/components/ai/byok-settings-panel'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { AISettingsHeader } from './ai-settings-header'
|
||||
|
||||
@@ -17,6 +18,7 @@ export default async function AISettingsPage() {
|
||||
<div className="space-y-6">
|
||||
<AISettingsHeader />
|
||||
<AISettingsPanel initialSettings={settings} />
|
||||
<ByokSettingsPanel />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,28 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { updateAISettings } from '@/app/actions/ai-settings'
|
||||
import { updateUserSettings } from '@/app/actions/user-settings'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { toast } from 'sonner'
|
||||
import { Palette, Type } from 'lucide-react'
|
||||
import { Palette, Type, LayoutGrid, Maximize } from 'lucide-react'
|
||||
import { applyDocumentTheme, normalizeThemeId, type ThemeId } from '@/lib/apply-document-theme'
|
||||
import { motion } from 'motion/react'
|
||||
|
||||
const PRESET_COLORS = [
|
||||
{ name: 'Warm Earth', value: '#A47148' },
|
||||
{ name: 'Sage', value: '#4E594A' },
|
||||
{ name: 'Terracotta', value: '#B1523E' },
|
||||
{ name: 'Iron', value: '#4A5568' },
|
||||
{ name: 'Charcoal', value: '#1E293B' },
|
||||
{ name: 'Slate', value: '#475569' },
|
||||
{ name: 'Olive', value: '#7C8363' },
|
||||
{ name: 'Wine', value: '#722F37' },
|
||||
]
|
||||
|
||||
interface AppearanceSettingsClientProps {
|
||||
initialFontSize: string
|
||||
initialTheme: string
|
||||
initialFontFamily?: string
|
||||
initialAccentColor?: string
|
||||
}
|
||||
|
||||
export function AppearanceSettingsClient({
|
||||
initialFontSize,
|
||||
initialTheme,
|
||||
initialFontFamily = 'inter',
|
||||
initialAccentColor = '#A47148',
|
||||
}: AppearanceSettingsClientProps) {
|
||||
const { t } = useLanguage()
|
||||
const [theme, setTheme] = useState<ThemeId>(normalizeThemeId(initialTheme || 'light'))
|
||||
const [fontSize, setFontSize] = useState(initialFontSize || 'medium')
|
||||
const [fontFamily, setFontFamily] = useState(initialFontFamily)
|
||||
const [accentColor, setAccentColor] = useState(initialAccentColor)
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty('--color-brand-accent', accentColor)
|
||||
}, [accentColor])
|
||||
|
||||
const handleAccentColorChange = async (color: string) => {
|
||||
setAccentColor(color)
|
||||
document.documentElement.style.setProperty('--color-brand-accent', color)
|
||||
localStorage.setItem('accent-color', color)
|
||||
await updateUserSettings({ accentColor: color })
|
||||
}
|
||||
|
||||
const handleThemeChange = async (value: string) => {
|
||||
const next = normalizeThemeId(value)
|
||||
@@ -74,21 +100,21 @@ export function AppearanceSettingsClient({
|
||||
optionGroups?: { label: string; options: { value: string; label: string }[] }[]
|
||||
onChange: (v: string) => void
|
||||
}) => (
|
||||
<div className="bg-card rounded-lg border border-border p-6 shadow-sm flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 space-y-8 group transition-all duration-300 hover:shadow-xl hover:shadow-slate/5">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl text-slate border border-border group-hover:scale-110 transition-transform duration-300">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">{title}</h3>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
<div className="space-y-0.5 text-left">
|
||||
<h4 className="text-base font-bold text-foreground">{title}</h4>
|
||||
<p className="text-[11px] text-muted-foreground leading-tight">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mt-2">
|
||||
<div className="relative group/select">
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full h-11 px-4 bg-muted border border-border rounded-lg text-foreground text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none appearance-none cursor-pointer transition-colors"
|
||||
className="w-full bg-white/50 dark:bg-black/40 border border-border rounded-xl px-5 py-4 text-sm outline-none focus:ring-1 ring-slate/20 appearance-none cursor-pointer text-foreground font-bold transition-all hover:bg-white dark:hover:bg-black/60"
|
||||
>
|
||||
{optionGroups
|
||||
? optionGroups.map((g) => (
|
||||
@@ -106,9 +132,9 @@ export function AppearanceSettingsClient({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
<div className="absolute right-5 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground group-hover/select:text-foreground transition-colors">
|
||||
<svg width="10" height="6" viewBox="0 0 10 6" fill="none">
|
||||
<path d="M1 1L5 5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,52 +167,116 @@ export function AppearanceSettingsClient({
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Section label — architectural style */}
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
|
||||
{t('appearance.description') || "Personnalisez l'interface"}
|
||||
</p>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-16 pb-20"
|
||||
>
|
||||
<div className="space-y-10">
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
|
||||
{t('appearance.description') || "Personnalisez l'interface"}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<SelectCard
|
||||
icon={Palette}
|
||||
title={t('settings.theme')}
|
||||
description={t('appearance.selectTheme')}
|
||||
value={theme}
|
||||
optionGroups={themeOptionGroups}
|
||||
onChange={handleThemeChange}
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{/* Accent Color Section */}
|
||||
<div className="md:col-span-2 bg-white/40 dark:bg-white/5 border border-border rounded-3xl p-10 space-y-8 group transition-all duration-300 hover:shadow-xl hover:shadow-brand-accent/5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl border border-border group-hover:scale-110 transition-transform duration-300 shadow-sm" style={{ color: accentColor }}>
|
||||
<Palette size={20} />
|
||||
</div>
|
||||
<div className="space-y-0.5 text-left">
|
||||
<h4 className="text-base font-bold text-foreground">{t('appearance.accentColorTitle') || 'Couleur d\'accentuation'}</h4>
|
||||
<p className="text-[11px] text-muted-foreground leading-tight">{t('appearance.accentColorDescription') || 'Définissez la couleur principale de votre espace de travail'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 bg-slate-50 dark:bg-black/20 px-4 py-2 rounded-xl border border-border/40">
|
||||
<div className="w-4 h-4 rounded-full border border-border/60" style={{ backgroundColor: accentColor }} />
|
||||
<span className="text-xs font-mono font-medium text-muted-foreground uppercase tracking-widest">{accentColor}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SelectCard
|
||||
icon={Type}
|
||||
title={t('profile.fontSize')}
|
||||
description={t('profile.fontSizeDescription')}
|
||||
value={fontSize}
|
||||
options={[
|
||||
{ value: 'small', label: t('profile.fontSizeSmall') },
|
||||
{ value: 'medium', label: t('profile.fontSizeMedium') },
|
||||
{ value: 'large', label: t('profile.fontSizeLarge') },
|
||||
{ value: 'extra-large', label: t('profile.fontSizeExtraLarge') },
|
||||
]}
|
||||
onChange={handleFontSizeChange}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{PRESET_COLORS.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
onClick={() => handleAccentColorChange(color.value)}
|
||||
className={`relative w-12 h-12 rounded-2xl transition-all duration-300 hover:scale-110 flex items-center justify-center p-1 border-2 ${
|
||||
accentColor.toLowerCase() === color.value.toLowerCase()
|
||||
? 'border-foreground shadow-lg'
|
||||
: 'border-transparent hover:border-muted-foreground/20'
|
||||
}`}
|
||||
title={color.name}
|
||||
>
|
||||
<div
|
||||
className="w-full h-full rounded-xl shadow-inner"
|
||||
style={{ backgroundColor: color.value }}
|
||||
/>
|
||||
{accentColor.toLowerCase() === color.value.toLowerCase() && (
|
||||
<motion.div
|
||||
layoutId="color-check"
|
||||
className="absolute inset-0 flex items-center justify-center text-white mix-blend-difference"
|
||||
>
|
||||
<Palette size={14} />
|
||||
</motion.div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<SelectCard
|
||||
icon={Type}
|
||||
title={t('appearance.fontFamilyLabel') || 'Police'}
|
||||
description={t('appearance.fontFamilyDescription') || "Choisissez la police de l'application"}
|
||||
value={fontFamily}
|
||||
options={[
|
||||
{ value: 'inter', label: 'Inter (défaut)' },
|
||||
{ value: 'playfair', label: 'Playfair Display' },
|
||||
{ value: 'jetbrains', label: 'JetBrains Mono' },
|
||||
{ value: 'system', label: t('appearance.fontSystem') || 'Système' },
|
||||
]}
|
||||
onChange={handleFontFamilyChange}
|
||||
/>
|
||||
<div className="h-12 w-px bg-border/40 mx-2" />
|
||||
|
||||
<div className="relative group/custom">
|
||||
<input
|
||||
type="color"
|
||||
value={accentColor}
|
||||
onChange={(e) => handleAccentColorChange(e.target.value)}
|
||||
className="w-12 h-12 rounded-2xl cursor-pointer opacity-0 absolute inset-0 z-10"
|
||||
/>
|
||||
<div className="w-12 h-12 rounded-2xl border-2 border-dashed border-muted-foreground/30 flex items-center justify-center text-muted-foreground transition-all group-hover/custom:border-foreground group-hover/custom:text-foreground">
|
||||
<Maximize size={16} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SelectCard
|
||||
icon={Palette}
|
||||
title={t('settings.theme')}
|
||||
description={t('appearance.selectTheme')}
|
||||
value={theme}
|
||||
optionGroups={themeOptionGroups}
|
||||
onChange={handleThemeChange}
|
||||
/>
|
||||
|
||||
<SelectCard
|
||||
icon={Type}
|
||||
title={t('profile.fontSize')}
|
||||
description={t('profile.fontSizeDescription')}
|
||||
value={fontSize}
|
||||
options={[
|
||||
{ value: 'small', label: t('profile.fontSizeSmall') },
|
||||
{ value: 'medium', label: t('profile.fontSizeMedium') },
|
||||
{ value: 'large', label: t('profile.fontSizeLarge') },
|
||||
{ value: 'extra-large', label: t('profile.fontSizeExtraLarge') },
|
||||
]}
|
||||
onChange={handleFontSizeChange}
|
||||
/>
|
||||
|
||||
<SelectCard
|
||||
icon={Type}
|
||||
title={t('appearance.fontFamilyLabel') || 'Police'}
|
||||
description={t('appearance.fontFamilyDescription') || "Choisissez la police de l'application"}
|
||||
value={fontFamily}
|
||||
options={[
|
||||
{ value: 'inter', label: t('appearance.fontInterDefault') },
|
||||
{ value: 'playfair', label: t('appearance.fontPlayfairDisplay') },
|
||||
{ value: 'jetbrains', label: t('appearance.fontJetBrainsMono') },
|
||||
{ value: 'system', label: t('appearance.fontSystem') || 'Système' },
|
||||
]}
|
||||
onChange={handleFontFamilyChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export default async function AppearanceSettingsPage() {
|
||||
initialFontSize={aiSettings.fontSize}
|
||||
initialTheme={userSettings.theme}
|
||||
initialFontFamily={aiSettings.fontFamily || 'inter'}
|
||||
initialAccentColor={userSettings.accentColor || '#A47148'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
23
memento-note/app/(main)/settings/billing/page.tsx
Normal file
23
memento-note/app/(main)/settings/billing/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Suspense } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { BillingPlans } from '@/components/settings/billing-plans';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Billing',
|
||||
};
|
||||
|
||||
function Fallback() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BillingPage() {
|
||||
return (
|
||||
<Suspense fallback={<Fallback />}>
|
||||
<BillingPlans />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Download, Upload, Trash2, Loader2, RefreshCw, Sparkles, Database } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { motion } from 'motion/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function DataSettingsPage() {
|
||||
const { t } = useLanguage()
|
||||
@@ -117,114 +118,134 @@ export default function DataSettingsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const cards = [
|
||||
{
|
||||
icon: Download,
|
||||
iconColor: 'text-zinc-600 dark:text-zinc-400',
|
||||
iconBg: 'bg-zinc-500/10 dark:bg-zinc-500/20',
|
||||
title: t('dataManagement.export.title'),
|
||||
description: t('dataManagement.export.description'),
|
||||
loading: isExporting,
|
||||
loadingText: t('dataManagement.exporting'),
|
||||
buttonText: t('dataManagement.export.button'),
|
||||
onAction: handleExport,
|
||||
btnClass: 'bg-ink text-paper shadow-xl shadow-ink/20 hover:scale-[1.02] active:scale-95',
|
||||
},
|
||||
{
|
||||
icon: Upload,
|
||||
iconColor: 'text-emerald-600 dark:text-emerald-400',
|
||||
iconBg: 'bg-emerald-500/10 dark:bg-emerald-500/20',
|
||||
title: t('dataManagement.import.title'),
|
||||
description: t('dataManagement.import.description'),
|
||||
loading: isImporting,
|
||||
loadingText: t('dataManagement.importing'),
|
||||
buttonText: t('dataManagement.import.button'),
|
||||
onAction: () => document.getElementById('import-file')?.click(),
|
||||
btnClass: 'bg-white dark:bg-white/10 text-ink dark:text-paper border border-border hover:scale-[1.02] active:scale-95',
|
||||
fileInput: true,
|
||||
},
|
||||
{
|
||||
icon: Sparkles,
|
||||
iconColor: 'text-amber-600 dark:text-amber-400',
|
||||
iconBg: 'bg-amber-500/10 dark:bg-amber-500/20',
|
||||
title: t('dataManagement.indexing.title'),
|
||||
description: t('dataManagement.indexing.description'),
|
||||
loading: isReindexing,
|
||||
loadingText: t('dataManagement.exporting'),
|
||||
buttonText: t('dataManagement.indexing.button'),
|
||||
onAction: handleReindex,
|
||||
btnClass: 'bg-white dark:bg-white/10 text-ink dark:text-paper border border-border hover:scale-[1.02] active:scale-95',
|
||||
},
|
||||
{
|
||||
icon: Database,
|
||||
iconColor: 'text-purple-600 dark:text-purple-400',
|
||||
iconBg: 'bg-purple-500/10 dark:bg-purple-500/20',
|
||||
title: t('dataManagement.cleanup.title'),
|
||||
description: t('dataManagement.cleanup.description'),
|
||||
loading: isCleaningUp,
|
||||
loadingText: t('dataManagement.exporting'),
|
||||
buttonText: t('dataManagement.cleanup.button'),
|
||||
onAction: handleCleanup,
|
||||
btnClass: 'bg-white dark:bg-white/10 text-ink dark:text-paper border border-border hover:scale-[1.02] active:scale-95',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-12"
|
||||
>
|
||||
<h3 className="text-[10px] font-bold uppercase tracking-[0.3em] text-concrete">
|
||||
{t('dataManagement.toolsDescription')}
|
||||
</p>
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Export card */}
|
||||
<div className="bg-card rounded-xl border border-border p-6 shadow-sm flex flex-col justify-between transition-all hover:shadow-md">
|
||||
<div className="space-y-4">
|
||||
<div className="w-12 h-12 rounded-full bg-zinc-500/10 flex items-center justify-center text-zinc-600 shrink-0">
|
||||
<Download className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">{t('dataManagement.export.title')}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
|
||||
{t('dataManagement.export.description')}
|
||||
</p>
|
||||
{cards.map((card) => (
|
||||
<div
|
||||
key={card.title}
|
||||
className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 flex flex-col justify-between group hover:shadow-xl hover:shadow-black/5 transition-all duration-300"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className={cn('w-12 h-12 rounded-2xl flex items-center justify-center shrink-0 border border-border/50', card.iconBg, card.iconColor)}>
|
||||
<card.icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-ink">{card.title}</h4>
|
||||
<p className="text-[11px] text-concrete leading-relaxed mt-1.5">
|
||||
{card.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{card.fileInput && (
|
||||
<input type="file" accept=".json" onChange={handleImport} disabled={isImporting} className="hidden" id="import-file" />
|
||||
)}
|
||||
<button
|
||||
onClick={card.onAction}
|
||||
disabled={card.loading}
|
||||
className={cn(
|
||||
'mt-8 w-full py-3.5 rounded-2xl text-[10px] font-bold uppercase tracking-[0.2em] transition-all duration-300',
|
||||
card.btnClass,
|
||||
'disabled:opacity-60 disabled:pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{card.loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <card.icon className="h-4 w-4" />}
|
||||
{card.loading ? card.loadingText : card.buttonText}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<Button onClick={handleExport} disabled={isExporting} className="mt-6 w-full">
|
||||
{isExporting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Download className="h-4 w-4 mr-2" />}
|
||||
{isExporting ? t('dataManagement.exporting') : t('dataManagement.export.button')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Import card */}
|
||||
<div className="bg-card rounded-xl border border-border p-6 shadow-sm flex flex-col justify-between transition-all hover:shadow-md">
|
||||
<div className="space-y-4">
|
||||
<div className="w-12 h-12 rounded-full bg-emerald-500/10 flex items-center justify-center text-emerald-600 shrink-0">
|
||||
<Upload className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">{t('dataManagement.import.title')}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
|
||||
{t('dataManagement.import.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" accept=".json" onChange={handleImport} disabled={isImporting} className="hidden" id="import-file" />
|
||||
<Button onClick={() => document.getElementById('import-file')?.click()} disabled={isImporting} variant="outline" className="mt-6 w-full">
|
||||
{isImporting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Upload className="h-4 w-4 mr-2" />}
|
||||
{isImporting ? t('dataManagement.importing') : t('dataManagement.import.button')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Reindex card */}
|
||||
<div className="bg-card rounded-xl border border-border p-6 shadow-sm flex flex-col justify-between transition-all hover:shadow-md">
|
||||
<div className="space-y-4">
|
||||
<div className="w-12 h-12 rounded-full bg-amber-500/10 flex items-center justify-center text-amber-600 shrink-0">
|
||||
<Sparkles className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">{t('dataManagement.indexing.title')}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
|
||||
{t('dataManagement.indexing.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleReindex} disabled={isReindexing} variant="secondary" className="mt-6 w-full">
|
||||
{isReindexing ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <RefreshCw className="h-4 w-4 mr-2" />}
|
||||
{isReindexing ? t('dataManagement.exporting') : t('dataManagement.indexing.button')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Cleanup card */}
|
||||
<div className="bg-card rounded-xl border border-border p-6 shadow-sm flex flex-col justify-between transition-all hover:shadow-md">
|
||||
<div className="space-y-4">
|
||||
<div className="w-12 h-12 rounded-full bg-purple-500/10 flex items-center justify-center text-purple-600 shrink-0">
|
||||
<Database className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">{t('dataManagement.cleanup.title')}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
|
||||
{t('dataManagement.cleanup.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleCleanup} disabled={isCleaningUp} variant="secondary" className="mt-6 w-full">
|
||||
{isCleaningUp ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Database className="h-4 w-4 mr-2" />}
|
||||
{isCleaningUp ? t('dataManagement.exporting') : t('dataManagement.cleanup.button')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Danger zone */}
|
||||
<div className="bg-destructive/5 rounded-xl border border-destructive/20 p-6 shadow-sm mt-12">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center text-destructive shrink-0">
|
||||
<Trash2 className="h-6 w-6" />
|
||||
<div className="bg-rose-50/50 dark:bg-rose-500/5 rounded-2xl border border-rose-200/50 dark:border-rose-500/20 p-8 mt-12">
|
||||
<div className="flex items-center gap-5 mb-8">
|
||||
<div className="p-3 bg-rose-500/10 rounded-2xl text-rose-600 dark:text-rose-400 border border-rose-500/20">
|
||||
<Trash2 size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-destructive">{t('dataManagement.dangerZone')}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t('dataManagement.dangerZoneDescription')}</p>
|
||||
<h4 className="text-sm font-bold text-rose-600 dark:text-rose-400">{t('dataManagement.dangerZone')}</h4>
|
||||
<p className="text-[11px] text-concrete mt-0.5">{t('dataManagement.dangerZoneDescription')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between p-4 bg-background/50 rounded-lg border border-destructive/10 gap-4">
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between p-6 bg-white/60 dark:bg-black/20 rounded-2xl border border-rose-200/30 dark:border-rose-500/10 gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold text-destructive">{t('dataManagement.delete.title')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('dataManagement.delete.description')}</p>
|
||||
<p className="text-[13px] font-bold text-ink">{t('dataManagement.delete.title')}</p>
|
||||
<p className="text-[11px] text-concrete">{t('dataManagement.delete.description')}</p>
|
||||
</div>
|
||||
<Button variant="destructive" onClick={handleDeleteAll} disabled={isDeleting} className="shrink-0 w-full sm:w-auto">
|
||||
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Trash2 className="h-4 w-4 mr-2" />}
|
||||
{isDeleting ? t('dataManagement.deleting') : t('dataManagement.delete.button')}
|
||||
</Button>
|
||||
<button
|
||||
onClick={handleDeleteAll}
|
||||
disabled={isDeleting}
|
||||
className="shrink-0 px-6 py-3 rounded-2xl bg-rose-600 text-white text-[10px] font-bold uppercase tracking-[0.2em] shadow-xl shadow-rose-600/20 hover:scale-[1.02] active:scale-95 transition-all duration-300 disabled:opacity-60 disabled:pointer-events-none"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||
{isDeleting ? t('dataManagement.deleting') : t('dataManagement.delete.button')}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { updateAISettings } from '@/app/actions/ai-settings'
|
||||
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: {
|
||||
@@ -51,7 +53,7 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
||||
await updateAISettings({ desktopNotifications: enabled })
|
||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||
}
|
||||
|
||||
|
||||
const handleAutoSaveChange = async (enabled: boolean) => {
|
||||
setAutoSave(enabled)
|
||||
await updateAISettings({ autoSave: enabled })
|
||||
@@ -59,29 +61,32 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-12"
|
||||
>
|
||||
<h3 className="text-[10px] font-bold uppercase tracking-[0.3em] text-concrete">
|
||||
{t('generalSettings.description')}
|
||||
</p>
|
||||
</h3>
|
||||
|
||||
{/* 2-column card grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Language card */}
|
||||
<div className="bg-card rounded-lg border border-border p-6 shadow-sm flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||
<Globe className="h-5 w-5" />
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-xl p-8 space-y-6">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl text-concrete border border-border">
|
||||
<Globe size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">{t('settings.language')}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t('settings.selectLanguage')}</p>
|
||||
<div className="space-y-0.5">
|
||||
<h4 className="text-base font-bold text-ink">{t('settings.language')}</h4>
|
||||
<p className="text-[11px] text-concrete">{t('settings.selectLanguage')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mt-2">
|
||||
|
||||
<div className="relative group">
|
||||
<select
|
||||
value={language}
|
||||
onChange={(e) => handleLanguageChange(e.target.value)}
|
||||
className="w-full h-11 px-4 bg-muted border border-border rounded-lg text-foreground text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none appearance-none cursor-pointer transition-colors"
|
||||
className="w-full bg-white/50 dark:bg-black/40 border border-border rounded-xl px-5 py-3.5 text-sm outline-none focus:ring-1 ring-brand-accent/20 appearance-none cursor-pointer transition-all hover:bg-white dark:hover:bg-black/60 text-ink font-medium"
|
||||
>
|
||||
<option value="auto">{t('profile.autoDetect')}</option>
|
||||
<option value="en">English</option>
|
||||
@@ -100,75 +105,76 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
||||
<option value="nl">Nederlands</option>
|
||||
<option value="pl">Polski</option>
|
||||
</select>
|
||||
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
||||
<div className="absolute right-5 top-1/2 -translate-y-1/2 pointer-events-none opacity-40 text-concrete">
|
||||
<svg width="10" height="6" viewBox="0 0 10 6" fill="none">
|
||||
<path d="M1 1L5 5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notifications card */}
|
||||
<div className="bg-card rounded-lg border border-border p-6 shadow-sm flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||
<Bell className="h-5 w-5" />
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-xl p-8 space-y-6">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl text-concrete border border-border">
|
||||
<Bell size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">{t('settings.notifications')}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t('settings.notificationsDesc')}</p>
|
||||
<div className="space-y-0.5">
|
||||
<h4 className="text-base font-bold text-ink">{t('settings.notifications')}</h4>
|
||||
<p className="text-[11px] text-concrete">{t('settings.notificationsDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-5">
|
||||
{/* Email toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">{t('settings.emailNotifications')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.emailNotificationsDesc')}</p>
|
||||
|
||||
<div className="space-y-6 divide-y divide-border/40 text-left">
|
||||
<div className="flex items-center justify-between pt-0">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-bold text-ink">{t('settings.emailNotifications')}</p>
|
||||
<p className="text-[10px] text-concrete leading-relaxed">{t('settings.emailNotificationsDesc')}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={emailNotifications}
|
||||
onClick={() => handleEmailNotificationsChange(!emailNotifications)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary/30 ${emailNotifications ? 'bg-primary' : 'bg-muted-foreground/30'}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${emailNotifications ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={emailNotifications}
|
||||
onChange={(e) => handleEmailNotificationsChange(e.target.checked)}
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-ink" />
|
||||
</label>
|
||||
</div>
|
||||
{/* Desktop toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">{t('settings.desktopNotifications')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.desktopNotificationsDesc')}</p>
|
||||
|
||||
<div className="flex items-center justify-between pt-6">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-bold text-ink">{t('settings.desktopNotifications')}</p>
|
||||
<p className="text-[10px] text-concrete leading-relaxed">{t('settings.desktopNotificationsDesc')}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={desktopNotifications}
|
||||
onClick={() => handleDesktopNotificationsChange(!desktopNotifications)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary/30 ${desktopNotifications ? 'bg-primary' : 'bg-muted-foreground/30'}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${desktopNotifications ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={desktopNotifications}
|
||||
onChange={(e) => handleDesktopNotificationsChange(e.target.checked)}
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-ink" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-5 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">{t('settings.autoSave') || 'Auto-Save'}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.autoSaveDesc') || 'Sauvegarder automatiquement les modifications'}</p>
|
||||
|
||||
<div className="flex items-center justify-between pt-6">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-bold text-ink">{t('settings.autoSave') || 'Auto-Save'}</p>
|
||||
<p className="text-[10px] text-concrete leading-relaxed">{t('settings.autoSaveDesc') || 'Sauvegarder automatiquement les modifications'}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={autoSave}
|
||||
onClick={() => handleAutoSaveChange(!autoSave)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary/30 ${autoSave ? 'bg-primary' : 'bg-muted-foreground/30'}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${autoSave ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={autoSave}
|
||||
onChange={(e) => handleAutoSaveChange(e.target.checked)}
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-ink" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,24 +8,20 @@ export default function SettingsLayout({
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-[#F2F0E9]">
|
||||
{/* Architectural header — matches Agents page */}
|
||||
<header className="flex flex-col px-12 pt-10 pb-0 border-b border-border/40 shrink-0">
|
||||
<div className="flex items-end justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="font-memento-serif text-4xl font-medium tracking-tight text-foreground leading-tight">
|
||||
Paramètres
|
||||
</h1>
|
||||
<p className="text-[11px] text-muted-foreground uppercase tracking-[0.2em] font-bold mt-2">
|
||||
Configuration & Préférences
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col h-full bg-[#F2F0E9] dark:bg-dark-paper">
|
||||
<header className="px-12 pt-20 pb-16 space-y-12 shrink-0">
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-[64px] font-serif text-ink tracking-tight leading-none italic font-medium">
|
||||
Paramètres
|
||||
</h1>
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete opacity-60">
|
||||
Configuration & Préférences
|
||||
</p>
|
||||
</div>
|
||||
{/* Tab nav flush to the border-bottom of header */}
|
||||
<SettingsNav className="-mb-px" />
|
||||
|
||||
<SettingsNav />
|
||||
</header>
|
||||
|
||||
{/* Page Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-5xl mx-auto px-12 py-10 space-y-8">
|
||||
{children}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { auth } from '@/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { McpSettingsPanel } from '@/components/mcp/mcp-settings-panel'
|
||||
import { listMcpKeys, getMcpServerStatus } from '@/app/actions/mcp-keys'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export default async function McpSettingsPage() {
|
||||
const session = await auth()
|
||||
@@ -14,9 +15,9 @@ export default async function McpSettingsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
|
||||
<h3 className="text-[10px] font-bold uppercase tracking-[0.3em] text-concrete">
|
||||
Gérez vos clés API et serveurs MCP connectés.
|
||||
</p>
|
||||
</h3>
|
||||
<McpSettingsPanel initialKeys={keys} serverStatus={serverStatus} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,8 +2,6 @@ import { auth } from '@/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { ProfileForm } from './profile-form'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { ProfilePageHeader } from '@/components/profile-page-header'
|
||||
import { AISettingsLinkCard } from './ai-settings-link-card'
|
||||
|
||||
export default async function ProfilePage() {
|
||||
const session = await auth()
|
||||
@@ -12,33 +10,18 @@ export default async function ProfilePage() {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
// Parallel queries
|
||||
const [user, aiSettings] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { name: true, email: true, role: true }
|
||||
}),
|
||||
prisma.userAISettings.findUnique({
|
||||
where: { userId: session.user.id }
|
||||
})
|
||||
])
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { name: true, email: true, role: true, image: true }
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
const userAISettings = {
|
||||
preferredLanguage: aiSettings?.preferredLanguage || 'auto',
|
||||
showRecentNotes: aiSettings?.showRecentNotes ?? false
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<ProfilePageHeader />
|
||||
<ProfileForm user={user} userAISettings={userAISettings} />
|
||||
|
||||
{/* AI Settings Link */}
|
||||
<AISettingsLinkCard />
|
||||
<ProfileForm user={user} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,91 +1,157 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useState } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { updateProfile, changePassword } from '@/app/actions/profile'
|
||||
import { updateUserSettings } from '@/app/actions/user-settings'
|
||||
import { signOut } from 'next-auth/react'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { User, Lock } from 'lucide-react'
|
||||
import { User, Mail, Shield, LogOut, Camera, Bell } from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
|
||||
export function ProfileForm({ user, userAISettings }: { user: any; userAISettings?: any }) {
|
||||
export function ProfileForm({ user }: { user: { name: string | null; email: string; image: string | null } }) {
|
||||
const { t } = useLanguage()
|
||||
const [desktopNotif, setDesktopNotif] = useState(false)
|
||||
|
||||
const initial = (user.name || user.email || 'U')[0].toUpperCase()
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
|
||||
{t('profile.description')}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Profile info card */}
|
||||
<div className="bg-card rounded-lg border border-border p-6 shadow-sm flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||
<User className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">{t('profile.title')}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t('profile.description')}</p>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-12 max-w-2xl"
|
||||
>
|
||||
<div className="space-y-8">
|
||||
{/* Avatar + Name */}
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="relative group">
|
||||
<div className="w-24 h-24 rounded-[32px] bg-brand-accent/10 border-2 border-brand-accent/20 flex items-center justify-center overflow-hidden">
|
||||
{user.image ? (
|
||||
<Avatar className="size-24 rounded-[32px]">
|
||||
<AvatarImage src={user.image} alt="" />
|
||||
<AvatarFallback className="text-2xl font-serif font-bold text-brand-accent">{initial}</AvatarFallback>
|
||||
</Avatar>
|
||||
) : (
|
||||
<User size={40} className="text-brand-accent" />
|
||||
)}
|
||||
</div>
|
||||
<button className="absolute -bottom-2 -right-2 p-2 bg-ink text-white rounded-xl shadow-lg border border-border opacity-0 group-hover:opacity-100 transition-all hover:scale-110">
|
||||
<Camera size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-2xl font-serif font-bold text-ink">{user.name || 'Utilisateur'}</h3>
|
||||
<p className="text-sm text-concrete font-light">{user.email}</p>
|
||||
</div>
|
||||
<form action={async (formData) => {
|
||||
const result = await updateProfile({ name: formData.get('name') as string })
|
||||
if (result?.error) toast.error(t('profile.updateFailed'))
|
||||
else toast.success(t('profile.updateSuccess'))
|
||||
}} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="name" className="text-sm font-medium text-foreground">{t('profile.displayName')}</label>
|
||||
<Input id="name" name="name" defaultValue={user.name} className="bg-muted border-border focus:border-primary" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="email" className="text-sm font-medium text-foreground">{t('profile.email')}</label>
|
||||
<Input id="email" value={user.email} disabled className="bg-muted border-border opacity-60" />
|
||||
</div>
|
||||
<Button type="submit" className="w-full mt-2">{t('general.save')}</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Password card */}
|
||||
<div className="bg-card rounded-lg border border-border p-6 shadow-sm flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||
<Lock className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">{t('profile.changePassword')}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t('profile.changePasswordDescription')}</p>
|
||||
{/* Personal info */}
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-[10px] font-bold uppercase tracking-[0.2em] text-concrete opacity-60">
|
||||
{t('profile.description')}
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{/* Name edit */}
|
||||
<form action={async (formData) => {
|
||||
const result = await updateProfile({ name: formData.get('name') as string })
|
||||
if (result?.error) toast.error(t('profile.updateFailed'))
|
||||
else toast.success(t('profile.updateSuccess'))
|
||||
}} className="p-4 bg-white/40 dark:bg-white/5 border border-border rounded-2xl flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<User size={18} className="text-concrete" />
|
||||
<div>
|
||||
<p className="text-[10px] uppercase font-bold text-concrete tracking-widest">{t('profile.displayName')}</p>
|
||||
<Input name="name" defaultValue={user.name || ''} className="border-0 bg-transparent p-0 h-auto text-sm text-ink focus:ring-0 focus:outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" className="text-[10px] font-bold text-brand-accent uppercase tracking-widest hover:underline">
|
||||
{t('general.save')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Email */}
|
||||
<div className="p-4 bg-white/40 dark:bg-white/5 border border-border rounded-2xl flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Mail size={18} className="text-concrete" />
|
||||
<div>
|
||||
<p className="text-[10px] uppercase font-bold text-concrete tracking-widest">Email</p>
|
||||
<p className="text-sm text-ink">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security / Password */}
|
||||
<form action={async (formData) => {
|
||||
const result = await changePassword(formData)
|
||||
if (result?.error) {
|
||||
const msg = '_form' in result.error
|
||||
? result.error._form[0]
|
||||
: result.error.currentPassword?.[0] || result.error.newPassword?.[0] || result.error.confirmPassword?.[0] || t('profile.passwordChangeFailed')
|
||||
toast.error(msg)
|
||||
} else {
|
||||
toast.success(t('profile.passwordChangeSuccess'))
|
||||
}
|
||||
}} className="p-4 bg-white/40 dark:bg-white/5 border border-border rounded-2xl space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Shield size={18} className="text-concrete" />
|
||||
<div>
|
||||
<p className="text-[10px] uppercase font-bold text-concrete tracking-widest">{t('profile.changePassword')}</p>
|
||||
<p className="text-sm text-ink">{t('profile.changePasswordDescription')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 ms-10">
|
||||
<Input name="currentPassword" type="password" required placeholder={t('profile.currentPassword')} className="bg-white/50 dark:bg-black/20 border-border text-sm h-9" />
|
||||
<Input name="newPassword" type="password" required minLength={6} placeholder={t('profile.newPassword')} className="bg-white/50 dark:bg-black/20 border-border text-sm h-9" />
|
||||
<Input name="confirmPassword" type="password" required minLength={6} placeholder={t('profile.confirmPassword')} className="bg-white/50 dark:bg-black/20 border-border text-sm h-9" />
|
||||
</div>
|
||||
<div className="flex justify-end ms-10">
|
||||
<button type="submit" className="text-[10px] font-bold text-brand-accent uppercase tracking-widest hover:underline">
|
||||
{t('profile.updatePassword')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<form action={async (formData) => {
|
||||
const result = await changePassword(formData)
|
||||
if (result?.error) {
|
||||
const msg = '_form' in result.error
|
||||
? result.error._form[0]
|
||||
: result.error.currentPassword?.[0] || result.error.newPassword?.[0] || result.error.confirmPassword?.[0] || t('profile.passwordChangeFailed')
|
||||
toast.error(msg)
|
||||
} else {
|
||||
toast.success(t('profile.passwordChangeSuccess'))
|
||||
const form = document.querySelector('form#password-form') as HTMLFormElement
|
||||
form?.reset()
|
||||
}
|
||||
}} id="password-form" className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="currentPassword" className="text-sm font-medium text-foreground">{t('profile.currentPassword')}</label>
|
||||
<Input id="currentPassword" name="currentPassword" type="password" required className="bg-muted border-border focus:border-primary" />
|
||||
|
||||
{/* Preferences */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-[10px] font-bold uppercase tracking-[0.2em] text-concrete opacity-60">
|
||||
{t('profile.preferences') || 'Préférences de compte'}
|
||||
</h4>
|
||||
<div className="p-4 bg-white/40 dark:bg-white/5 border border-border rounded-2xl flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Bell size={18} className="text-concrete" />
|
||||
<div>
|
||||
<p className="text-sm text-ink">{t('profile.desktopNotifications') || 'Notification bureau'}</p>
|
||||
<p className="text-[10px] text-concrete font-light pe-4">{t('profile.desktopNotificationsDesc') || 'Recevez des alertes pour vos rappels et activités IA.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setDesktopNotif(!desktopNotif)}
|
||||
className={`w-10 h-5 rounded-full relative p-0.5 cursor-pointer transition-all duration-300 ${desktopNotif ? 'bg-brand-accent' : 'bg-gray-200 dark:bg-white/10'}`}
|
||||
>
|
||||
<div className={`w-3.5 h-3.5 bg-white rounded-full transition-all duration-300 ${desktopNotif ? 'translate-x-[18px]' : 'translate-x-0'}`} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="newPassword" className="text-sm font-medium text-foreground">{t('profile.newPassword')}</label>
|
||||
<Input id="newPassword" name="newPassword" type="password" required minLength={6} className="bg-muted border-border focus:border-primary" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="confirmPassword" className="text-sm font-medium text-foreground">{t('profile.confirmPassword')}</label>
|
||||
<Input id="confirmPassword" name="confirmPassword" type="password" required minLength={6} className="bg-muted border-border focus:border-primary" />
|
||||
</div>
|
||||
<Button type="submit" className="w-full mt-2">{t('profile.updatePassword')}</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
<div className="pt-8 border-t border-border/40">
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: '/login' })}
|
||||
className="flex items-center gap-3 px-6 py-3 bg-rose-50 dark:bg-rose-500/10 text-rose-600 rounded-xl font-bold uppercase tracking-widest text-[10px] hover:bg-rose-100 transition-colors"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
{t('sidebar.signOut') || 'Déconnexion'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +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,
|
||||
@@ -137,6 +138,7 @@ 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">
|
||||
@@ -206,12 +208,12 @@ export function TrashClient({
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(daysLeft / 30) * 100}%` }}
|
||||
className={`h-full ${daysLeft < 5 ? 'bg-rose-500' : 'bg-blueprint'}`}
|
||||
className={`h-full ${daysLeft < 5 ? 'bg-rose-500' : 'bg-terreo'}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className={`p-3 rounded-2xl ${item.itemType === 'note' ? 'bg-blueprint/10 text-blueprint' : 'bg-concrete/10 text-concrete'}`}>
|
||||
<div className={`p-3 rounded-2xl ${item.itemType === 'note' ? 'bg-terreo/10 text-terreo' : 'bg-concrete/10 text-concrete'}`}>
|
||||
{item.itemType === 'note' ? <FileText size={20} /> : <Folder size={20} />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -236,7 +238,7 @@ export function TrashClient({
|
||||
{item.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`text-[9px] font-bold uppercase tracking-widest px-2 py-0.5 rounded border ${daysLeft < 5 ? 'border-rose-200 text-rose-500 bg-rose-50' : 'border-blueprint/20 text-blueprint bg-blueprint/5'}`}>
|
||||
<div className={`text-[9px] font-bold uppercase tracking-widest px-2 py-0.5 rounded border ${daysLeft < 5 ? 'border-rose-200 text-rose-500 bg-rose-50' : 'border-terreo/20 text-terreo bg-terreo/5'}`}>
|
||||
{daysLeft} {t('trash.daysRemaining') || 'DAYS LEFT'}
|
||||
</div>
|
||||
{item.deletedAt && (
|
||||
@@ -287,5 +289,6 @@ export function TrashClient({
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</PageEntry>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use server'
|
||||
|
||||
import { semanticSearchService, SearchResult } from '@/lib/ai/services/semantic-search.service'
|
||||
import { auth } from '@/auth'
|
||||
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
|
||||
|
||||
export interface SemanticSearchResponse {
|
||||
results: SearchResult[]
|
||||
@@ -20,6 +22,17 @@ export async function semanticSearch(
|
||||
notebookId?: string // NEW: Filter by notebook for contextual search (IA5)
|
||||
}
|
||||
): Promise<SemanticSearchResponse> {
|
||||
const session = await auth();
|
||||
if (session?.user?.id) {
|
||||
try {
|
||||
await checkEntitlementOrThrow(session.user.id, 'semantic_search');
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaExceededError) throw err;
|
||||
console.error('[semantic-search] Quota check error (fail-open):', err);
|
||||
}
|
||||
incrementUsageAsync(session.user.id, 'semantic_search');
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await semanticSearchService.search(query, {
|
||||
limit: options?.limit || 20,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { normalizeThemeId, type ThemeId } from '@/lib/apply-document-theme'
|
||||
export type UserSettingsData = {
|
||||
theme?: ThemeId
|
||||
cardSizeMode?: 'variable' | 'uniform'
|
||||
accentColor?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,9 +24,10 @@ export async function updateUserSettings(settings: UserSettingsData) {
|
||||
}
|
||||
|
||||
try {
|
||||
const data: { theme?: string; cardSizeMode?: 'variable' | 'uniform' } = {}
|
||||
const data: { theme?: string; cardSizeMode?: 'variable' | 'uniform'; accentColor?: string } = {}
|
||||
if (settings.theme !== undefined) data.theme = normalizeThemeId(settings.theme)
|
||||
if (settings.cardSizeMode !== undefined) data.cardSizeMode = settings.cardSizeMode
|
||||
if (settings.accentColor !== undefined) data.accentColor = settings.accentColor
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
@@ -38,7 +40,7 @@ export async function updateUserSettings(settings: UserSettingsData) {
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Error updating user settings:', error)
|
||||
throw new Error('Failed to update user settings')
|
||||
throw new Error('Failed to update user settings: ' + (error instanceof Error ? error.message : String(error)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,12 +49,13 @@ const getCachedUserSettings = unstable_cache(
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { theme: true, cardSizeMode: true },
|
||||
select: { theme: true, cardSizeMode: true, accentColor: true },
|
||||
})
|
||||
|
||||
return {
|
||||
theme: normalizeThemeId(user?.theme || 'light'),
|
||||
cardSizeMode: (user?.cardSizeMode || 'variable') as 'variable' | 'uniform',
|
||||
accentColor: user?.accentColor || '#A47148',
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting user settings:', error)
|
||||
@@ -78,6 +81,7 @@ export async function getUserSettings(userId?: string) {
|
||||
return {
|
||||
theme: 'light' as const satisfies ThemeId,
|
||||
cardSizeMode: 'variable' as const,
|
||||
accentColor: '#A47148',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service';
|
||||
import { getTagsProvider } from '@/lib/ai/factory';
|
||||
import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-for-user';
|
||||
import { getSystemConfig } from '@/lib/config';
|
||||
import { z } from 'zod';
|
||||
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements';
|
||||
|
||||
import { getAISettings } from '@/app/actions/ai-settings';
|
||||
|
||||
@@ -25,6 +26,18 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ tags: [] });
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await getSystemConfig();
|
||||
const { usedByok: willUseByok } = await willUseByokForLane('tags', config, session.user.id);
|
||||
if (!willUseByok) {
|
||||
await checkEntitlementOrThrow(session.user.id, 'auto_tag');
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaExceededError) {
|
||||
return NextResponse.json(err.toJSON(), { status: 402 });
|
||||
}
|
||||
console.error('[/api/ai/tags] Quota check error (fail-open):', err);
|
||||
}
|
||||
const body = await req.json();
|
||||
const { content, notebookId, language } = requestSchema.parse(body);
|
||||
|
||||
@@ -58,8 +71,13 @@ export async function POST(req: NextRequest) {
|
||||
// Otherwise, use legacy auto-tagging (generates new tags)
|
||||
try {
|
||||
const config = await getSystemConfig();
|
||||
const provider = getTagsProvider(config);
|
||||
const tags = await provider.generateTags(content, language);
|
||||
const { result: tags, usedByok } = await runLaneWithBillingUser(
|
||||
'tags',
|
||||
config,
|
||||
session.user.id,
|
||||
(provider) => provider.generateTags(content, language),
|
||||
);
|
||||
if (!usedByok) incrementUsageAsync(session.user.id, 'auto_tag');
|
||||
return NextResponse.json({ tags });
|
||||
} catch (err) {
|
||||
console.error('[/api/ai/tags] legacy tagging failed:', err)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getTagsProvider } from '@/lib/ai/factory'
|
||||
import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-for-user'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { auth } from '@/auth'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
|
||||
import { z } from 'zod'
|
||||
|
||||
const requestSchema = z.object({
|
||||
@@ -13,14 +14,27 @@ export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Check authentication and user setting
|
||||
const session = await auth()
|
||||
if (session?.user?.id) {
|
||||
const settings = await getAISettings(session.user.id)
|
||||
if (settings.titleSuggestions === false) {
|
||||
return NextResponse.json({ suggestions: [] })
|
||||
}
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const settings = await getAISettings(session.user.id)
|
||||
if (settings.titleSuggestions === false) {
|
||||
return NextResponse.json({ suggestions: [] })
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const { usedByok: willUseByok } = await willUseByokForLane('tags', config, session.user.id);
|
||||
if (!willUseByok) {
|
||||
await checkEntitlementOrThrow(session.user.id, 'auto_title');
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaExceededError) {
|
||||
return NextResponse.json(err.toJSON(), { status: 402 });
|
||||
}
|
||||
console.error('[/api/ai/title-suggestions] Quota check error (fail-open):', err);
|
||||
}
|
||||
const body = await req.json()
|
||||
const { content } = requestSchema.parse(body)
|
||||
|
||||
@@ -35,7 +49,6 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const provider = getTagsProvider(config)
|
||||
|
||||
// Détecter la langue du contenu (simple détection basée sur les caractères et mots)
|
||||
const hasNonLatinChars = /[\u0400-\u04FF\u0600-\u06FF\u4E00-\u9FFF\u0E00-\u0E7F]/.test(content)
|
||||
@@ -95,7 +108,13 @@ CONTENT_START: ${content.substring(0, 500)} CONTENT_END
|
||||
|
||||
Réponds SEULEMENT avec un tableau JSON: [{"title": "titre1", "confidence": 0.95}, {"title": "titre2", "confidence": 0.85}, {"title": "titre3", "confidence": 0.75}]`
|
||||
|
||||
const titles = await provider.generateTitles(titlePrompt)
|
||||
const { result: titles, usedByok } = await runLaneWithBillingUser(
|
||||
'tags',
|
||||
config,
|
||||
session.user.id,
|
||||
(provider) => provider.generateTitles(titlePrompt),
|
||||
)
|
||||
if (!usedByok) incrementUsageAsync(session.user.id, 'auto_title')
|
||||
|
||||
// Créer les suggestions
|
||||
const suggestions = titles.map((t: any) => ({
|
||||
|
||||
68
memento-note/app/api/billing/create-checkout/route.ts
Normal file
68
memento-note/app/api/billing/create-checkout/route.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { resolvePriceId } from '@/lib/billing/stripe-prices';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { z } from 'zod';
|
||||
|
||||
const bodySchema = z.object({
|
||||
tier: z.enum(['PRO', 'BUSINESS']),
|
||||
interval: z.enum(['month', 'year']),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id || !session.user.email) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const parsed = bodySchema.safeParse(await req.json());
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { tier, interval } = parsed.data;
|
||||
const userId = session.user.id;
|
||||
const userEmail = session.user.email;
|
||||
|
||||
try {
|
||||
const priceId = resolvePriceId(tier, interval);
|
||||
|
||||
const subscription = await prisma.subscription.findUnique({ where: { userId } });
|
||||
let customerId = subscription?.stripeCustomerId ?? undefined;
|
||||
|
||||
if (!customerId) {
|
||||
const customer = await stripe.customers.create({
|
||||
email: userEmail,
|
||||
metadata: { userId },
|
||||
});
|
||||
customerId = customer.id;
|
||||
}
|
||||
|
||||
const origin = req.headers.get('origin') ?? process.env.NEXTAUTH_URL ?? 'http://localhost:3000';
|
||||
|
||||
const sessionParams = {
|
||||
customer: customerId,
|
||||
mode: 'subscription' as const,
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
ui_mode: 'embedded',
|
||||
return_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
|
||||
metadata: { userId, tier },
|
||||
subscription_data: { metadata: { userId, tier } },
|
||||
customer_update: { address: 'auto' },
|
||||
};
|
||||
const checkoutSession = await stripe.checkout.sessions.create(sessionParams as any);
|
||||
|
||||
if (checkoutSession.client_secret) {
|
||||
return NextResponse.json({
|
||||
clientSecret: checkoutSession.client_secret,
|
||||
sessionId: checkoutSession.id,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ url: checkoutSession.url });
|
||||
} catch (error) {
|
||||
console.error('[billing/create-checkout]', error);
|
||||
return NextResponse.json({ error: 'Failed to create checkout session' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
32
memento-note/app/api/billing/portal/route.ts
Normal file
32
memento-note/app/api/billing/portal/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
|
||||
try {
|
||||
const subscription = await prisma.subscription.findUnique({ where: { userId } });
|
||||
if (!subscription?.stripeCustomerId) {
|
||||
return NextResponse.json({ error: 'No active subscription found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const origin = req.headers.get('origin') ?? process.env.NEXTAUTH_URL ?? 'http://localhost:3000';
|
||||
|
||||
const portalSession = await stripe.billingPortal.sessions.create({
|
||||
customer: subscription.stripeCustomerId,
|
||||
return_url: `${origin}/settings/billing`,
|
||||
});
|
||||
|
||||
return NextResponse.json({ url: portalSession.url });
|
||||
} catch (error) {
|
||||
console.error('[billing/portal]', error);
|
||||
return NextResponse.json({ error: 'Failed to create portal session' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
32
memento-note/app/api/billing/status/route.ts
Normal file
32
memento-note/app/api/billing/status/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { getUserInfo, getEffectiveTier } from '@/lib/entitlements';
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
|
||||
try {
|
||||
const { tier, status, currentPeriodEnd } = await getUserInfo(userId);
|
||||
const effectiveTier = await getEffectiveTier(userId);
|
||||
|
||||
const { prisma } = await import('@/lib/prisma');
|
||||
const subscription = await prisma.subscription.findUnique({ where: { userId } });
|
||||
|
||||
return NextResponse.json({
|
||||
tier,
|
||||
effectiveTier,
|
||||
status,
|
||||
currentPeriodEnd: currentPeriodEnd ?? null,
|
||||
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd ?? false,
|
||||
hasStripeSubscription: !!subscription?.stripeSubscriptionId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[billing/status]', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch billing status' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
98
memento-note/app/api/billing/webhook/route.ts
Normal file
98
memento-note/app/api/billing/webhook/route.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { headers } from 'next/headers';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import {
|
||||
syncSubscriptionFromStripe,
|
||||
handleSubscriptionDeleted,
|
||||
resolveUserIdFromStripeEvent,
|
||||
} from '@/lib/billing/sync-subscription-from-stripe';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.text();
|
||||
const headersList = await headers();
|
||||
const sig = headersList.get('stripe-signature');
|
||||
|
||||
if (!sig) {
|
||||
return NextResponse.json({ error: 'Missing stripe-signature header' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!process.env.STRIPE_WEBHOOK_SECRET) {
|
||||
console.error('[billing/webhook] STRIPE_WEBHOOK_SECRET not configured');
|
||||
return NextResponse.json({ error: 'Webhook not configured' }, { status: 500 });
|
||||
}
|
||||
|
||||
let event: Stripe.Event;
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET);
|
||||
} catch (err) {
|
||||
console.error('[billing/webhook] Signature verification failed:', err);
|
||||
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed': {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
if (session.mode === 'subscription' && session.subscription) {
|
||||
const subscriptionId = typeof session.subscription === 'string'
|
||||
? session.subscription
|
||||
: session.subscription.id;
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
const userId = (session.metadata?.userId as string | undefined)
|
||||
?? (subscription.metadata?.userId as string | undefined);
|
||||
if (userId) {
|
||||
await syncSubscriptionFromStripe(subscription, userId);
|
||||
} else {
|
||||
console.warn('[billing/webhook] checkout.session.completed: no userId in metadata', session.id);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'customer.subscription.created':
|
||||
case 'customer.subscription.updated': {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
const userId = await resolveUserIdFromStripeEvent(subscription);
|
||||
if (userId) {
|
||||
await syncSubscriptionFromStripe(subscription, userId);
|
||||
} else {
|
||||
console.warn('[billing/webhook] subscription event: no userId found', subscription.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'customer.subscription.deleted': {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
await handleSubscriptionDeleted(subscription);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'invoice.payment_failed': {
|
||||
const invoice = event.data.object as Stripe.Invoice & { subscription?: string | { id: string } };
|
||||
if (invoice.subscription) {
|
||||
const subscriptionId = typeof invoice.subscription === 'string'
|
||||
? invoice.subscription
|
||||
: invoice.subscription.id;
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
const userId = await resolveUserIdFromStripeEvent(subscription);
|
||||
if (userId) {
|
||||
await syncSubscriptionFromStripe(subscription, userId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return NextResponse.json({ received: true });
|
||||
} catch (error) {
|
||||
console.error('[billing/webhook] Handler error:', error);
|
||||
return NextResponse.json({ error: 'Webhook handler failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,15 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { z } from 'zod'
|
||||
import { getTagsProvider } from '@/lib/ai/factory'
|
||||
import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-for-user'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { embeddingService } from '@/lib/ai/services/embedding.service'
|
||||
import {
|
||||
checkSessionEntitlementOrThrow,
|
||||
QuotaExceededError,
|
||||
} from '@/lib/entitlements'
|
||||
import {
|
||||
billingOwnerFromSession,
|
||||
verifyParticipant,
|
||||
resolveAiContextUserId,
|
||||
sanitizeNotesForGuest,
|
||||
@@ -182,7 +187,23 @@ export async function POST(
|
||||
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const parentIdea = brainstormSession.ideas.find(i => i.id === ideaId)
|
||||
const { billingOwnerId, isGuestActor } = billingOwnerFromSession(
|
||||
brainstormSession.userId,
|
||||
session.user.id,
|
||||
)
|
||||
// Story 3.5: per-provider BYOK bypass
|
||||
const earlyConfig = await getSystemConfig()
|
||||
const { usedByok: willUseByok } = await willUseByokForLane('tags', earlyConfig, billingOwnerId)
|
||||
if (!willUseByok) {
|
||||
await checkSessionEntitlementOrThrow(
|
||||
billingOwnerId,
|
||||
session.user.id,
|
||||
isGuestActor,
|
||||
'brainstorm_expand',
|
||||
)
|
||||
}
|
||||
|
||||
const parentIdea = (brainstormSession.ideas || []).find(i => i.id === ideaId)
|
||||
if (!parentIdea) {
|
||||
return NextResponse.json({ error: 'Idea not found' }, { status: 404 })
|
||||
}
|
||||
@@ -205,7 +226,6 @@ export async function POST(
|
||||
}
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const provider = getTagsProvider(config)
|
||||
|
||||
const prompt = buildExpandPromptV2(
|
||||
parentIdea.title,
|
||||
@@ -216,7 +236,12 @@ export async function POST(
|
||||
locale
|
||||
)
|
||||
|
||||
const llmResponse = await provider.generateText(prompt)
|
||||
const { result: llmResponse } = await runLaneWithBillingUser(
|
||||
'tags',
|
||||
config,
|
||||
billingOwnerId,
|
||||
(provider) => provider.generateText(prompt),
|
||||
)
|
||||
|
||||
let newIdeas: any[]
|
||||
try {
|
||||
@@ -311,6 +336,9 @@ export async function POST(
|
||||
|
||||
return NextResponse.json({ success: true, data: updatedSession })
|
||||
} catch (error: any) {
|
||||
if (error instanceof QuotaExceededError) {
|
||||
return NextResponse.json(error.toJSON(), { status: 402 })
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: error.issues }, { status: 400 })
|
||||
}
|
||||
|
||||
@@ -2,10 +2,20 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { z } from 'zod'
|
||||
import { verifyParticipant, logActivity, resolveAiContextUserId, captureSnapshot } from '@/lib/brainstorm-collab'
|
||||
import {
|
||||
billingOwnerFromSession,
|
||||
verifyParticipant,
|
||||
logActivity,
|
||||
resolveAiContextUserId,
|
||||
captureSnapshot,
|
||||
} from '@/lib/brainstorm-collab'
|
||||
import { embeddingService } from '@/lib/ai/services/embedding.service'
|
||||
import { getTagsProvider } from '@/lib/ai/factory'
|
||||
import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-for-user'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import {
|
||||
reserveUsageOrThrow,
|
||||
QuotaExceededError,
|
||||
} from '@/lib/entitlements'
|
||||
import { emitToSession } from '@/lib/socket-emit'
|
||||
|
||||
const manualSchema = z.object({
|
||||
@@ -43,6 +53,11 @@ export async function POST(
|
||||
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const { billingOwnerId, isGuestActor } = billingOwnerFromSession(
|
||||
brainstormSession.userId,
|
||||
session.user.id,
|
||||
)
|
||||
|
||||
let wave = 1
|
||||
let parentIdea: any = null
|
||||
if (parentIdeaId) {
|
||||
@@ -78,8 +93,25 @@ export async function POST(
|
||||
},
|
||||
})
|
||||
|
||||
// [UPDATE - SÉCURITÉ] Recherche vectorielle guest-safe
|
||||
// Story 3.5: per-provider BYOK bypass for enrich
|
||||
let enrichBlocked = false
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const { usedByok: willUseByok } = await willUseByokForLane('tags', config, billingOwnerId)
|
||||
if (!willUseByok) {
|
||||
await reserveUsageOrThrow(billingOwnerId, 'brainstorm_enrich')
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaExceededError) {
|
||||
enrichBlocked = true
|
||||
} else {
|
||||
console.error('[manual-idea] reserveUsage error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// [UPDATE - SÉCURITÉ] Recherche vectorielle guest-safe (skipped when host quota exhausted)
|
||||
let relatedNoteIds: string[] = []
|
||||
if (!enrichBlocked) {
|
||||
try {
|
||||
const { isGuest, publicNoteIds, aiUserId } = await resolveAiContextUserId(sessionId, session.user.id)
|
||||
|
||||
@@ -117,7 +149,10 @@ export async function POST(
|
||||
}
|
||||
relatedNoteIds = results.map((r: any) => r.id)
|
||||
}
|
||||
} catch {}
|
||||
} catch (vectorErr) {
|
||||
console.error('[manual-idea] vector context search failed:', vectorErr)
|
||||
}
|
||||
}
|
||||
|
||||
if (relatedNoteIds.length > 0) {
|
||||
const notes = await prisma.note.findMany({
|
||||
@@ -155,6 +190,16 @@ export async function POST(
|
||||
const requestingUserId = session.user!.id
|
||||
|
||||
const enrichAsync = async () => {
|
||||
if (enrichBlocked) {
|
||||
await emitToSession(sessionId, 'idea:ai_failed', {
|
||||
ideaId: idea.id,
|
||||
reason: 'quota_exceeded',
|
||||
isGuestActor,
|
||||
billingOwnerId,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Notifier le room que l'IA traite ce nœud (bordure pulsante violet)
|
||||
await emitToSession(sessionId, 'idea:ai_processing', {
|
||||
@@ -163,7 +208,6 @@ export async function POST(
|
||||
})
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const provider = getTagsProvider(config)
|
||||
const lang = locale === 'fr' ? 'French' : locale === 'es' ? 'Spanish' : locale === 'de' ? 'German' : locale === 'it' ? 'Italian' : locale === 'pt' ? 'Portuguese' : locale === 'ja' ? 'Japanese' : locale === 'ko' ? 'Korean' : locale === 'zh' ? 'Chinese' : locale === 'ar' ? 'Arabic' : "the user's language"
|
||||
|
||||
const enrichPrompt = `You are an idea enrichment assistant. Given a user's raw brainstorm idea and context, produce a JSON object with:
|
||||
@@ -181,7 +225,12 @@ User's raw description: "${description || 'none provided'}"
|
||||
|
||||
Respond ONLY with the JSON object, no markdown.`
|
||||
|
||||
const raw = await provider.generateText(enrichPrompt)
|
||||
const { result: raw } = await runLaneWithBillingUser(
|
||||
'tags',
|
||||
config,
|
||||
billingOwnerId,
|
||||
(provider) => provider.generateText(enrichPrompt),
|
||||
)
|
||||
const cleaned = raw.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim()
|
||||
const enriched = JSON.parse(cleaned)
|
||||
|
||||
@@ -195,7 +244,6 @@ Respond ONLY with the JSON object, no markdown.`
|
||||
data: { title: enrichedTitle, description: enrichedDescription, connectionToSeed, noveltyScore },
|
||||
})
|
||||
|
||||
// [UPDATE - TEMPS RÉEL] Notifier la complétion avec les données enrichies
|
||||
await emitToSession(sessionId, 'idea:ai_completed', {
|
||||
ideaId: idea.id,
|
||||
title: enrichedTitle,
|
||||
|
||||
@@ -2,9 +2,14 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { z } from 'zod'
|
||||
import { getTagsProvider } from '@/lib/ai/factory'
|
||||
import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-for-user'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { embeddingService } from '@/lib/ai/services/embedding.service'
|
||||
import {
|
||||
checkEntitlementOrThrow,
|
||||
QuotaExceededError,
|
||||
incrementUsageAsync,
|
||||
} from '@/lib/entitlements'
|
||||
import { logActivity, captureSnapshot } from '@/lib/brainstorm-collab'
|
||||
|
||||
const waveSchema = z.object({
|
||||
@@ -64,11 +69,7 @@ async function autoContextSearch(
|
||||
snippet: (n.content || '').slice(0, 300),
|
||||
}))
|
||||
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getTagsProvider(config)
|
||||
|
||||
const classifyPrompt = `Given the seed idea: "${seedIdea}"
|
||||
const classifyPrompt = `Given the seed idea: "${seedIdea}"
|
||||
|
||||
Classify each note as SUPPORT (confirms/reinforces the seed), TENSION (contradicts/questions the seed), or EXTENSION (extends the seed into an adjacent domain).
|
||||
|
||||
@@ -78,7 +79,14 @@ ${notesForLLM.map(n => `[${n.id}] "${n.title}": ${n.snippet}`).join('\n')}
|
||||
Respond ONLY with a valid JSON array of objects:
|
||||
{ "noteId": string, "category": "SUPPORT" | "TENSION" | "EXTENSION" }`
|
||||
|
||||
const raw = await provider.generateText(classifyPrompt)
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const { result: raw } = await runLaneWithBillingUser(
|
||||
'tags',
|
||||
config,
|
||||
userId,
|
||||
(provider) => provider.generateText(classifyPrompt),
|
||||
)
|
||||
const cleaned = raw.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim()
|
||||
const classifications: { noteId: string; category: 'SUPPORT' | 'TENSION' | 'EXTENSION' }[] = JSON.parse(cleaned)
|
||||
|
||||
@@ -199,13 +207,23 @@ export async function POST(request: NextRequest) {
|
||||
const body = await request.json()
|
||||
const { seedIdea, sourceNoteId, contextNoteIds, locale } = waveSchema.parse(body)
|
||||
|
||||
// Story 3.5: per-provider BYOK bypass
|
||||
const config = await getSystemConfig()
|
||||
const { usedByok: willUseByok } = await willUseByokForLane('tags', config, userId)
|
||||
if (!willUseByok) {
|
||||
await checkEntitlementOrThrow(userId, 'brainstorm_create')
|
||||
}
|
||||
|
||||
const classifiedNotes = await autoContextSearch(userId, seedIdea, contextNoteIds)
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const provider = getTagsProvider(config)
|
||||
|
||||
const prompt = buildPromptV2(seedIdea, classifiedNotes, locale)
|
||||
const llmResponse = await provider.generateText(prompt)
|
||||
const { result: llmResponse, usedByok } = await runLaneWithBillingUser(
|
||||
'tags',
|
||||
config,
|
||||
userId,
|
||||
(provider) => provider.generateText(prompt),
|
||||
)
|
||||
if (!usedByok) incrementUsageAsync(userId, 'brainstorm_create')
|
||||
|
||||
let ideas: any[]
|
||||
try {
|
||||
@@ -312,6 +330,9 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
}, { status: 201 })
|
||||
} catch (error: any) {
|
||||
if (error instanceof QuotaExceededError) {
|
||||
return NextResponse.json(error.toJSON(), { status: 402 })
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: error.issues }, { status: 400 })
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { streamText, UIMessage, stepCountIs } from 'ai'
|
||||
import { getChatProvider } from '@/lib/ai/factory'
|
||||
import { resolveAiRouteWithTiming, formatAiRouteDebug } from '@/lib/ai/router'
|
||||
import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-for-user'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { semanticSearchService } from '@/lib/ai/services/semantic-search.service'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
|
||||
import { toolRegistry } from '@/lib/ai/tools'
|
||||
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
|
||||
import { trackFeatureUsage } from '@/lib/usage-tracker'
|
||||
import { readFile } from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
@@ -46,6 +49,20 @@ export async function POST(req: Request) {
|
||||
}
|
||||
const userId = session.user.id
|
||||
|
||||
// 1.5 Quota check (per-provider BYOK bypass — only when BYOK will be used for resolved provider)
|
||||
try {
|
||||
const sysConfigEarly = await getSystemConfig()
|
||||
const { usedByok: willUseByok } = await willUseByokForLane('chat', sysConfigEarly, userId)
|
||||
if (!willUseByok) {
|
||||
await checkEntitlementOrThrow(userId, 'chat')
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaExceededError) {
|
||||
return Response.json(err.toJSON(), { status: 402 })
|
||||
}
|
||||
console.error('[chat] Quota check error (fail-open):', err)
|
||||
}
|
||||
|
||||
// 2. Parse request body
|
||||
const body = await req.json()
|
||||
const { messages: rawMessages, conversationId, notebookId, language, webSearch, noteContext, format, noteId } = body as {
|
||||
@@ -323,27 +340,43 @@ Focus ONLY on this note unless asked otherwise.`
|
||||
|
||||
// 6. Execute stream
|
||||
const sysConfig = await getSystemConfig()
|
||||
|
||||
const routeDebug =
|
||||
process.env.NODE_ENV !== 'production' || process.env.MEMENTO_AI_ROUTE_DEBUG === '1'
|
||||
if (routeDebug) {
|
||||
console.debug('[ai-route]', formatAiRouteDebug(resolveAiRouteWithTiming('chat', sysConfig)))
|
||||
}
|
||||
|
||||
const chatTools = noteContext
|
||||
? toolRegistry.buildToolsForChat({ userId, config: sysConfig, webSearch, notebookId: notebookId || undefined })
|
||||
: toolRegistry.buildToolsForChat({ userId, config: sysConfig, webSearch, notebookId: notebookId || undefined })
|
||||
|
||||
const provider = getChatProvider(sysConfig)
|
||||
const result = await streamText({
|
||||
model: provider.getModel(),
|
||||
system: systemPrompt,
|
||||
messages: incomingMessages,
|
||||
tools: chatTools,
|
||||
stopWhen: stepCountIs(5),
|
||||
onFinish: async (final) => {
|
||||
const userContent = incomingMessages[incomingMessages.length - 1].content
|
||||
await prisma.chatMessage.create({
|
||||
data: { conversationId: conversation.id, role: 'user', content: userContent }
|
||||
})
|
||||
await prisma.chatMessage.create({
|
||||
data: { conversationId: conversation.id, role: 'assistant', content: final.text }
|
||||
})
|
||||
}
|
||||
})
|
||||
const { result, usedByok } = await runLaneWithBillingUser(
|
||||
'chat',
|
||||
sysConfig,
|
||||
userId,
|
||||
async (provider) =>
|
||||
streamText({
|
||||
model: provider.getModel(),
|
||||
system: systemPrompt,
|
||||
messages: incomingMessages,
|
||||
tools: chatTools,
|
||||
stopWhen: stepCountIs(5),
|
||||
onFinish: async (final) => {
|
||||
const userContent = incomingMessages[incomingMessages.length - 1].content
|
||||
await prisma.chatMessage.create({
|
||||
data: { conversationId: conversation.id, role: 'user', content: userContent },
|
||||
})
|
||||
await prisma.chatMessage.create({
|
||||
data: { conversationId: conversation.id, role: 'assistant', content: final.text },
|
||||
})
|
||||
if (!usedByok) {
|
||||
trackFeatureUsage(userId, 'chat', final.usage?.totalTokens ?? 0)
|
||||
incrementUsageAsync(userId, 'chat')
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
return result.toUIMessageStreamResponse()
|
||||
}
|
||||
|
||||
122
memento-note/app/api/cron/sync-usage/route.ts
Normal file
122
memento-note/app/api/cron/sync-usage/route.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { redis } from '@/lib/redis';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { parseRedisInt, getCurrentPeriodKey } from '@/lib/quota-utils';
|
||||
|
||||
function verifyCronAuth(req: NextRequest): boolean {
|
||||
const cronSecret = process.env.CRON_SECRET;
|
||||
if (!cronSecret) {
|
||||
console.error('[sync-usage] CRON_SECRET env var is required but not set');
|
||||
return false;
|
||||
}
|
||||
const authHeader = req.headers.get('authorization');
|
||||
return authHeader === `Bearer ${cronSecret}`;
|
||||
}
|
||||
|
||||
async function scanKeys(pattern: string): Promise<string[]> {
|
||||
const keys: string[] = [];
|
||||
let cursor = '0';
|
||||
do {
|
||||
const [nextCursor, batch] = await redis.scan(
|
||||
cursor,
|
||||
'MATCH',
|
||||
pattern,
|
||||
'COUNT',
|
||||
200,
|
||||
);
|
||||
cursor = nextCursor;
|
||||
keys.push(...batch);
|
||||
} while (cursor !== '0');
|
||||
return keys;
|
||||
}
|
||||
|
||||
function parseUsageKey(key: string, period: string): { userId: string; feature: string } | null {
|
||||
const suffix = `:${period}`;
|
||||
if (!key.endsWith(suffix)) return null;
|
||||
if (key.endsWith(':tokens')) return null;
|
||||
|
||||
const prefix = key.slice(0, key.length - suffix.length);
|
||||
const firstColon = prefix.indexOf(':');
|
||||
if (firstColon === -1) return null;
|
||||
|
||||
const afterPrefix = prefix.slice(firstColon + 1);
|
||||
const lastColon = afterPrefix.lastIndexOf(':');
|
||||
if (lastColon === -1) return null;
|
||||
|
||||
const userId = afterPrefix.slice(0, lastColon);
|
||||
const feature = afterPrefix.slice(lastColon + 1);
|
||||
|
||||
if (!userId || !feature) return null;
|
||||
return { userId, feature };
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!verifyCronAuth(req)) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const period = getCurrentPeriodKey();
|
||||
const pattern = `usage:*:${period}`;
|
||||
const keys = await scanKeys(pattern);
|
||||
|
||||
let synced = 0;
|
||||
let errors = 0;
|
||||
|
||||
for (const key of keys) {
|
||||
if (key.endsWith(':tokens')) continue;
|
||||
|
||||
try {
|
||||
const parsed = parseUsageKey(key, period);
|
||||
if (!parsed) continue;
|
||||
|
||||
const { userId, feature } = parsed;
|
||||
|
||||
const [counter, tokens] = await Promise.all([
|
||||
redis.get(key),
|
||||
redis.get(`${key}:tokens`),
|
||||
]);
|
||||
|
||||
const periodStart = new Date(`${period}-01`);
|
||||
const periodEnd = new Date(
|
||||
periodStart.getFullYear(),
|
||||
periodStart.getMonth() + 1,
|
||||
1,
|
||||
);
|
||||
|
||||
await prisma.usageLog.upsert({
|
||||
where: {
|
||||
userId_feature_periodStart: {
|
||||
userId,
|
||||
feature,
|
||||
periodStart,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
feature,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
requestsCount: parseRedisInt(counter),
|
||||
tokensUsed: parseRedisInt(tokens),
|
||||
},
|
||||
update: {
|
||||
requestsCount: parseRedisInt(counter),
|
||||
tokensUsed: parseRedisInt(tokens),
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
synced++;
|
||||
} catch (err) {
|
||||
errors++;
|
||||
console.error(`[sync-usage] Failed to sync key ${key}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ synced, errors, total: keys.length });
|
||||
} catch (error) {
|
||||
console.error('[sync-usage] Error:', error);
|
||||
return NextResponse.json({ error: 'Sync failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export async function PATCH(
|
||||
try {
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
const { name, icon, color, order, trashedAt } = body
|
||||
const { name, icon, color, order, trashedAt, parentId } = body
|
||||
|
||||
const existing = await prisma.notebook.findUnique({
|
||||
where: { id },
|
||||
@@ -49,12 +49,40 @@ export async function PATCH(
|
||||
)
|
||||
}
|
||||
|
||||
if (parentId !== undefined) {
|
||||
if (parentId !== null) {
|
||||
if (parentId === id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'A notebook cannot be its own parent' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const target = await prisma.notebook.findFirst({
|
||||
where: { id: parentId, userId: session.user.id }
|
||||
})
|
||||
if (!target) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Parent notebook not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
const descendants = await getDescendantIds(id)
|
||||
if (descendants.includes(parentId)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Cannot move a notebook into its own descendant' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateData: any = {}
|
||||
if (name !== undefined) updateData.name = name.trim()
|
||||
if (icon !== undefined) updateData.icon = icon
|
||||
if (color !== undefined) updateData.color = color
|
||||
if (order !== undefined) updateData.order = order
|
||||
if (trashedAt !== undefined) updateData.trashedAt = trashedAt
|
||||
if (parentId !== undefined) updateData.parentId = parentId
|
||||
|
||||
if (trashedAt !== undefined) {
|
||||
const descendantIds = await getDescendantIds(id)
|
||||
|
||||
@@ -4,124 +4,103 @@ import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// Check authentication
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Fetch all notes with related data
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
trashedAt: null
|
||||
},
|
||||
include: {
|
||||
labelRelations: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true
|
||||
}
|
||||
},
|
||||
notebook: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
}
|
||||
})
|
||||
const userId = session.user.id
|
||||
|
||||
// Fetch labels separately
|
||||
const labels = await prisma.label.findMany({
|
||||
where: {
|
||||
userId: session.user.id
|
||||
},
|
||||
include: {
|
||||
notes: {
|
||||
select: {
|
||||
id: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
const [allNotes, labels, notebooks] = await Promise.all([
|
||||
prisma.note.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
}),
|
||||
prisma.label.findMany({
|
||||
where: { userId },
|
||||
include: { notes: { select: { id: true } } },
|
||||
}),
|
||||
prisma.notebook.findMany({
|
||||
where: { userId },
|
||||
include: { notes: { select: { id: true } } },
|
||||
}),
|
||||
])
|
||||
|
||||
// Fetch notebooks
|
||||
const notebooks = await prisma.notebook.findMany({
|
||||
where: {
|
||||
userId: session.user.id
|
||||
},
|
||||
include: {
|
||||
notes: {
|
||||
select: {
|
||||
id: true
|
||||
}
|
||||
}
|
||||
const noteLabelMap = new Map<string, string[]>()
|
||||
for (const label of labels) {
|
||||
for (const note of label.notes) {
|
||||
const arr = noteLabelMap.get(note.id) || []
|
||||
arr.push(label.id)
|
||||
noteLabelMap.set(note.id, arr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Create export object
|
||||
const exportData = {
|
||||
version: '1.0.0',
|
||||
version: '2.0.0',
|
||||
exportDate: new Date().toISOString(),
|
||||
user: {
|
||||
id: session.user.id,
|
||||
email: session.user.email,
|
||||
name: session.user.name
|
||||
id: userId,
|
||||
email: session.user.email ?? '',
|
||||
name: session.user.name ?? '',
|
||||
},
|
||||
data: {
|
||||
labels: labels.map(label => ({
|
||||
id: label.id,
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
noteCount: label.notes.length
|
||||
notebooks: notebooks.map(nb => ({
|
||||
id: nb.id,
|
||||
name: nb.name,
|
||||
icon: nb.icon,
|
||||
color: nb.color,
|
||||
order: nb.order,
|
||||
parentId: nb.parentId,
|
||||
trashedAt: nb.trashedAt?.toISOString() ?? null,
|
||||
createdAt: nb.createdAt.toISOString(),
|
||||
updatedAt: nb.updatedAt.toISOString(),
|
||||
noteIds: nb.notes.map(n => n.id),
|
||||
})),
|
||||
notebooks: notebooks.map(notebook => ({
|
||||
id: notebook.id,
|
||||
name: notebook.name,
|
||||
noteCount: notebook.notes.length
|
||||
labels: labels.map(lb => ({
|
||||
id: lb.id,
|
||||
name: lb.name,
|
||||
color: lb.color,
|
||||
notebookId: lb.notebookId,
|
||||
type: lb.type,
|
||||
createdAt: lb.createdAt.toISOString(),
|
||||
updatedAt: lb.updatedAt.toISOString(),
|
||||
noteIds: lb.notes.map(n => n.id),
|
||||
})),
|
||||
notes: notes.map(note => ({
|
||||
id: note.id,
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
color: note.color,
|
||||
isPinned: note.isPinned,
|
||||
isArchived: note.isArchived,
|
||||
type: note.type,
|
||||
checkItems: note.checkItems,
|
||||
images: note.images,
|
||||
links: note.links,
|
||||
createdAt: note.createdAt,
|
||||
updatedAt: note.updatedAt,
|
||||
notebookId: note.notebookId,
|
||||
labelRelations: note.labelRelations.map(label => ({
|
||||
id: label.id,
|
||||
name: label.name
|
||||
}))
|
||||
}))
|
||||
}
|
||||
notes: allNotes.map(n => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
content: n.content,
|
||||
color: n.color,
|
||||
isPinned: n.isPinned,
|
||||
isArchived: n.isArchived,
|
||||
type: n.type,
|
||||
order: n.order,
|
||||
isMarkdown: n.isMarkdown,
|
||||
size: n.size,
|
||||
autoGenerated: n.autoGenerated,
|
||||
historyEnabled: n.historyEnabled,
|
||||
checkItems: n.checkItems,
|
||||
images: n.images,
|
||||
links: n.links,
|
||||
trashedAt: n.trashedAt?.toISOString() ?? null,
|
||||
notebookId: n.notebookId,
|
||||
createdAt: n.createdAt.toISOString(),
|
||||
updatedAt: n.updatedAt.toISOString(),
|
||||
contentUpdatedAt: n.contentUpdatedAt?.toISOString() ?? null,
|
||||
labelIds: noteLabelMap.get(n.id) || [],
|
||||
})),
|
||||
},
|
||||
}
|
||||
|
||||
// Return as JSON file
|
||||
const jsonString = JSON.stringify(exportData, null, 2)
|
||||
return new NextResponse(jsonString, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Disposition': `attachment; filename="memento-export-${new Date().toISOString().split('T')[0]}.json"`
|
||||
}
|
||||
'Content-Disposition': `attachment; filename="memento-export-${new Date().toISOString().split('T')[0]}.json"`,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Export error:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to export notes' },
|
||||
{ status: 500 }
|
||||
)
|
||||
return NextResponse.json({ success: false, error: 'Failed to export notes' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,162 +3,231 @@ import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
function parseDate(v: unknown): Date | null {
|
||||
if (!v) return null
|
||||
const d = new Date(v as string)
|
||||
return isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Check authentication
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Parse form data
|
||||
const userId = session.user.id
|
||||
const formData = await req.formData()
|
||||
const file = formData.get('file') as File
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'No file provided' },
|
||||
{ status: 400 }
|
||||
)
|
||||
return NextResponse.json({ success: false, error: 'No file provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Parse JSON file
|
||||
const text = await file.text()
|
||||
let importData: any
|
||||
|
||||
try {
|
||||
importData = JSON.parse(text)
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid JSON file' },
|
||||
{ status: 400 }
|
||||
)
|
||||
} catch {
|
||||
return NextResponse.json({ success: false, error: 'Invalid JSON file' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate import data structure
|
||||
if (!importData.data || !importData.data.notes) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid import format' },
|
||||
{ status: 400 }
|
||||
)
|
||||
return NextResponse.json({ success: false, error: 'Invalid import format' }, { status: 400 })
|
||||
}
|
||||
|
||||
let importedNotes = 0
|
||||
let importedLabels = 0
|
||||
let importedNotebooks = 0
|
||||
const stats = { notes: 0, labels: 0, notebooks: 0, skipped: 0 }
|
||||
|
||||
// Import labels first
|
||||
if (importData.data.labels && Array.isArray(importData.data.labels)) {
|
||||
for (const label of importData.data.labels) {
|
||||
// Check if label already exists
|
||||
const existing = await prisma.label.findFirst({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
name: label.name
|
||||
}
|
||||
})
|
||||
// 1. Notebooks — parents first, preserve original IDs
|
||||
if (importData.data.notebooks?.length) {
|
||||
const sorted = [...importData.data.notebooks].sort((a: any, b: any) => {
|
||||
if (!a.parentId && b.parentId) return -1
|
||||
if (a.parentId && !b.parentId) return 1
|
||||
return 0
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
await prisma.label.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
name: label.name,
|
||||
color: label.color
|
||||
}
|
||||
for (const nb of sorted) {
|
||||
try {
|
||||
await prisma.notebook.upsert({
|
||||
where: { id: nb.id },
|
||||
update: {
|
||||
name: nb.name,
|
||||
icon: nb.icon ?? null,
|
||||
color: nb.color ?? null,
|
||||
order: nb.order ?? 0,
|
||||
parentId: nb.parentId ?? null,
|
||||
trashedAt: parseDate(nb.trashedAt),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
id: nb.id,
|
||||
name: nb.name,
|
||||
icon: nb.icon ?? null,
|
||||
color: nb.color ?? null,
|
||||
order: nb.order ?? 0,
|
||||
parentId: nb.parentId ?? null,
|
||||
trashedAt: parseDate(nb.trashedAt),
|
||||
userId,
|
||||
createdAt: parseDate(nb.createdAt) ?? new Date(),
|
||||
updatedAt: parseDate(nb.updatedAt) ?? new Date(),
|
||||
},
|
||||
})
|
||||
importedLabels++
|
||||
stats.notebooks++
|
||||
} catch (e: any) {
|
||||
if (e.code === 'P2002') {
|
||||
// unique constraint — try with new ID
|
||||
const parentId = nb.parentId ?? null
|
||||
await prisma.notebook.create({
|
||||
data: {
|
||||
name: nb.name,
|
||||
icon: nb.icon ?? null,
|
||||
color: nb.color ?? null,
|
||||
order: nb.order ?? 0,
|
||||
parentId,
|
||||
trashedAt: parseDate(nb.trashedAt),
|
||||
userId,
|
||||
createdAt: parseDate(nb.createdAt) ?? new Date(),
|
||||
updatedAt: parseDate(nb.updatedAt) ?? new Date(),
|
||||
},
|
||||
})
|
||||
stats.notebooks++
|
||||
} else {
|
||||
stats.skipped++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import notebooks
|
||||
const notebookIdMap = new Map<string, string>()
|
||||
if (importData.data.notebooks && Array.isArray(importData.data.notebooks)) {
|
||||
for (const notebook of importData.data.notebooks) {
|
||||
// Check if notebook already exists
|
||||
const existing = await prisma.notebook.findFirst({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
name: notebook.name
|
||||
}
|
||||
})
|
||||
|
||||
let newNotebookId
|
||||
if (!existing) {
|
||||
const created = await prisma.notebook.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
name: notebook.name,
|
||||
order: 0
|
||||
}
|
||||
// 2. Labels — preserve original IDs
|
||||
if (importData.data.labels?.length) {
|
||||
for (const lb of importData.data.labels) {
|
||||
try {
|
||||
await prisma.label.upsert({
|
||||
where: { id: lb.id },
|
||||
update: {
|
||||
name: lb.name,
|
||||
color: lb.color ?? null,
|
||||
notebookId: lb.notebookId ?? null,
|
||||
type: lb.type ?? 'user',
|
||||
},
|
||||
create: {
|
||||
id: lb.id,
|
||||
name: lb.name,
|
||||
color: lb.color ?? null,
|
||||
notebookId: lb.notebookId ?? null,
|
||||
type: lb.type ?? 'user',
|
||||
userId,
|
||||
createdAt: parseDate(lb.createdAt) ?? new Date(),
|
||||
updatedAt: parseDate(lb.updatedAt) ?? new Date(),
|
||||
},
|
||||
})
|
||||
newNotebookId = created.id
|
||||
notebookIdMap.set(notebook.id, newNotebookId)
|
||||
importedNotebooks++
|
||||
} else {
|
||||
notebookIdMap.set(notebook.id, existing.id)
|
||||
stats.labels++
|
||||
} catch {
|
||||
stats.skipped++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import notes
|
||||
if (importData.data.notes && Array.isArray(importData.data.notes)) {
|
||||
for (const note of importData.data.notes) {
|
||||
// Map notebook ID
|
||||
const mappedNotebookId = notebookIdMap.get(note.notebookId) || null
|
||||
// 3. Notes — preserve original IDs
|
||||
if (importData.data.notes?.length) {
|
||||
for (const n of importData.data.notes) {
|
||||
try {
|
||||
const labelIds: string[] = n.labelIds || []
|
||||
const validLabelIds = labelIds.length
|
||||
? await prisma.label.findMany({
|
||||
where: { id: { in: labelIds }, userId },
|
||||
select: { id: true },
|
||||
}).then(ls => ls.map(l => l.id))
|
||||
: []
|
||||
|
||||
// Get label IDs
|
||||
const labelNames = (note.labels || note.labelRelations || []).map((l: any) => l.name).filter(Boolean)
|
||||
const labels = await prisma.label.findMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
name: {
|
||||
in: labelNames
|
||||
}
|
||||
await prisma.note.upsert({
|
||||
where: { id: n.id },
|
||||
update: {
|
||||
title: n.title || 'Untitled',
|
||||
content: n.content || '',
|
||||
color: n.color || 'default',
|
||||
isPinned: n.isPinned ?? false,
|
||||
isArchived: n.isArchived ?? false,
|
||||
type: n.type || 'richtext',
|
||||
order: n.order ?? 0,
|
||||
isMarkdown: n.isMarkdown ?? false,
|
||||
size: n.size || 'small',
|
||||
autoGenerated: n.autoGenerated ?? false,
|
||||
historyEnabled: n.historyEnabled ?? true,
|
||||
checkItems: n.checkItems ?? undefined,
|
||||
images: n.images ?? undefined,
|
||||
links: n.links ?? undefined,
|
||||
trashedAt: parseDate(n.trashedAt),
|
||||
notebookId: n.notebookId ?? null,
|
||||
contentUpdatedAt: parseDate(n.contentUpdatedAt),
|
||||
updatedAt: new Date(),
|
||||
labelRelations: {
|
||||
set: validLabelIds.map(id => ({ id })),
|
||||
},
|
||||
},
|
||||
create: {
|
||||
id: n.id,
|
||||
title: n.title || 'Untitled',
|
||||
content: n.content || '',
|
||||
color: n.color || 'default',
|
||||
isPinned: n.isPinned ?? false,
|
||||
isArchived: n.isArchived ?? false,
|
||||
type: n.type || 'richtext',
|
||||
order: n.order ?? 0,
|
||||
isMarkdown: n.isMarkdown ?? false,
|
||||
size: n.size || 'small',
|
||||
autoGenerated: n.autoGenerated ?? false,
|
||||
historyEnabled: n.historyEnabled ?? true,
|
||||
checkItems: n.checkItems ?? undefined,
|
||||
images: n.images ?? undefined,
|
||||
links: n.links ?? undefined,
|
||||
trashedAt: parseDate(n.trashedAt),
|
||||
notebookId: n.notebookId ?? null,
|
||||
userId,
|
||||
createdAt: parseDate(n.createdAt) ?? new Date(),
|
||||
updatedAt: parseDate(n.updatedAt) ?? new Date(),
|
||||
contentUpdatedAt: parseDate(n.contentUpdatedAt),
|
||||
labelRelations: {
|
||||
connect: validLabelIds.map(id => ({ id })),
|
||||
},
|
||||
},
|
||||
})
|
||||
stats.notes++
|
||||
} catch (e: any) {
|
||||
if (e.code === 'P2002') {
|
||||
await prisma.note.create({
|
||||
data: {
|
||||
title: n.title || 'Untitled',
|
||||
content: n.content || '',
|
||||
color: n.color || 'default',
|
||||
isPinned: n.isPinned ?? false,
|
||||
isArchived: n.isArchived ?? false,
|
||||
type: n.type || 'richtext',
|
||||
order: n.order ?? 0,
|
||||
isMarkdown: n.isMarkdown ?? false,
|
||||
size: n.size || 'small',
|
||||
notebookId: n.notebookId ?? null,
|
||||
userId,
|
||||
createdAt: parseDate(n.createdAt) ?? new Date(),
|
||||
updatedAt: parseDate(n.updatedAt) ?? new Date(),
|
||||
contentUpdatedAt: parseDate(n.contentUpdatedAt),
|
||||
},
|
||||
})
|
||||
stats.notes++
|
||||
} else {
|
||||
stats.skipped++
|
||||
}
|
||||
})
|
||||
|
||||
// Create note
|
||||
await prisma.note.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
title: note.title || 'Untitled',
|
||||
content: note.content || '',
|
||||
color: note.color || 'default',
|
||||
isPinned: note.isPinned || false,
|
||||
isArchived: note.isArchived || false,
|
||||
type: note.type || 'richtext',
|
||||
checkItems: note.checkItems || null,
|
||||
images: note.images || null,
|
||||
links: note.links || null,
|
||||
notebookId: mappedNotebookId,
|
||||
labelRelations: {
|
||||
connect: labels.map(label => ({ id: label.id }))
|
||||
}
|
||||
}
|
||||
})
|
||||
importedNotes++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Revalidate paths
|
||||
revalidatePath('/')
|
||||
revalidatePath('/settings/data')
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
count: importedNotes,
|
||||
labels: importedLabels,
|
||||
notebooks: importedNotebooks
|
||||
})
|
||||
return NextResponse.json({ success: true, ...stats })
|
||||
} catch (error) {
|
||||
console.error('Import error:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to import notes' },
|
||||
{ status: 500 }
|
||||
)
|
||||
return NextResponse.json({ success: false, error: 'Failed to import notes' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
22
memento-note/app/api/usage/current/route.ts
Normal file
22
memento-note/app/api/usage/current/route.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { getUserQuotas } from '@/lib/entitlements';
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const quotas = await getUserQuotas(session.user.id);
|
||||
return NextResponse.json({ quotas });
|
||||
} catch (error) {
|
||||
console.error('[usage/current] Failed to fetch quotas:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch usage data' },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
56
memento-note/app/api/user/api-keys/[provider]/route.ts
Normal file
56
memento-note/app/api/user/api-keys/[provider]/route.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { VALID_PROVIDERS } from '@/lib/ai/router';
|
||||
|
||||
type RouteContext = { params: Promise<{ provider: string }> };
|
||||
|
||||
export async function PATCH(req: NextRequest, context: RouteContext) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { provider } = await context.params;
|
||||
if (!VALID_PROVIDERS.has(provider)) {
|
||||
return NextResponse.json({ error: 'Unknown provider' }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = (await req.json().catch(() => ({}))) as { isActive?: boolean };
|
||||
if (typeof body.isActive !== 'boolean') {
|
||||
return NextResponse.json({ error: 'isActive boolean required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = await prisma.userAPIKey.updateMany({
|
||||
where: { userId: session.user.id, provider },
|
||||
data: { isActive: body.isActive },
|
||||
});
|
||||
|
||||
if (updated.count === 0) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
export async function DELETE(_req: NextRequest, context: RouteContext) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { provider } = await context.params;
|
||||
if (!VALID_PROVIDERS.has(provider)) {
|
||||
return NextResponse.json({ error: 'Unknown provider' }, { status: 400 });
|
||||
}
|
||||
|
||||
const deleted = await prisma.userAPIKey.deleteMany({
|
||||
where: { userId: session.user.id, provider },
|
||||
});
|
||||
|
||||
if (deleted.count === 0) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
99
memento-note/app/api/user/api-keys/route.ts
Normal file
99
memento-note/app/api/user/api-keys/route.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { auth } from '@/auth';
|
||||
import { getEffectiveTier } from '@/lib/entitlements';
|
||||
import {
|
||||
getAllowedByokProviders,
|
||||
isByokProviderAllowed,
|
||||
} from '@/lib/byok';
|
||||
import {
|
||||
upsertUserApiKey,
|
||||
toPublicApiKey,
|
||||
} from '@/lib/byok';
|
||||
import { validateProviderApiKey } from '@/lib/byok/validate-key';
|
||||
import { VALID_PROVIDERS, type AiGatewayProvider } from '@/lib/ai/router';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
const createSchema = z.object({
|
||||
provider: z.string().min(1),
|
||||
apiKey: z.string().min(8),
|
||||
alias: z.string().max(120).optional(),
|
||||
model: z.string().max(120).optional(),
|
||||
baseUrl: z.string().url().optional(),
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const keys = await prisma.userAPIKey.findMany({
|
||||
where: { userId: session.user.id },
|
||||
orderBy: { provider: 'asc' },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
keys: keys.map(toPublicApiKey),
|
||||
allowedProviders: getAllowedByokProviders(await getEffectiveTier(session.user.id)),
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const tier = await getEffectiveTier(session.user.id);
|
||||
if (tier === 'BASIC') {
|
||||
return NextResponse.json(
|
||||
{ error: 'TIER_LIMITED', message: 'BYOK requires a Pro plan or higher' },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: z.infer<typeof createSchema>;
|
||||
try {
|
||||
body = createSchema.parse(await req.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const provider = body.provider as AiGatewayProvider;
|
||||
if (!VALID_PROVIDERS.has(provider)) {
|
||||
return NextResponse.json({ error: 'Unknown provider' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!isByokProviderAllowed(tier, provider)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'TIER_LIMITED', message: `Provider "${provider}" is not available on your plan` },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const effectiveBaseUrl = provider === 'custom' ? body.baseUrl : undefined;
|
||||
if (provider !== 'custom' && body.baseUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: 'INVALID_REQUEST', message: 'baseUrl is only allowed for custom providers' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await validateProviderApiKey(provider, body.apiKey, effectiveBaseUrl);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Invalid API key';
|
||||
return NextResponse.json({ error: 'INVALID_API_KEY', message }, { status: 400 });
|
||||
}
|
||||
|
||||
const row = await upsertUserApiKey({
|
||||
userId: session.user.id,
|
||||
provider,
|
||||
plaintext: body.apiKey,
|
||||
alias: body.alias,
|
||||
model: body.model,
|
||||
});
|
||||
|
||||
return NextResponse.json({ key: toPublicApiKey(row) });
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
--color-primary: #ACB995;
|
||||
--color-memento-accent: #D4A373;
|
||||
--color-memento-blue: #A47148;
|
||||
--color-brand-accent: #A47148;
|
||||
--color-memento-paper-elevated: #faf9f5;
|
||||
--color-background-light: var(--color-memento-paper);
|
||||
--color-background-dark: #202020;
|
||||
|
||||
@@ -123,7 +123,7 @@ export default async function RootLayout({
|
||||
<SessionProviderWrapper>
|
||||
<ErrorReporter />
|
||||
<DirectionInitializer />
|
||||
<ThemeInitializer theme={userSettings.theme} fontSize={aiSettings.fontSize} fontFamily={aiSettings.fontFamily} />
|
||||
<ThemeInitializer theme={userSettings.theme} fontSize={aiSettings.fontSize} fontFamily={aiSettings.fontFamily} accentColor={userSettings.accentColor} />
|
||||
{children}
|
||||
<Toaster />
|
||||
</SessionProviderWrapper>
|
||||
|
||||
@@ -73,6 +73,27 @@ const TOOL_PRESETS: Record<string, string[]> = {
|
||||
'task-extractor': ['note_search', 'note_read', 'task_extract', 'note_create'],
|
||||
}
|
||||
|
||||
const SLIDE_GENERATOR_THEME_VALUES = [
|
||||
'modern_wellness',
|
||||
'business_authority',
|
||||
'nature_outdoors',
|
||||
'vintage_academic',
|
||||
'soft_creative',
|
||||
'bohemian',
|
||||
'vibrant_tech',
|
||||
'craft_artisan',
|
||||
'tech_night',
|
||||
'education_charts',
|
||||
'forest_eco',
|
||||
'elegant_fashion',
|
||||
'art_food',
|
||||
'luxury_mystery',
|
||||
'pure_tech_blue',
|
||||
'coastal_coral',
|
||||
'vibrant_orange_mint',
|
||||
'platinum_white_gold',
|
||||
] as const
|
||||
|
||||
interface AgentDetailViewProps {
|
||||
agent?: {
|
||||
id: string
|
||||
@@ -539,24 +560,9 @@ export function AgentDetailView({
|
||||
<label className={labelCls}>{t('agents.form.slideTheme')}<FieldHelp tooltip={t('agents.help.tooltips.slideTheme')} /></label>
|
||||
<select value={slideTheme} onChange={e => setSlideTheme(e.target.value)} className={selectCls}>
|
||||
<option value="">{t('agents.form.slideThemeDefault')}</option>
|
||||
<option value="modern_wellness">Modern & Wellness</option>
|
||||
<option value="business_authority">Business & Authority</option>
|
||||
<option value="nature_outdoors">Nature & Outdoors</option>
|
||||
<option value="vintage_academic">Vintage & Academic</option>
|
||||
<option value="soft_creative">Soft & Creative</option>
|
||||
<option value="bohemian">Bohemian</option>
|
||||
<option value="vibrant_tech">Vibrant & Tech</option>
|
||||
<option value="craft_artisan">Craft & Artisan</option>
|
||||
<option value="tech_night">Tech & Night (dark)</option>
|
||||
<option value="education_charts">Education & Charts</option>
|
||||
<option value="forest_eco">Forest & Eco</option>
|
||||
<option value="elegant_fashion">Elegant & Fashion</option>
|
||||
<option value="art_food">Art & Food</option>
|
||||
<option value="luxury_mystery">Luxury & Mystery</option>
|
||||
<option value="pure_tech_blue">Pure Tech Blue</option>
|
||||
<option value="coastal_coral">Coastal Coral</option>
|
||||
<option value="vibrant_orange_mint">Vibrant Orange Mint</option>
|
||||
<option value="platinum_white_gold">Platinum White Gold</option>
|
||||
{SLIDE_GENERATOR_THEME_VALUES.map(themeId => (
|
||||
<option key={themeId} value={themeId}>{t(`agents.form.slideThemes.${themeId}`)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { useChat } from '@ai-sdk/react'
|
||||
import { DefaultChatTransport } from 'ai'
|
||||
import type { UIMessage } from 'ai'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { X, Bot, Sparkles, History, Send, Globe, Briefcase, Palette, GraduationCap, Coffee, Loader2, BookOpen, Layers, Square, Plus } from 'lucide-react'
|
||||
import {
|
||||
X, Bot, Sparkles, History, Send, Globe, Briefcase, Palette, GraduationCap, Coffee,
|
||||
Loader2, Layers, Square, Plus, ChevronRight, MessageSquare, FileCode,
|
||||
Zap, Network, Clock, Scissors, Languages, Layout, ArrowRightLeft, BookOpen,
|
||||
Maximize2, Minimize2
|
||||
} from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { MarkdownContent } from '@/components/markdown-content'
|
||||
import { useWebSearchAvailable } from '@/hooks/use-web-search-available'
|
||||
@@ -14,6 +18,7 @@ import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { HierarchicalNotebookSelector } from '@/components/hierarchical-notebook-selector'
|
||||
import { toast } from 'sonner'
|
||||
import { createConversation } from '@/app/actions/chat-actions'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
|
||||
function getTextContent(msg: UIMessage): string {
|
||||
if (msg.parts && Array.isArray(msg.parts)) {
|
||||
@@ -23,36 +28,37 @@ function getTextContent(msg: UIMessage): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
const TONES = [
|
||||
{ id: 'professional', label: 'Professional', icon: Briefcase },
|
||||
{ id: 'creative', label: 'Creative', icon: Palette },
|
||||
{ id: 'academic', label: 'Academic', icon: GraduationCap },
|
||||
{ id: 'casual', label: 'Casual', icon: Coffee },
|
||||
]
|
||||
const TONE_IDS = [
|
||||
{ id: 'professional', icon: Briefcase },
|
||||
{ id: 'creative', icon: Palette },
|
||||
{ id: 'academic', icon: GraduationCap },
|
||||
{ id: 'casual', icon: Coffee },
|
||||
] as const
|
||||
|
||||
type AITab = 'discussion' | 'actions' | 'explore' | 'resources'
|
||||
|
||||
export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: boolean } = {}) {
|
||||
const { t, language } = useLanguage()
|
||||
const webSearchAvailable = useWebSearchAvailable()
|
||||
const { notebooks } = useNotebooks()
|
||||
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState('chat')
|
||||
const [isContextualAIVisible, setIsContextualAIVisible] = useState(false)
|
||||
const [aiTab, setAiTab] = useState<AITab>('discussion')
|
||||
const [selectedTone, setSelectedTone] = useState('professional')
|
||||
const [webSearch, setWebSearch] = useState(false)
|
||||
const [chatScope, setChatScope] = useState<'all' | string>('all')
|
||||
const [input, setInput] = useState('')
|
||||
const [conversationId, setConversationId] = useState<string | undefined>()
|
||||
|
||||
const [isContextualAIVisible, setIsContextualAIVisible] = useState(false)
|
||||
|
||||
const [history, setHistory] = useState<any[]>([])
|
||||
const [historyLoading, setHistoryLoading] = useState(false)
|
||||
|
||||
|
||||
const [insights, setInsights] = useState<string>('')
|
||||
const [insightsLoading, setInsightsLoading] = useState(false)
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const transport = useRef(new DefaultChatTransport({ api: '/api/chat' })).current
|
||||
|
||||
const { messages, setMessages, sendMessage, status, stop } = useChat({
|
||||
@@ -69,8 +75,6 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
||||
const text = input.trim()
|
||||
if (!text || isLoading) return
|
||||
setInput('')
|
||||
|
||||
// Create conversation upfront so we have the ID for continuity
|
||||
let convId = conversationId
|
||||
if (!convId) {
|
||||
try {
|
||||
@@ -82,7 +86,6 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await sendMessage(
|
||||
{ text },
|
||||
@@ -125,9 +128,10 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'history') fetchHistory()
|
||||
if (activeTab === 'insights' && !insights) fetchInsights()
|
||||
}, [activeTab])
|
||||
if (aiTab === 'discussion') {
|
||||
// history is loaded on demand via insights tab
|
||||
}
|
||||
}, [aiTab])
|
||||
|
||||
useEffect(() => {
|
||||
if (messagesEndRef.current) {
|
||||
@@ -135,331 +139,456 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
const handleToggle = useCallback(() => setIsOpen(v => !v), [])
|
||||
const handleVisibility = useCallback((e: any) => setIsContextualAIVisible(e.detail), [])
|
||||
useEffect(() => {
|
||||
const handleToggle = () => setIsOpen(v => !v)
|
||||
const handleVisibility = (e: any) => setIsContextualAIVisible(e.detail)
|
||||
window.addEventListener('toggle-ai-chat', handleToggle)
|
||||
window.addEventListener('contextual-ai-visibility', handleVisibility)
|
||||
return () => {
|
||||
window.removeEventListener('toggle-ai-chat', handleToggle)
|
||||
window.removeEventListener('contextual-ai-visibility', handleVisibility)
|
||||
}
|
||||
}, [])
|
||||
}, [handleToggle, handleVisibility])
|
||||
|
||||
// Floating trigger when closed
|
||||
if (!isOpen) {
|
||||
if (isContextualAIVisible) return null
|
||||
if (!showFloatingTrigger) return null
|
||||
if (!showFloatingTrigger || isContextualAIVisible) return null
|
||||
return (
|
||||
<Button
|
||||
<motion.button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="fixed bottom-6 end-6 h-12 w-12 rounded-full shadow-xl z-40 transition-transform hover:scale-105 bg-muted text-foreground hover:bg-muted/80 border border-border"
|
||||
size="icon"
|
||||
className="fixed bottom-6 end-6 h-12 w-12 rounded-2xl shadow-xl z-40 bg-brand-accent text-white hover:scale-105 border border-brand-accent/20"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
title={t('ai.openAssistant')}
|
||||
>
|
||||
<Sparkles className="h-5 w-5 text-memento-accent" />
|
||||
</Button>
|
||||
<Sparkles className="h-5 w-5 mx-auto" />
|
||||
</motion.button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className={cn(
|
||||
"fixed bottom-20 end-6 border border-border/40 bg-memento-paper dark:bg-background flex flex-col z-40 shadow-2xl rounded-2xl overflow-hidden transition-all duration-300",
|
||||
isExpanded ? "w-[80vw] h-[85vh] max-w-[1200px]" : "h-[700px] max-h-[85vh] w-[360px]"
|
||||
)}>
|
||||
{/* Header */}
|
||||
<div className="px-5 py-4 border-b border-border/40 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-heading text-lg font-semibold text-foreground tracking-tight">{t('ai.assistantTitle')}</h2>
|
||||
<p className="text-xs text-muted-foreground font-medium">{t('ai.poweredByMomento')}</p>
|
||||
<motion.aside
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||
className={cn(
|
||||
"fixed bottom-20 end-6 border border-border/60 bg-[#FDFCFB] dark:bg-[#0D0D0D] shadow-2xl flex flex-col z-50 rounded-2xl overflow-hidden transition-all duration-300",
|
||||
isExpanded ? "w-[80vw] h-[85vh] max-w-[1200px]" : "w-[420px] h-[85vh] max-h-[800px]"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-border/60 space-y-1.5 bg-white/50 dark:bg-black/20 backdrop-blur-md shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="flex items-center gap-2 font-serif text-xl font-medium text-ink">
|
||||
<Sparkles size={18} className="text-brand-accent" />
|
||||
{t('ai.assistantTitle')}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setIsExpanded(e => !e)}
|
||||
className="p-1.5 hover:bg-slate-100 dark:hover:bg-white/10 rounded-lg transition-colors text-concrete"
|
||||
title={isExpanded ? t('ai.shrinkPanel') : t('ai.expandPanel')}
|
||||
>
|
||||
{isExpanded ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setMessages([]); setConversationId(undefined) }}
|
||||
className="p-1.5 hover:bg-slate-100 dark:hover:bg-white/10 rounded-lg transition-colors text-concrete"
|
||||
title={t('ai.newDiscussion')}
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-1.5 hover:bg-slate-100 dark:hover:bg-white/10 rounded-lg transition-colors text-concrete"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-concrete uppercase tracking-wider font-medium opacity-60">
|
||||
{t('ai.poweredByMomento')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => { setMessages([]); setConversationId(undefined) }} className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-muted" title={t('ai.newDiscussion')}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => setIsExpanded(!isExpanded)} className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-muted">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<polyline points="4 14 10 14 10 20"></polyline>
|
||||
<polyline points="20 10 14 10 14 4"></polyline>
|
||||
<line x1="14" y1="10" x2="21" y2="3"></line>
|
||||
<line x1="3" y1="21" x2="10" y2="14"></line>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<polyline points="15 3 21 3 21 9"></polyline>
|
||||
<polyline points="9 21 3 21 3 15"></polyline>
|
||||
<line x1="21" y1="3" x2="14" y2="10"></line>
|
||||
<line x1="3" y1="21" x2="10" y2="14"></line>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => setIsOpen(false)} className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-muted">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-border px-2 shrink-0">
|
||||
{(['discussion', 'actions', 'explore', 'resources'] as AITab[]).map((tab) => {
|
||||
const labels: Record<AITab, string> = {
|
||||
discussion: t('ai.chatTab') || 'Discussion',
|
||||
actions: t('ai.actionsTab') || 'Actions',
|
||||
explore: t('ai.exploreTab') || 'Explorer',
|
||||
resources: t('ai.resourcesTab') || 'Ressources',
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setAiTab(tab)}
|
||||
className={cn(
|
||||
"flex-1 py-3 text-[10px] uppercase tracking-[0.2em] font-bold transition-all relative",
|
||||
aiTab === tab ? 'text-brand-accent' : 'text-concrete hover:text-ink/60'
|
||||
)}
|
||||
>
|
||||
{labels[tab]}
|
||||
{aiTab === tab && (
|
||||
<motion.div
|
||||
layoutId="activeAiTab"
|
||||
className="absolute bottom-0 left-0 right-0 h-0.5 bg-brand-accent"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="flex px-4 pt-4 border-b border-border/40 shrink-0">
|
||||
<button
|
||||
onClick={() => setActiveTab('chat')}
|
||||
className={cn(
|
||||
"flex-1 pb-3 border-b-2 text-sm font-semibold flex items-center justify-center gap-2 transition-all",
|
||||
activeTab === 'chat' ? "border-memento-blue text-memento-blue" : "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Bot className="h-4 w-4" /> {t('ai.chatTab')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('insights')}
|
||||
className={cn(
|
||||
"flex-1 pb-3 border-b-2 text-sm font-semibold flex items-center justify-center gap-2 transition-all",
|
||||
activeTab === 'insights' ? "border-memento-blue text-memento-blue" : "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Sparkles className="h-4 w-4" /> {t('ai.insightsTab')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('history')}
|
||||
className={cn(
|
||||
"flex-1 pb-3 border-b-2 text-sm font-semibold flex items-center justify-center gap-2 transition-all",
|
||||
activeTab === 'history' ? "border-memento-blue text-memento-blue" : "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<History className="h-4 w-4" /> {t('ai.historyTab')}
|
||||
</button>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar">
|
||||
<AnimatePresence mode="wait">
|
||||
{aiTab === 'discussion' && (
|
||||
<motion.div key="discussion" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} className="space-y-6">
|
||||
{/* Context selector */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-concrete">{t('ai.writingTone')}</label>
|
||||
<div className="flex items-center gap-1">
|
||||
{TONE_IDS.map((tone) => {
|
||||
const Icon = tone.icon
|
||||
return (
|
||||
<button
|
||||
key={tone.id}
|
||||
onClick={() => setSelectedTone(tone.id)}
|
||||
title={t(`ai.tones.${tone.id}`)}
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-lg flex items-center justify-center text-[9px] font-bold transition-all border',
|
||||
selectedTone === tone.id
|
||||
? 'bg-brand-accent text-white border-brand-accent shadow-sm'
|
||||
: 'bg-white/50 dark:bg-white/5 border-border/40 text-concrete hover:border-brand-accent/40'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 overflow-y-auto p-5 space-y-6">
|
||||
{activeTab === 'chat' && (
|
||||
<>
|
||||
{/* AI Welcome Message */}
|
||||
{messages.length === 0 && (
|
||||
<div className="flex gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-memento-blue/20 text-memento-blue flex items-center justify-center flex-shrink-0 border border-memento-blue/30 shadow-sm">
|
||||
<Bot className="h-4 w-4" />
|
||||
<button
|
||||
onClick={() => setChatScope('all')}
|
||||
className={cn(
|
||||
'w-full p-2.5 border rounded-xl text-[11px] flex items-center justify-between transition-all',
|
||||
chatScope === 'all' ? 'bg-brand-accent/5 border-brand-accent/30' : 'bg-white/50 dark:bg-white/5 border-border/40 hover:border-ink/20'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<FileCode size={14} className="text-brand-accent/60" />
|
||||
<span className={cn('font-medium', chatScope === 'all' ? 'text-brand-accent' : 'text-concrete')}>
|
||||
{t('ai.allMyNotes') || 'Toutes mes notes'}
|
||||
</span>
|
||||
</div>
|
||||
{chatScope === 'all' && (
|
||||
<span className="text-[8px] bg-brand-accent/10 text-brand-accent px-1.5 py-0.5 rounded-full uppercase font-bold">Auto</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
<span className="text-[9px] font-bold text-concrete uppercase tracking-widest">+ Carnet</span>
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
</div>
|
||||
|
||||
<HierarchicalNotebookSelector
|
||||
notebooks={notebooks.filter(nb => !nb.trashedAt)}
|
||||
selectedId={chatScope !== 'all' ? chatScope : null}
|
||||
onSelect={(id) => setChatScope(id)}
|
||||
placeholder={t('ai.selectNotebook') || 'Inclure un carnet...'}
|
||||
dropUp
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-memento-paper dark:bg-background border border-border/50 p-3.5 rounded-2xl rounded-tl-sm shadow-sm">
|
||||
<p className="text-sm text-foreground leading-relaxed">
|
||||
{t('ai.welcomeMsg')}
|
||||
|
||||
{/* Messages */}
|
||||
{messages.length === 0 && (
|
||||
<div className="h-48 flex flex-col items-center justify-center text-center space-y-3 text-concrete/30">
|
||||
<div className="w-12 h-12 rounded-full border border-dashed border-concrete/10 flex items-center justify-center">
|
||||
<MessageSquare size={18} />
|
||||
</div>
|
||||
<p className="text-[11px] italic leading-relaxed px-12">{t('ai.welcomeMsg')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg: UIMessage) => {
|
||||
const text = getTextContent(msg)
|
||||
if (msg.role === 'assistant' && !text) return null
|
||||
return (
|
||||
<div key={msg.id} className={cn('flex gap-3', msg.role === 'user' && 'flex-row-reverse')}>
|
||||
<div className={cn(
|
||||
'w-8 h-8 rounded-xl flex items-center justify-center flex-shrink-0',
|
||||
msg.role === 'user' ? 'bg-ink text-paper' : 'bg-brand-accent/10 text-brand-accent',
|
||||
)}>
|
||||
{msg.role === 'user' ? 'U' : <Bot className="h-4 w-4" />}
|
||||
</div>
|
||||
<div className={cn(
|
||||
'max-w-[85%] p-3.5 rounded-2xl text-sm leading-relaxed',
|
||||
msg.role === 'user'
|
||||
? 'bg-ink text-paper rounded-tr-sm'
|
||||
: 'bg-white/60 dark:bg-white/5 border border-border rounded-tl-sm text-ink',
|
||||
)}>
|
||||
{msg.role === 'assistant' ? <MarkdownContent content={text} /> : <p>{text}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex gap-3">
|
||||
<div className="w-8 h-8 rounded-xl bg-brand-accent/10 text-brand-accent flex items-center justify-center flex-shrink-0">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
<div className="bg-white/60 dark:bg-white/5 border border-border p-3.5 rounded-2xl rounded-tl-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-concrete" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{aiTab === 'actions' && (
|
||||
<motion.div key="actions" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} className="space-y-8">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
<h4 className="text-[10px] uppercase tracking-[0.25em] font-bold text-concrete whitespace-nowrap">Transformations</h4>
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[
|
||||
{ icon: <Sparkles size={14} />, label: t('ai.actionClarify') || 'Clarifier' },
|
||||
{ icon: <Scissors size={14} />, label: t('ai.actionShorten') || 'Raccourcir' },
|
||||
{ icon: <Zap size={14} />, label: t('ai.actionImprove') || 'Améliorer' },
|
||||
{ icon: <Languages size={14} />, label: t('ai.actionTranslate') || 'Traduire' },
|
||||
].map((action, i) => (
|
||||
<button key={i} className="flex flex-col items-center gap-3 p-4 bg-white/50 dark:bg-white/5 border border-border rounded-xl transition-all group hover:border-ink/20">
|
||||
<div className="p-2 rounded-lg bg-slate-50 dark:bg-white/10 transition-colors group-hover:bg-brand-accent group-hover:text-white shadow-sm text-concrete">
|
||||
{action.icon}
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-ink/80 uppercase tracking-widest">{action.label}</span>
|
||||
</button>
|
||||
))}
|
||||
<button className="col-span-2 flex items-center justify-center gap-3 py-3 px-4 bg-white/50 dark:bg-white/5 border border-border rounded-xl text-[10px] font-bold text-ink/80 hover:bg-white dark:hover:bg-white/10 transition-colors hover:border-ink/20 uppercase tracking-widest">
|
||||
<FileCode size={14} className="text-concrete" />
|
||||
{t('ai.actionMarkdown') || 'Convertir en Markdown'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
<h4 className="text-[10px] uppercase tracking-[0.25em] font-bold text-concrete whitespace-nowrap">Generation Tools</h4>
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
</div>
|
||||
|
||||
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-brand-accent/30 transition-all duration-500 overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
||||
<Layout size={80} className="text-brand-accent" />
|
||||
</div>
|
||||
<div className="relative space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-slate-50 rounded-lg text-brand-accent"><Layout size={18} /></div>
|
||||
<div className="space-y-0.5">
|
||||
<h5 className="text-sm font-bold text-ink leading-none">{t('ai.slides') || 'Présentation'}</h5>
|
||||
<p className="text-[10px] text-concrete uppercase tracking-tight">{t('ai.slidesDesc') || 'Convertir en slides interactives'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="w-full py-3.5 bg-brand-accent text-white rounded-xl text-[10px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-brand-accent/20 uppercase tracking-[0.2em]">
|
||||
{t('ai.generate') || 'Générer'} <ArrowRightLeft size={14} className="opacity-60" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-emerald-500/30 transition-all duration-500 overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
||||
<BookOpen size={80} className="text-emerald-500" />
|
||||
</div>
|
||||
<div className="relative space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-slate-50 rounded-lg text-emerald-500"><BookOpen size={18} /></div>
|
||||
<div className="space-y-0.5">
|
||||
<h5 className="text-sm font-bold text-ink leading-none">{t('ai.diagram') || 'Diagramme'}</h5>
|
||||
<p className="text-[10px] text-concrete uppercase tracking-tight">{t('ai.diagramDesc') || 'Visualisation de structure'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="w-full py-3.5 bg-emerald-600 text-white rounded-xl text-[10px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-emerald-600/20 uppercase tracking-[0.2em]">
|
||||
{t('ai.trace') || 'Tracer'} <ArrowRightLeft size={14} className="opacity-60" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-2 opacity-20 py-4">
|
||||
<History size={16} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest whitespace-nowrap">Auto-Save Enabled</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{aiTab === 'explore' && (
|
||||
<motion.div key="explore" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} className="space-y-6">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
<h4 className="text-[10px] uppercase tracking-[0.25em] font-bold text-concrete whitespace-nowrap">Intelligence Modules</h4>
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button className="w-full group relative p-5 rounded-2xl bg-white border border-border hover:border-brand-accent/30 transition-all text-left overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
||||
<Zap size={60} className="text-brand-accent" />
|
||||
</div>
|
||||
<div className="relative flex items-center gap-4">
|
||||
<div className="p-3 bg-brand-accent/10 rounded-xl text-brand-accent group-hover:bg-brand-accent group-hover:text-white transition-colors">
|
||||
<Zap size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="font-bold text-ink text-sm">{t('ai.brainstormWave') || 'Brainstorm Wave'}</h5>
|
||||
<p className="text-[10px] text-concrete uppercase tracking-tight">{t('ai.brainstormWaveDesc') || 'Unfold dimensions of thought'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button className="w-full group relative p-5 rounded-2xl bg-white border border-border hover:border-indigo-500/30 transition-all text-left overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
||||
<Network size={60} className="text-indigo-500" />
|
||||
</div>
|
||||
<div className="relative flex items-center gap-4">
|
||||
<div className="p-3 bg-indigo-500/10 rounded-xl text-indigo-500 group-hover:bg-indigo-500 group-hover:text-white transition-colors">
|
||||
<Network size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="font-bold text-ink text-sm">{t('ai.semanticNetwork') || 'Réseau Sémantique'}</h5>
|
||||
<p className="text-[10px] text-concrete uppercase tracking-tight">{t('ai.semanticNetworkDesc') || 'Detect clusters and bridges'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button className="w-full group relative p-5 rounded-2xl bg-white border border-border hover:border-rose-500/30 transition-all text-left overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
||||
<Clock size={60} className="text-rose-500" />
|
||||
</div>
|
||||
<div className="relative flex items-center gap-4">
|
||||
<div className="p-3 bg-rose-500/10 rounded-xl text-rose-500 group-hover:bg-rose-500 group-hover:text-white transition-colors">
|
||||
<Clock size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="font-bold text-ink text-sm">{t('ai.temporalForecast') || 'Prévision Temporelle'}</h5>
|
||||
<p className="text-[10px] text-concrete uppercase tracking-tight">{t('ai.temporalForecastDesc') || 'Predict relevance recurrence'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 rounded-2xl bg-slate-50 dark:bg-white/5 border border-dashed border-border">
|
||||
<p className="text-[10px] text-concrete leading-relaxed font-medium italic text-center">
|
||||
{t('ai.modulesHint') || 'Ces modules utilisent les embeddings pour analyser vos pensées.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
{messages.map((msg: UIMessage) => {
|
||||
const text = getTextContent(msg)
|
||||
// Skip empty assistant messages (thinking/reasoning phase)
|
||||
if (msg.role === 'assistant' && !text) return null
|
||||
return (
|
||||
<div key={msg.id} className={cn('flex gap-3', msg.role === 'user' && 'flex-row-reverse')}>
|
||||
<div className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 border text-[10px] font-bold',
|
||||
msg.role === 'user'
|
||||
? 'bg-muted border-border text-muted-foreground'
|
||||
: 'bg-memento-blue/20 text-memento-blue border-memento-blue/30 shadow-sm',
|
||||
)}>
|
||||
{msg.role === 'user' ? 'U' : <Bot className="h-4 w-4" />}
|
||||
</div>
|
||||
<div className={cn(
|
||||
'max-w-[85%] p-3.5 rounded-2xl text-sm leading-relaxed shadow-sm',
|
||||
msg.role === 'user'
|
||||
? 'bg-memento-blue text-white rounded-tr-sm'
|
||||
: 'bg-memento-paper dark:bg-background border border-border/50 rounded-tl-sm text-foreground',
|
||||
)}>
|
||||
{msg.role === 'assistant'
|
||||
? <MarkdownContent content={text} />
|
||||
: <p>{text}</p>}
|
||||
|
||||
{aiTab === 'resources' && (
|
||||
<motion.div key="resources" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-concrete">{t('ai.resourceUrl') || 'URL (Optionnel)'}</label>
|
||||
<div className="relative">
|
||||
<input type="text" placeholder="https://..." className="w-full bg-white/50 dark:bg-white/5 border border-border rounded-xl pl-4 pr-10 py-3 text-xs outline-none focus:border-brand-accent transition-colors text-ink" />
|
||||
<Globe size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-concrete/40" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-memento-blue/10 text-memento-blue flex items-center justify-center flex-shrink-0 border border-memento-blue/20">
|
||||
<Bot className="h-4 w-4" />
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-concrete">{t('ai.resourceText') || 'Texte de la ressource'}</label>
|
||||
<textarea
|
||||
rows={8}
|
||||
placeholder={t('ai.resourcePlaceholder') || 'Collez votre texte ici...'}
|
||||
className="w-full bg-white/50 dark:bg-white/5 border border-border rounded-xl p-4 text-xs outline-none focus:border-brand-accent transition-colors resize-none leading-relaxed text-ink"
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-memento-paper dark:bg-background border border-border/50 p-3.5 rounded-2xl rounded-tl-sm shadow-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-concrete">{t('ai.integrationMode') || "Mode d'intégration"}</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ id: 'replace', label: t('ai.modeReplace') || 'Remplacer', sub: 'Direct' },
|
||||
{ id: 'append', label: t('ai.modeAppend') || 'Compléter', sub: 'Ajoute' },
|
||||
{ id: 'merge', label: t('ai.modeMerge') || 'Fusionner', sub: 'Intègre' },
|
||||
].map((mode) => (
|
||||
<button key={mode.id} className="flex flex-col items-center justify-center p-3 rounded-xl border transition-all text-center bg-white border-border hover:bg-slate-50 dark:hover:bg-white/5">
|
||||
<span className="text-[10px] font-bold text-ink">{mode.label}</span>
|
||||
<span className="text-[8px] text-concrete opacity-60 leading-tight mt-1 font-medium">{mode.sub}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'insights' && (
|
||||
<div className="h-full">
|
||||
<h3 className="text-sm font-semibold mb-4 flex items-center gap-2"><Sparkles className="h-4 w-4 text-memento-accent" /> {t('ai.summaryLast5')}</h3>
|
||||
{insightsLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 opacity-60">
|
||||
<Loader2 className="h-8 w-8 animate-spin mb-4 text-muted-foreground" />
|
||||
<p className="text-xs text-muted-foreground">{t('ai.analyzingProgress')}</p>
|
||||
</div>
|
||||
) : insights ? (
|
||||
<div className="prose prose-sm dark:prose-invert">
|
||||
<MarkdownContent content={insights} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center mt-6">
|
||||
<Button onClick={fetchInsights} variant="outline" size="sm">{t('ai.generateInsightsBtn')}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'history' && (
|
||||
<div className="space-y-3">
|
||||
{historyLoading ? (
|
||||
<div className="flex justify-center py-10 opacity-60">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : history.length > 0 ? (
|
||||
history.map(conv => (
|
||||
<button
|
||||
key={conv.id}
|
||||
className="w-full text-start p-3 rounded-xl border border-border/50 hover:bg-muted/50 hover:border-memento-blue/30 transition-all flex flex-col gap-1"
|
||||
onClick={() => {
|
||||
setConversationId(conv.id)
|
||||
setMessages(conv.messages.map((m: any) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
parts: [{ type: 'text', text: m.content }]
|
||||
})))
|
||||
setActiveTab('chat')
|
||||
}}
|
||||
>
|
||||
<span className="text-sm font-medium line-clamp-1">{conv.title || conv.messages[0]?.content || t('ai.newDiscussion')}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{new Date(conv.updatedAt).toLocaleString()}</span>
|
||||
|
||||
<button className="w-full py-4 bg-brand-accent text-white rounded-xl text-[11px] font-bold uppercase tracking-[0.2em] flex items-center justify-center gap-3 hover:opacity-90 transition-opacity shadow-lg shadow-brand-accent/20">
|
||||
<Sparkles size={18} />
|
||||
{t('ai.generatePreview') || "Générer l'aperçu"}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-10 opacity-60 text-center space-y-2">
|
||||
<History className="h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm">{t('ai.noRecentConversations')}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input Area & Tone Controls (Only in Chat tab) */}
|
||||
<div className={cn("p-4 border-t border-border/40 bg-memento-paper dark:bg-background shrink-0", activeTab !== 'chat' && "hidden")}>
|
||||
{/* Context Scope */}
|
||||
<div className="mb-3 space-y-2">
|
||||
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground block ms-1">Source du Contexte</span>
|
||||
<button
|
||||
onClick={() => setChatScope('all')}
|
||||
className={cn(
|
||||
'w-full p-2.5 border rounded-lg text-xs flex items-center justify-between transition-all',
|
||||
chatScope === 'all' ? 'bg-memento-blue/15 border-memento-blue/40 shadow-inner' : 'bg-card border-border hover:border-foreground/20'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-3.5 w-3.5 text-memento-blue/70" />
|
||||
<span className={cn('font-bold', chatScope === 'all' ? 'text-memento-blue' : 'text-foreground/60')}>
|
||||
{t('ai.allMyNotes') || 'Toutes mes notes'}
|
||||
</span>
|
||||
</div>
|
||||
{chatScope === 'all' && (
|
||||
<span className="text-[8px] bg-memento-blue/20 text-memento-blue px-1.5 py-0.5 rounded uppercase font-bold border border-memento-blue/30">Auto</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
<span className="text-[9px] font-bold text-muted-foreground uppercase tracking-widest">+ Carnet</span>
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
</div>
|
||||
|
||||
<HierarchicalNotebookSelector
|
||||
notebooks={notebooks.filter(nb => !nb.trashedAt)}
|
||||
selectedId={chatScope !== 'all' ? chatScope : null}
|
||||
onSelect={(id) => setChatScope(id)}
|
||||
placeholder={t('ai.selectNotebook') || 'Inclure un carnet...'}
|
||||
dropUp
|
||||
/>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Tone Selection */}
|
||||
<div className="mb-3">
|
||||
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground block mb-1.5 ms-1">{t('ai.writingTone')}</span>
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{TONES.map(tone => {
|
||||
const Icon = tone.icon
|
||||
const isSelected = selectedTone === tone.id
|
||||
return (
|
||||
<button
|
||||
key={tone.id}
|
||||
onClick={() => setSelectedTone(tone.id)}
|
||||
title={tone.label}
|
||||
className={cn(
|
||||
"py-1 rounded-md border text-[10px] font-medium transition-all flex flex-col items-center justify-center gap-0.5",
|
||||
isSelected
|
||||
? "border-memento-blue bg-memento-blue/15 text-memento-blue shadow-sm font-bold"
|
||||
: "border-border/60 bg-memento-paper dark:bg-background text-muted-foreground hover:bg-muted hover:border-border"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
<span className="hidden sm:inline text-[9px]">{tone.label.slice(0, 4)}.</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Text Input */}
|
||||
<div className="relative bg-memento-paper dark:bg-background border border-border/60 rounded-xl p-1 focus-within:border-memento-blue focus-within:ring-1 focus-within:ring-memento-blue/20 transition-all shadow-sm">
|
||||
<textarea
|
||||
className="w-full bg-transparent border-none focus:ring-0 resize-none text-sm text-foreground placeholder:text-muted-foreground/70 p-2 min-h-[60px] max-h-[120px]"
|
||||
placeholder={t('ai.chatPlaceholder')}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
|
||||
}}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<div className="flex justify-between items-center px-1 pb-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn("h-7 px-2 text-[10px] gap-1", webSearch ? "bg-emerald-50 text-emerald-600 hover:bg-emerald-100 hover:text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400" : "text-muted-foreground hover:text-foreground")}
|
||||
onClick={() => webSearchAvailable && setWebSearch(!webSearch)}
|
||||
disabled={!webSearchAvailable}
|
||||
title={webSearchAvailable ? t('ai.webSearchLabel') : t('ai.webSearchNotConfigured')}
|
||||
{/* Textarea — only on discussion tab */}
|
||||
<AnimatePresence>
|
||||
{aiTab === 'discussion' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="p-6 bg-white/40 dark:bg-black/20 border-t border-border backdrop-blur-xl shrink-0"
|
||||
>
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
Web{webSearchAvailable ? '' : ' ⚠'}
|
||||
</Button>
|
||||
{isLoading ? (
|
||||
<Button
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-lg bg-red-500 text-white shadow-sm hover:bg-red-600 transition-all"
|
||||
onClick={() => stop()}
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-lg bg-memento-blue text-white shadow-sm hover:shadow-md transition-all"
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim()}
|
||||
>
|
||||
<Send className="h-4 w-4 ms-0.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative group/chat">
|
||||
<textarea
|
||||
rows={4}
|
||||
placeholder={t('ai.chatPlaceholder')}
|
||||
className="w-full bg-white/80 dark:bg-white/5 border border-border rounded-[24px] p-5 pr-14 text-sm outline-none focus:border-brand-accent focus:ring-4 ring-brand-accent/5 transition-all resize-none leading-relaxed font-light shadow-inner text-ink"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
|
||||
}}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<div className="absolute right-4 bottom-4 flex flex-col gap-2">
|
||||
{isLoading ? (
|
||||
<button onClick={() => stop()} className="p-2.5 bg-rose-500 text-white rounded-xl transition-all hover:scale-110 active:scale-95 shadow-lg shadow-rose-500/20">
|
||||
<Square size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={handleSend} disabled={!input.trim()} className="p-2.5 bg-brand-accent text-white rounded-xl transition-all hover:scale-110 active:scale-95 shadow-lg shadow-brand-accent/20 disabled:opacity-40 disabled:pointer-events-none">
|
||||
<Send size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute left-6 bottom-4 flex gap-3 text-concrete/40">
|
||||
<button
|
||||
onClick={() => webSearchAvailable && setWebSearch(!webSearch)}
|
||||
className={cn("hover:text-brand-accent transition-colors", webSearch && "text-brand-accent")}
|
||||
>
|
||||
<Globe size={14} />
|
||||
</button>
|
||||
<button className="hover:text-brand-accent transition-colors"><Network size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center mt-4">
|
||||
<p className="text-[9px] text-concrete/40 uppercase tracking-[0.3em] font-bold">Shift+Enter for new line</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</aside>
|
||||
</motion.aside>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { updateAISettings } from '@/app/actions/ai-settings'
|
||||
import { DemoModeToggle } from '@/components/demo-mode-toggle'
|
||||
import { toast } from 'sonner'
|
||||
import { Loader2, Sparkles, Brain, Languages, Tag, History, Wand2 } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { motion } from 'motion/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AISettingsPanelProps {
|
||||
initialSettings: {
|
||||
@@ -60,6 +59,20 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleHistoryModeChange = async (value: 'manual' | 'auto') => {
|
||||
setSettings(prev => ({ ...prev, noteHistoryMode: value }))
|
||||
try {
|
||||
setIsPending(true)
|
||||
await updateAISettings({ noteHistoryMode: value })
|
||||
toast.success(t('settings.settingsSaved'))
|
||||
} catch {
|
||||
toast.error(t('aiSettings.error'))
|
||||
setSettings(initialSettings)
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDemoModeToggle = async (enabled: boolean) => {
|
||||
setSettings(prev => ({ ...prev, demoMode: enabled }))
|
||||
try {
|
||||
@@ -78,7 +91,7 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
||||
{
|
||||
key: 'titleSuggestions',
|
||||
icon: Wand2,
|
||||
name: t('titleSuggestions.available').replace('💡 ', ''),
|
||||
name: t('aiSettings.titleSuggestions'),
|
||||
description: t('aiSettings.titleSuggestionsDesc'),
|
||||
value: settings.titleSuggestions,
|
||||
},
|
||||
@@ -120,7 +133,11 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-16 pb-20"
|
||||
>
|
||||
{isPending && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
@@ -128,88 +145,118 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feature toggles as cards */}
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground mb-4">{t('aiSettings.features')}</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{features.map(({ key, icon: Icon, name, description, value }) => (
|
||||
<div
|
||||
key={key}
|
||||
className="bg-card rounded-lg border border-border p-5 shadow-sm flex items-start justify-between gap-4"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0 mt-0.5">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">{name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={value}
|
||||
onClick={() => handleToggle(key, !value)}
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary/30 mt-0.5 ${value ? 'bg-primary' : 'bg-muted-foreground/30'}`}
|
||||
<div className="space-y-10">
|
||||
<div className="space-y-6">
|
||||
<h4 className="text-sm font-bold text-ink border-b border-border/40 pb-4">
|
||||
{t('aiSettings.features')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{features.map(({ key, icon: Icon, name, description, value }) => (
|
||||
<div
|
||||
key={key}
|
||||
className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-6 flex items-center justify-between group hover:shadow-xl hover:shadow-brand-accent/5 transition-all duration-300"
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${value ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Memory Echo frequency — shown when enabled */}
|
||||
{settings.memoryEcho && (
|
||||
<div className="bg-card rounded-lg border border-border p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-1">{t('aiSettings.frequency')}</h3>
|
||||
<p className="text-xs text-muted-foreground mb-4">{t('aiSettings.frequencyDesc')}</p>
|
||||
<RadioGroup value={settings.memoryEchoFrequency} onValueChange={handleFrequencyChange} className="space-y-2">
|
||||
{[
|
||||
{ value: 'daily', label: t('aiSettings.frequencyDaily') },
|
||||
{ value: 'weekly', label: t('aiSettings.frequencyWeekly') },
|
||||
].map((opt) => (
|
||||
<div key={opt.value} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={opt.value} id={`freq-${opt.value}`} />
|
||||
<Label htmlFor={`freq-${opt.value}`} className="font-normal text-sm cursor-pointer">{opt.label}</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note History mode — shown when enabled */}
|
||||
{settings.noteHistory && (
|
||||
<div className="bg-card rounded-lg border border-border p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-1">{t('notes.historyMode')}</h3>
|
||||
<RadioGroup
|
||||
value={settings.noteHistoryMode ?? 'manual'}
|
||||
onValueChange={(value) => {
|
||||
const mode = value as 'manual' | 'auto'
|
||||
setSettings(s => ({ ...s, noteHistoryMode: mode }))
|
||||
updateAISettings({ noteHistoryMode: mode }).then(() => toast.success(t('settings.settingsSaved')))
|
||||
}}
|
||||
className="space-y-3 mt-3"
|
||||
>
|
||||
{[
|
||||
{ value: 'manual', label: t('notes.historyModeManual'), desc: t('notes.historyModeManualDesc') },
|
||||
{ value: 'auto', label: t('notes.historyModeAuto'), desc: t('notes.historyModeAutoDesc') },
|
||||
].map((opt) => (
|
||||
<div key={opt.value} className="flex items-start gap-2">
|
||||
<RadioGroupItem value={opt.value} id={`history-${opt.value}`} className="mt-0.5" />
|
||||
<div>
|
||||
<Label htmlFor={`history-${opt.value}`} className="text-sm font-medium cursor-pointer">{opt.label}</Label>
|
||||
<p className="text-xs text-muted-foreground">{opt.desc}</p>
|
||||
<div className="flex items-center gap-5">
|
||||
<div className={cn(
|
||||
'p-3 bg-paper dark:bg-brand-accent/10 rounded-2xl border border-brand-accent/20 transition-all duration-300',
|
||||
'group-hover:bg-brand-accent group-hover:text-white group-hover:scale-110',
|
||||
'text-brand-accent'
|
||||
)}>
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-[13px] font-bold text-ink">{name}</h4>
|
||||
<p className="text-[10px] text-concrete leading-relaxed pr-4 line-clamp-2">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer shrink-0 ml-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={value}
|
||||
onChange={() => handleToggle(key, !value)}
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-brand-accent" />
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Demo Mode */}
|
||||
<DemoModeToggle demoMode={settings.demoMode} onToggle={handleDemoModeToggle} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-6">
|
||||
{settings.memoryEcho && (
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 space-y-10">
|
||||
<div className="space-y-1.5 text-left text-brand-accent">
|
||||
<h4 className="text-sm font-bold">{t('aiSettings.frequency')}</h4>
|
||||
<p className="text-[10px] opacity-60 uppercase tracking-wider font-semibold">
|
||||
{t('aiSettings.frequencyDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{(['daily', 'weekly'] as const).map((opt) => (
|
||||
<label key={opt} className="flex items-center gap-4 cursor-pointer group">
|
||||
<input
|
||||
type="radio"
|
||||
name="freq"
|
||||
className="sr-only peer"
|
||||
checked={settings.memoryEchoFrequency === opt}
|
||||
onChange={() => handleFrequencyChange(opt)}
|
||||
/>
|
||||
<div className="w-5 h-5 rounded-full border-2 border-border peer-checked:border-brand-accent flex items-center justify-center p-0.5 transition-all">
|
||||
<div className={cn(
|
||||
'w-full h-full rounded-full transition-all',
|
||||
settings.memoryEchoFrequency === opt ? 'bg-brand-accent' : 'bg-transparent'
|
||||
)} />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-ink group-hover:opacity-70 transition-opacity">
|
||||
{opt === 'daily' ? t('aiSettings.frequencyDaily') : t('aiSettings.frequencyWeekly')}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{settings.noteHistory && (
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 space-y-10">
|
||||
<div className="space-y-1.5 text-left text-brand-accent">
|
||||
<h4 className="text-sm font-bold">{t('notes.historyMode')}</h4>
|
||||
<p className="text-[10px] opacity-60 uppercase tracking-wider font-semibold">
|
||||
{t('aiSettings.noteHistoryDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{([
|
||||
{ value: 'manual' as const, label: t('notes.historyModeManual'), desc: t('notes.historyModeManualDesc') },
|
||||
{ value: 'auto' as const, label: t('notes.historyModeAuto'), desc: t('notes.historyModeAutoDesc') },
|
||||
]).map((opt) => (
|
||||
<label key={opt.value} className="flex items-center gap-4 cursor-pointer group">
|
||||
<input
|
||||
type="radio"
|
||||
name="hist"
|
||||
className="sr-only peer"
|
||||
checked={(settings.noteHistoryMode ?? 'manual') === opt.value}
|
||||
onChange={() => handleHistoryModeChange(opt.value)}
|
||||
/>
|
||||
<div className="w-5 h-5 rounded-full border-2 border-border peer-checked:border-brand-accent flex items-center justify-center p-0.5 transition-all">
|
||||
<div className={cn(
|
||||
'w-full h-full rounded-full transition-all',
|
||||
(settings.noteHistoryMode ?? 'manual') === opt.value ? 'bg-brand-accent' : 'bg-transparent'
|
||||
)} />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-bold text-ink">{opt.label}</p>
|
||||
<p className="text-[10px] text-concrete">{opt.desc}</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DemoModeToggle demoMode={settings.demoMode} onToggle={handleDemoModeToggle} />
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
277
memento-note/components/ai/byok-settings-panel.tsx
Normal file
277
memento-note/components/ai/byok-settings-panel.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { KeyRound, Loader2, Shield, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type PublicKey = {
|
||||
provider: string
|
||||
alias: string
|
||||
model: string | null
|
||||
isActive: boolean
|
||||
lastUsedAt: string | null
|
||||
}
|
||||
|
||||
async function fetchByokKeys(): Promise<{
|
||||
keys: PublicKey[]
|
||||
allowedProviders: string[]
|
||||
}> {
|
||||
const res = await fetch('/api/user/api-keys')
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.message || body.error || 'Failed to load keys')
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
function providerLabel(t: (key: string) => string, provider: string): string {
|
||||
const key = `byokSettings.providers.${provider}`
|
||||
const translated = t(key)
|
||||
return translated === key ? provider : translated
|
||||
}
|
||||
|
||||
export function ByokSettingsPanel() {
|
||||
const { t } = useLanguage()
|
||||
const queryClient = useQueryClient()
|
||||
const [provider, setProvider] = useState('')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [alias, setAlias] = useState('')
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['user', 'api-keys'],
|
||||
queryFn: fetchByokKeys,
|
||||
})
|
||||
|
||||
const invalidate = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['user', 'api-keys'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['usage', 'current'] })
|
||||
}, [queryClient])
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch('/api/user/api-keys', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider, apiKey, alias: alias || undefined }),
|
||||
})
|
||||
const body = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
throw new Error(body.message || body.error || 'Save failed')
|
||||
}
|
||||
return body
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t('byokSettings.saved'))
|
||||
setApiKey('')
|
||||
setAlias('')
|
||||
setProvider('')
|
||||
invalidate()
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(err.message || t('byokSettings.error'))
|
||||
},
|
||||
})
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
provider: p,
|
||||
isActive,
|
||||
}: {
|
||||
provider: string
|
||||
isActive: boolean
|
||||
}) => {
|
||||
const res = await fetch(`/api/user/api-keys/${encodeURIComponent(p)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isActive }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Update failed')
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
onError: () => toast.error(t('byokSettings.error')),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (p: string) => {
|
||||
const res = await fetch(`/api/user/api-keys/${encodeURIComponent(p)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!res.ok) throw new Error('Delete failed')
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t('byokSettings.deleted'))
|
||||
invalidate()
|
||||
},
|
||||
onError: () => toast.error(t('byokSettings.error')),
|
||||
})
|
||||
|
||||
const allowed = data?.allowedProviders ?? []
|
||||
const keys = data?.keys ?? []
|
||||
const hasActiveByok = keys.some((k) => k.isActive)
|
||||
const tierBlocked = !isLoading && allowed.length === 0
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground p-5">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('byokSettings.loading')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<p className="text-sm text-destructive p-5">{t('byokSettings.loadError')}</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id="byok"
|
||||
className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 scroll-mt-6 space-y-6"
|
||||
>
|
||||
<div className="flex items-start gap-5">
|
||||
<div className="p-3 bg-violet-500/10 rounded-2xl text-violet-500 border border-violet-500/20">
|
||||
<KeyRound size={18} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-[13px] font-bold text-ink flex items-center gap-2 flex-wrap">
|
||||
{t('byokSettings.title')}
|
||||
{hasActiveByok && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 px-2 py-0.5 text-[10px] font-medium">
|
||||
<Shield size={10} />
|
||||
{t('byokSettings.badgeActive')}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-[10px] text-concrete leading-relaxed">{t('byokSettings.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tierBlocked ? (
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400 bg-amber-500/10 rounded-xl px-4 py-3">
|
||||
{t('byokSettings.tierRequired')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4 border border-border/60 rounded-2xl p-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="byok-provider" className="text-[10px] font-bold uppercase tracking-widest text-concrete">{t('byokSettings.provider')}</Label>
|
||||
<Select
|
||||
value={provider}
|
||||
onValueChange={setProvider}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
<SelectTrigger id="byok-provider">
|
||||
<SelectValue placeholder={t('byokSettings.providerPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allowed.map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
{providerLabel(t, p)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="byok-alias" className="text-[10px] font-bold uppercase tracking-widest text-concrete">{t('byokSettings.alias')}</Label>
|
||||
<Input
|
||||
id="byok-alias"
|
||||
value={alias}
|
||||
onChange={(e) => setAlias(e.target.value)}
|
||||
placeholder={t('byokSettings.aliasPlaceholder')}
|
||||
disabled={saveMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="byok-key" className="text-[10px] font-bold uppercase tracking-widest text-concrete">{t('byokSettings.apiKey')}</Label>
|
||||
<Input
|
||||
id="byok-key"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={t('byokSettings.apiKeyPlaceholder')}
|
||||
disabled={saveMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!provider || apiKey.length < 8 || saveMutation.isPending}
|
||||
onClick={() => saveMutation.mutate()}
|
||||
className={cn(
|
||||
'px-6 py-3 rounded-2xl text-[10px] font-bold uppercase tracking-[0.2em] transition-all duration-300',
|
||||
'bg-ink text-paper shadow-xl shadow-ink/20 hover:scale-[1.02] active:scale-95',
|
||||
'disabled:opacity-40 disabled:pointer-events-none disabled:shadow-none'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{saveMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{t('byokSettings.save')}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{keys.length > 0 && (
|
||||
<ul className="space-y-3">
|
||||
{keys.map((key) => (
|
||||
<li
|
||||
key={key.provider}
|
||||
className="flex items-center justify-between gap-3 rounded-2xl border border-border/60 px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13px] font-bold text-ink">{providerLabel(t, key.provider)}</div>
|
||||
{key.alias ? (
|
||||
<p className="text-[10px] text-concrete truncate">{key.alias}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={key.isActive}
|
||||
onChange={(e) => toggleMutation.mutate({ provider: key.provider, isActive: e.target.checked })}
|
||||
disabled={toggleMutation.isPending}
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-brand-accent" />
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 w-8 rounded-lg flex items-center justify-center text-destructive/60 hover:text-destructive hover:bg-rose-50 dark:hover:bg-rose-500/10 transition-colors"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => {
|
||||
if (confirm(t('byokSettings.confirmDelete'))) {
|
||||
deleteMutation.mutate(key.provider)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!tierBlocked && keys.length === 0 && (
|
||||
<p className="text-[11px] text-concrete">{t('byokSettings.empty')}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { PageEntry } from '@/components/page-entry'
|
||||
import dynamic from 'next/dynamic'
|
||||
import type { Note } from '@/lib/types'
|
||||
import { NotesEditorialView } from '@/components/notes-editorial-view'
|
||||
@@ -40,9 +41,11 @@ export function ArchiveClient({ notes }: ArchiveClientProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<PageEntry>
|
||||
<NotesEditorialView
|
||||
notes={notes}
|
||||
onOpen={handleOpen}
|
||||
/>
|
||||
</PageEntry>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -134,8 +134,8 @@ export function AutoLabelSuggestionDialog({
|
||||
<DialogTitle className="sr-only">{t('ai.autoLabels.analyzing')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<div className="w-16 h-16 rounded-full border border-dashed border-memento-blue/20 flex items-center justify-center mb-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-memento-blue" />
|
||||
<div className="w-16 h-16 rounded-full border border-dashed border-brand-accent/20 flex items-center justify-center mb-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-brand-accent" />
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-widest font-bold">
|
||||
{t('ai.autoLabels.analyzing')}
|
||||
@@ -155,7 +155,7 @@ export function AutoLabelSuggestionDialog({
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-memento-blue" />
|
||||
<Sparkles className="h-5 w-5 text-brand-accent" />
|
||||
{t('ai.autoLabels.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -175,7 +175,7 @@ export function AutoLabelSuggestionDialog({
|
||||
className={cn(
|
||||
"flex items-start gap-3 p-3 rounded-xl border cursor-pointer transition-all",
|
||||
isSelected
|
||||
? "bg-memento-blue/5 border-memento-blue/30 hover:bg-memento-blue/10"
|
||||
? "bg-brand-accent/5 border-brand-accent/30 hover:bg-brand-accent/10"
|
||||
: "border-border hover:bg-muted/50"
|
||||
)}
|
||||
onClick={() => toggleLabelSelection(label.name)}
|
||||
@@ -183,22 +183,22 @@ export function AutoLabelSuggestionDialog({
|
||||
<div className={cn(
|
||||
"w-5 h-5 rounded-full border-2 flex items-center justify-center mt-0.5 transition-all shrink-0",
|
||||
isSelected
|
||||
? "bg-memento-blue border-memento-blue"
|
||||
? "bg-brand-accent border-brand-accent"
|
||||
: "border-border"
|
||||
)}>
|
||||
{isSelected && <CheckCircle2 className="h-3.5 w-3.5 text-white" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="h-3.5 w-3.5 text-memento-blue/60" />
|
||||
<Tag className="h-3.5 w-3.5 text-brand-accent/60" />
|
||||
<span className="font-medium text-sm">{label.name}</span>
|
||||
<Sparkles className="h-3 w-3 text-memento-blue/40" />
|
||||
<Sparkles className="h-3 w-3 text-brand-accent/40" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1.5">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t('ai.autoLabels.notesCount', { count: label.count })}
|
||||
</span>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-memento-blue/10 text-memento-blue font-bold">
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-brand-accent/10 text-brand-accent font-bold">
|
||||
{Math.round(label.confidence * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
@@ -219,7 +219,7 @@ export function AutoLabelSuggestionDialog({
|
||||
<button
|
||||
onClick={handleCreateLabels}
|
||||
disabled={selectedLabels.size === 0 || creating}
|
||||
className="flex-1 py-3 bg-memento-blue text-white rounded-xl text-[10px] font-bold uppercase tracking-widest hover:opacity-90 transition-all shadow-lg shadow-memento-blue/20 disabled:opacity-50"
|
||||
className="flex-1 py-3 bg-brand-accent text-white rounded-xl text-[10px] font-bold uppercase tracking-widest hover:opacity-90 transition-all shadow-lg shadow-brand-accent/20 disabled:opacity-50"
|
||||
>
|
||||
{creating ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
|
||||
@@ -14,7 +14,7 @@ interface ActivityFeedProps {
|
||||
|
||||
function getActionIcon(action: string) {
|
||||
switch (action) {
|
||||
case 'manual_idea': return <Lightbulb size={12} className="text-memento-blue" />
|
||||
case 'manual_idea': return <Lightbulb size={12} className="text-brand-accent" />
|
||||
case 'wave_generated': return <Zap size={12} className="text-orange-500" />
|
||||
case 'joined': return <UserPlus size={12} className="text-emerald-500" />
|
||||
case 'idea_dismissed': return <X size={12} className="text-rose-500" />
|
||||
|
||||
@@ -3,11 +3,18 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { BrainstormSession, BrainstormIdea } from '@/types/brainstorm'
|
||||
import { useExpandIdea, useDismissIdea, useConvertIdea, useExportBrainstorm } from '@/hooks/use-brainstorm'
|
||||
import {
|
||||
useExpandIdea,
|
||||
useDismissIdea,
|
||||
useConvertIdea,
|
||||
useExportBrainstorm,
|
||||
brainstormQuotaMessageKey,
|
||||
} from '@/hooks/use-brainstorm'
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Sparkles, X, FileText, Download, ChevronDown } from 'lucide-react'
|
||||
import { Sparkles, X, FileText, Download } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), {
|
||||
ssr: false,
|
||||
@@ -58,6 +65,7 @@ interface BrainstormCanvasProps {
|
||||
}
|
||||
|
||||
export function BrainstormCanvas({ session }: BrainstormCanvasProps) {
|
||||
const { t } = useLanguage()
|
||||
const fgRef = useRef<any>(null)
|
||||
const [selectedIdea, setSelectedIdea] = useState<BrainstormIdea | null>(null)
|
||||
const [isSheetOpen, setIsSheetOpen] = useState(false)
|
||||
@@ -83,7 +91,7 @@ export function BrainstormCanvas({ session }: BrainstormCanvasProps) {
|
||||
sessionId: session.id,
|
||||
waveNumber: 0,
|
||||
title: session.seedIdea,
|
||||
description: 'Original seed idea',
|
||||
description: t('brainstorm.originalSeedDescription'),
|
||||
connectionToSeed: null,
|
||||
noveltyScore: null,
|
||||
parentIdeaId: null,
|
||||
@@ -121,7 +129,7 @@ export function BrainstormCanvas({ session }: BrainstormCanvasProps) {
|
||||
}
|
||||
|
||||
return { graphData: { nodes, links } }
|
||||
}, [session])
|
||||
}, [session, t])
|
||||
|
||||
const handleNodeClick = useCallback((node: any) => {
|
||||
setSelectedIdea(node.idea)
|
||||
@@ -131,12 +139,13 @@ export function BrainstormCanvas({ session }: BrainstormCanvasProps) {
|
||||
const handleExpand = useCallback(async () => {
|
||||
if (!selectedIdea || selectedIdea.id === 'seed') return
|
||||
try {
|
||||
await expandIdea.mutateAsync(selectedIdea.id)
|
||||
toast.success('Ideas expanded!')
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to expand')
|
||||
await expandIdea.mutateAsync({ ideaId: selectedIdea.id })
|
||||
toast.success(t('brainstorm.toastExpandSuccess'))
|
||||
} catch (err: unknown) {
|
||||
const quotaKey = brainstormQuotaMessageKey(err)
|
||||
toast.error(quotaKey ? t(quotaKey) : (err instanceof Error ? err.message : t('brainstorm.toastExpandFailed')))
|
||||
}
|
||||
}, [selectedIdea, expandIdea])
|
||||
}, [selectedIdea, expandIdea, t])
|
||||
|
||||
const handleDismiss = useCallback(async () => {
|
||||
if (!selectedIdea || selectedIdea.id === 'seed') return
|
||||
@@ -144,30 +153,30 @@ export function BrainstormCanvas({ session }: BrainstormCanvasProps) {
|
||||
await dismissIdea.mutateAsync(selectedIdea.id)
|
||||
setIsSheetOpen(false)
|
||||
setSelectedIdea(null)
|
||||
toast.success('Idea dismissed')
|
||||
toast.success(t('brainstorm.toastDismissSuccess'))
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to dismiss')
|
||||
toast.error(err.message || t('brainstorm.toastDismissFailed'))
|
||||
}
|
||||
}, [selectedIdea, dismissIdea])
|
||||
}, [selectedIdea, dismissIdea, t])
|
||||
|
||||
const handleConvert = useCallback(async () => {
|
||||
if (!selectedIdea || selectedIdea.id === 'seed') return
|
||||
try {
|
||||
await convertIdea.mutateAsync(selectedIdea.id)
|
||||
toast.success('Idea converted to note!')
|
||||
toast.success(t('brainstorm.toastConvertSuccess'))
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to convert')
|
||||
toast.error(err.message || t('brainstorm.toastConvertFailed'))
|
||||
}
|
||||
}, [selectedIdea, convertIdea])
|
||||
}, [selectedIdea, convertIdea, t])
|
||||
|
||||
const handleExport = useCallback(async () => {
|
||||
try {
|
||||
const note = await exportBrainstorm.mutateAsync()
|
||||
toast.success('Exported as note!')
|
||||
toast.success(t('brainstorm.toastExportNoteSuccess'))
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to export')
|
||||
toast.error(err.message || t('brainstorm.toastExportFailed'))
|
||||
}
|
||||
}, [exportBrainstorm])
|
||||
}, [exportBrainstorm, t])
|
||||
|
||||
const paintNode = useCallback((node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
const label = node.name
|
||||
@@ -211,12 +220,15 @@ export function BrainstormCanvas({ session }: BrainstormCanvasProps) {
|
||||
ctx.globalAlpha = 1
|
||||
}, [])
|
||||
|
||||
const waveLegend = [
|
||||
{ label: 'Seed', color: WAVE_COLORS[0] },
|
||||
{ label: 'Variations', color: WAVE_COLORS[1] },
|
||||
{ label: 'Analogies', color: WAVE_COLORS[2] },
|
||||
{ label: 'Disruptions', color: WAVE_COLORS[3] },
|
||||
]
|
||||
const waveLegend = useMemo(
|
||||
() => [
|
||||
{ label: t('brainstorm.legendSeed'), color: WAVE_COLORS[0] },
|
||||
{ label: t('brainstorm.legendVariations'), color: WAVE_COLORS[1] },
|
||||
{ label: t('brainstorm.legendAnalogies'), color: WAVE_COLORS[2] },
|
||||
{ label: t('brainstorm.legendDisruptions'), color: WAVE_COLORS[3] },
|
||||
],
|
||||
[t]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full bg-zinc-950 rounded-xl overflow-hidden">
|
||||
@@ -264,7 +276,7 @@ export function BrainstormCanvas({ session }: BrainstormCanvasProps) {
|
||||
disabled={exportBrainstorm.isPending}
|
||||
>
|
||||
<Download size={14} className="mr-1" />
|
||||
{exportBrainstorm.isPending ? 'Exporting...' : 'Export'}
|
||||
{exportBrainstorm.isPending ? t('brainstorm.exporting') : t('brainstorm.export')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -280,26 +292,26 @@ export function BrainstormCanvas({ session }: BrainstormCanvasProps) {
|
||||
<div className="mt-6 space-y-4">
|
||||
{selectedIdea.description && selectedIdea.id !== 'seed' && (
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">Description</p>
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">{t('brainstorm.ideaDetailDescription')}</p>
|
||||
<p className="text-sm text-zinc-300">{selectedIdea.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedIdea.connectionToSeed && (
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">Connection</p>
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">{t('brainstorm.ideaDetailConnection')}</p>
|
||||
<p className="text-sm text-zinc-300">{selectedIdea.connectionToSeed}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedIdea.noveltyScore && (
|
||||
{(selectedIdea.noveltyScore != null && selectedIdea.noveltyScore > 0) && (
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">Novelty</p>
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">{t('brainstorm.ideaDetailNovelty')}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-2 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-orange-500 via-memento-blue to-purple-500 rounded-full"
|
||||
style={{ width: `${selectedIdea.noveltyScore * 10}%` }}
|
||||
className="h-full bg-gradient-to-r from-orange-500 via-brand-accent to-purple-500 rounded-full"
|
||||
style={{ width: `${Math.min(100, Math.max(0, selectedIdea.noveltyScore * 10))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-zinc-400">{selectedIdea.noveltyScore}/10</span>
|
||||
@@ -309,7 +321,7 @@ export function BrainstormCanvas({ session }: BrainstormCanvasProps) {
|
||||
|
||||
{selectedIdea.waveNumber > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">Wave</p>
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">{t('brainstorm.ideaDetailWave')}</p>
|
||||
<span
|
||||
className="inline-block px-2 py-0.5 rounded text-xs font-medium"
|
||||
style={{
|
||||
@@ -317,7 +329,11 @@ export function BrainstormCanvas({ session }: BrainstormCanvasProps) {
|
||||
color: WAVE_COLORS[selectedIdea.waveNumber],
|
||||
}}
|
||||
>
|
||||
{selectedIdea.waveNumber === 1 ? 'Variation' : selectedIdea.waveNumber === 2 ? 'Analogy' : 'Disruption'}
|
||||
{selectedIdea.waveNumber === 1
|
||||
? t('brainstorm.waveFlavorVariation')
|
||||
: selectedIdea.waveNumber === 2
|
||||
? t('brainstorm.waveFlavorAnalogy')
|
||||
: t('brainstorm.waveFlavorDisruption')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -326,7 +342,7 @@ export function BrainstormCanvas({ session }: BrainstormCanvasProps) {
|
||||
<div className="p-3 bg-green-500/10 border border-green-500/20 rounded-lg">
|
||||
<p className="text-xs text-green-400 flex items-center gap-1">
|
||||
<FileText size={12} />
|
||||
Converted to note
|
||||
{t('brainstorm.convertedToNoteStatus')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -339,15 +355,15 @@ export function BrainstormCanvas({ session }: BrainstormCanvasProps) {
|
||||
className="w-full bg-orange-600 hover:bg-orange-700 text-white"
|
||||
>
|
||||
<Sparkles size={14} className="mr-1" />
|
||||
{expandIdea.isPending ? 'Generating...' : 'Dig Deeper'}
|
||||
{expandIdea.isPending ? t('brainstorm.deepening') : t('brainstorm.deepen')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConvert}
|
||||
disabled={convertIdea.isPending}
|
||||
className="w-full bg-memento-blue hover:bg-blue-700 text-white"
|
||||
className="w-full bg-brand-accent hover:bg-blue-700 text-white"
|
||||
>
|
||||
<FileText size={14} className="mr-1" />
|
||||
{convertIdea.isPending ? 'Converting...' : 'Create Note'}
|
||||
{convertIdea.isPending ? t('brainstorm.converting') : t('brainstorm.extract')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDismiss}
|
||||
@@ -356,7 +372,7 @@ export function BrainstormCanvas({ session }: BrainstormCanvasProps) {
|
||||
className="w-full border-zinc-700 text-zinc-400 hover:text-white hover:bg-zinc-800"
|
||||
>
|
||||
<X size={14} className="mr-1" />
|
||||
Dismiss
|
||||
{t('brainstorm.dismiss')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -60,7 +60,7 @@ function CursorTrackerEffect({ containerRef, moveCursor }: { containerRef: React
|
||||
|
||||
const WAVE_COLORS: Record<number, { border: string; bg: string; text: string }> = {
|
||||
1: { border: 'border-orange-200', bg: 'bg-orange-50', text: 'text-orange-600' },
|
||||
2: { border: 'border-memento-blue', bg: 'bg-memento-blue', text: 'text-memento-blue' },
|
||||
2: { border: 'border-brand-accent', bg: 'bg-brand-accent', text: 'text-brand-accent' },
|
||||
3: { border: 'border-violet-200', bg: 'bg-violet-50', text: 'text-violet-600' },
|
||||
}
|
||||
|
||||
@@ -224,8 +224,8 @@ export function BrainstormPage() {
|
||||
try {
|
||||
const result = await exportBrainstorm.mutateAsync()
|
||||
if (result?.id) {
|
||||
const notebookName = result._notebookName || 'Brainstorm'
|
||||
setExportToast({ noteTitle: result.title || 'Synthèse', notebookName })
|
||||
const notebookName = result._notebookName || t('brainstorm.exportDefaultNotebookName')
|
||||
setExportToast({ noteTitle: result.title || t('brainstorm.exportDefaultNoteTitle'), notebookName })
|
||||
setTimeout(() => {
|
||||
setExportToast(null)
|
||||
router.push(`/?openNote=${result.id}`)
|
||||
@@ -238,7 +238,7 @@ export function BrainstormPage() {
|
||||
setTimeout(() => setImpactToast(null), 5000)
|
||||
}
|
||||
} catch (err: any) {
|
||||
setExportError(err?.message || 'Export failed')
|
||||
setExportError(err?.message || t('brainstorm.exportFailedMessage'))
|
||||
setTimeout(() => setExportError(null), 4000)
|
||||
}
|
||||
}
|
||||
@@ -283,7 +283,7 @@ export function BrainstormPage() {
|
||||
duration: 20,
|
||||
ease: 'linear',
|
||||
}}
|
||||
className="w-14 h-14 rounded-2xl bg-orange-500 shadow-[0_0_20px_rgba(249,115,22,0.2)] flex items-center justify-center text-white"
|
||||
className="w-14 h-14 rounded-2xl bg-brand-accent shadow-[0_0_20px_rgba(164,113,72,0.2)] flex items-center justify-center text-white"
|
||||
>
|
||||
<Wind size={28} />
|
||||
</motion.div>
|
||||
@@ -292,7 +292,7 @@ export function BrainstormPage() {
|
||||
{t('brainstorm.title') || 'Waves of Thought'}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="w-8 h-px bg-orange-400/40" />
|
||||
<span className="w-8 h-px bg-brand-accent/40" />
|
||||
<p className="text-[10px] text-muted-foreground tracking-[0.3em] uppercase font-bold">
|
||||
{t('brainstorm.subtitle') || 'Unfold dimensions of potentiality'}
|
||||
</p>
|
||||
@@ -304,7 +304,7 @@ export function BrainstormPage() {
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={exportBrainstorm.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-white/5 border border-border rounded-xl text-xs font-bold uppercase tracking-widest text-muted-foreground hover:text-orange-500 transition-all shadow-sm disabled:opacity-50"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-white/5 border border-border rounded-xl text-xs font-bold uppercase tracking-widest text-muted-foreground hover:text-brand-accent transition-all shadow-sm disabled:opacity-50"
|
||||
title={t('brainstorm.export') || 'Export'}
|
||||
>
|
||||
<Download size={14} />
|
||||
@@ -325,18 +325,18 @@ export function BrainstormPage() {
|
||||
<span className="hidden sm:inline">{t('brainstorm.invite') || 'Invite'}</span>
|
||||
</button>
|
||||
<div className="flex items-center px-3 py-1.5 bg-white dark:bg-white/5 border border-border rounded-xl shadow-sm transition-all hover:border-emerald-500/30">
|
||||
<div className="flex items-center gap-2 mr-3" title="Live Collaboration">
|
||||
<div className="flex items-center gap-2 mr-3" title={t('brainstorm.liveCollaborationTitle')}>
|
||||
<div className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.6)]"></span>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-emerald-600 dark:text-emerald-400 hidden sm:inline-block">Live</span>
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-emerald-600 dark:text-emerald-400 hidden sm:inline-block">{t('brainstorm.liveStatus')}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center group/avatars">
|
||||
{authSession?.user?.name && (
|
||||
<div
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold text-white ring-2 ring-white dark:ring-[#1A1A1A] shadow-sm bg-memento-blue z-10 transition-transform hover:scale-110 hover:z-20"
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold text-white ring-2 ring-white dark:ring-[#1A1A1A] shadow-sm bg-brand-accent z-10 transition-transform hover:scale-110 hover:z-20"
|
||||
title={`${authSession.user.name} (You)`}
|
||||
>
|
||||
{authSession.user.name.charAt(0).toUpperCase()}
|
||||
@@ -368,19 +368,19 @@ export function BrainstormPage() {
|
||||
</div>
|
||||
|
||||
<div className="relative group">
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-orange-500/20 to-memento-blue/20 rounded-[28px] blur-xl opacity-0 group-focus-within:opacity-100 transition-opacity duration-700" />
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-brand-accent/20 to-brand-accent/10 rounded-[28px] blur-xl opacity-0 group-focus-within:opacity-100 transition-opacity duration-700" />
|
||||
<input
|
||||
type="text"
|
||||
value={seedInput}
|
||||
onChange={(e) => setSeedInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleStartBrainstorm()}
|
||||
placeholder={t('brainstorm.placeholder') || 'Enter a concept to unfold...'}
|
||||
className="w-full relative bg-white dark:bg-[#1A1A1A] border-2 rounded-2xl px-8 py-7 pr-20 outline-none transition-all text-2xl font-serif italic text-foreground shadow-sm group-hover:shadow-md border-border/40 focus:border-orange-400/40 focus:ring-4 focus:ring-orange-500/5"
|
||||
className="w-full relative bg-white dark:bg-[#1A1A1A] border-2 rounded-2xl px-8 py-7 pr-20 outline-none transition-all text-2xl font-serif italic text-foreground shadow-sm group-hover:shadow-md border-border/40 focus:border-brand-accent/40 focus:ring-4 focus:ring-brand-accent/5"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleStartBrainstorm()}
|
||||
disabled={isGenerating || !seedInput.trim()}
|
||||
className="absolute right-4 top-4 bottom-4 px-6 bg-foreground dark:bg-orange-500 text-background rounded-xl disabled:opacity-50 transition-all hover:scale-[1.02] active:scale-[0.98] flex items-center justify-center gap-2 min-w-[70px] shadow-lg"
|
||||
className="absolute right-4 top-4 bottom-4 px-6 bg-foreground dark:bg-brand-accent text-background rounded-xl disabled:opacity-50 transition-all hover:scale-[1.02] active:scale-[0.98] flex items-center justify-center gap-2 min-w-[70px] shadow-lg"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<div className="w-6 h-6 border-3 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
@@ -395,7 +395,7 @@ export function BrainstormPage() {
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mt-6 flex items-center gap-4 text-orange-500/80 italic font-serif"
|
||||
className="mt-6 flex items-center gap-4 text-brand-accent/80 italic font-serif"
|
||||
>
|
||||
<div className="flex gap-1.5">
|
||||
{[0.2, 0.4, 0.6].map((d, i) => (
|
||||
@@ -403,7 +403,7 @@ export function BrainstormPage() {
|
||||
key={i}
|
||||
animate={{ scale: [1, 1.5, 1], opacity: [0.3, 1, 0.3] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity, delay: d }}
|
||||
className="w-1.5 h-1.5 rounded-full bg-orange-500"
|
||||
className="w-1.5 h-1.5 rounded-full bg-brand-accent"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -444,7 +444,7 @@ export function BrainstormPage() {
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none opacity-20 flex-col gap-6">
|
||||
<Wind size={120} strokeWidth={1} className="text-muted-foreground animate-pulse" />
|
||||
<p className="text-xl font-serif italic text-muted-foreground">
|
||||
The canvas is waiting for your spark...
|
||||
{t('brainstorm.canvasWaitingHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -467,7 +467,7 @@ export function BrainstormPage() {
|
||||
}}
|
||||
/>
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
Wave {w}
|
||||
{t('brainstorm.waveBadge', { wave: w })}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -479,7 +479,7 @@ export function BrainstormPage() {
|
||||
className="px-6 py-3 bg-[#F4F1EA] dark:bg-black/60 backdrop-blur-xl border border-border shadow-xl rounded-full flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-muted-foreground hover:bg-foreground hover:text-background transition-all"
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t('brainstorm.addManualIdea') || 'Add Manual Idea'}
|
||||
{t('brainstorm.addIdea')}
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
@@ -519,7 +519,7 @@ export function BrainstormPage() {
|
||||
selectedIdea.waveNumber === 1
|
||||
? 'border-orange-300 dark:border-orange-700 bg-orange-50 dark:bg-orange-500/15 text-orange-600 dark:text-orange-400'
|
||||
: selectedIdea.waveNumber === 2
|
||||
? 'border-memento-blue dark:border-blue-700 bg-memento-blue dark:bg-memento-blue/15 text-memento-blue dark:text-memento-blue'
|
||||
? 'border-brand-accent dark:border-blue-700 bg-brand-accent dark:bg-brand-accent/15 text-brand-accent dark:text-brand-accent'
|
||||
: 'border-violet-300 dark:border-violet-700 bg-violet-50 dark:bg-violet-500/15 text-violet-600 dark:text-violet-400'
|
||||
}`}
|
||||
>
|
||||
@@ -546,18 +546,18 @@ export function BrainstormPage() {
|
||||
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="flex items-center gap-1">
|
||||
<Zap size={14} className="text-orange-500" />
|
||||
<Zap size={14} className="text-brand-accent" />
|
||||
<span className="text-xs font-bold text-muted-foreground">
|
||||
{t('brainstorm.novelty') || 'Novelty'}: {selectedIdea.noveltyScore || 'N/A'}/10
|
||||
{t('brainstorm.novelty')}: {selectedIdea.noveltyScore ?? t('common.notAvailable')}/10
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{selectedIdea.createdByType === 'human' ? (
|
||||
<>
|
||||
<div className="w-4 h-4 rounded-full bg-memento-blue flex items-center justify-center text-[8px] font-bold text-white">
|
||||
<div className="w-4 h-4 rounded-full bg-brand-accent flex items-center justify-center text-[8px] font-bold text-white">
|
||||
{(selectedIdea as any).creator?.name?.charAt(0)?.toUpperCase() || 'U'}
|
||||
</div>
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-memento-blue">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-brand-accent">
|
||||
{t('brainstorm.humanIdea') || 'Human'}
|
||||
</span>
|
||||
</>
|
||||
@@ -615,13 +615,13 @@ export function BrainstormPage() {
|
||||
</span>
|
||||
{isRestricted && !isGuest && (
|
||||
<span className="shrink-0 px-1.5 py-0.5 rounded-full text-[8px] font-bold uppercase tracking-wider border border-amber-200 dark:border-amber-700 bg-amber-500/10 text-amber-600 dark:text-amber-400 flex items-center gap-0.5">
|
||||
<Lock size={8} /> Owner
|
||||
<Lock size={8} /> {t('brainstorm.ownerBadge')}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
{ref.note ? (
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{ref.note.title || 'Untitled'}
|
||||
{ref.note.title || t('notes.untitled')}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm italic text-muted-foreground">
|
||||
@@ -653,9 +653,9 @@ export function BrainstormPage() {
|
||||
<button
|
||||
onClick={() => handleDeepen(selectedIdea)}
|
||||
disabled={expandIdea.isPending}
|
||||
className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-border rounded-2xl hover:border-orange-400/40 hover:bg-orange-500/5 transition-all group disabled:opacity-50"
|
||||
className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-border rounded-2xl hover:border-brand-accent/40 hover:bg-brand-accent/5 transition-all group disabled:opacity-50"
|
||||
>
|
||||
<Wind size={24} className="text-muted-foreground group-hover:text-orange-500 mb-2" />
|
||||
<Wind size={24} className="text-muted-foreground group-hover:text-brand-accent mb-2" />
|
||||
<span className="text-[11px] font-bold uppercase tracking-widest text-muted-foreground group-hover:text-foreground">
|
||||
{expandIdea.isPending ? (t('brainstorm.deepening') || 'Generating...') : (t('brainstorm.deepen') || 'Deepen')}
|
||||
</span>
|
||||
@@ -682,10 +682,10 @@ export function BrainstormPage() {
|
||||
)}
|
||||
|
||||
{isGuest && (
|
||||
<div className="mt-6 px-4 py-3 bg-orange-500/5 border border-orange-500/10 rounded-xl flex items-center gap-2">
|
||||
<Globe size={14} className="text-orange-500 shrink-0" />
|
||||
<span className="text-[11px] text-orange-600 dark:text-orange-400">
|
||||
Vous consultez ce brainstorm en tant qu'invité. Connectez-vous pour modifier.
|
||||
<div className="mt-6 px-4 py-3 bg-brand-accent/5 border border-brand-accent/10 rounded-xl flex items-center gap-2">
|
||||
<Globe size={14} className="text-brand-accent shrink-0" />
|
||||
<span className="text-[11px] text-brand-accent">
|
||||
{t('brainstorm.guestReadOnlyNotice')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -706,7 +706,7 @@ export function BrainstormPage() {
|
||||
activeSessionId === s.id
|
||||
? 'bg-foreground text-background scale-110 shadow-lg'
|
||||
: (s as any)._owned === false
|
||||
? 'bg-memento-blue dark:bg-memento-blue/10 text-memento-blue hover:bg-blue-100 hover:text-memento-blue'
|
||||
? 'bg-brand-accent dark:bg-brand-accent/10 text-brand-accent hover:bg-blue-100 hover:text-brand-accent'
|
||||
: 'bg-white dark:bg-white/10 text-muted-foreground hover:bg-foreground/5 hover:text-foreground'
|
||||
}`}
|
||||
title={s.seedIdea}
|
||||
@@ -740,11 +740,11 @@ export function BrainstormPage() {
|
||||
>
|
||||
<span>
|
||||
{impactToast.notesEnriched === 0 && impactToast.notesMarkedDry === 0
|
||||
? (t('brainstorm.linkCopied') || 'Invite link copied!')
|
||||
? t('brainstorm.linkCopied')
|
||||
: <>
|
||||
{impactToast.notesEnriched > 0 && `${impactToast.notesEnriched} note(s) enriched`}
|
||||
{impactToast.notesEnriched > 0 && impactToast.notesMarkedDry > 0 && ' · '}
|
||||
{impactToast.notesMarkedDry > 0 && `${impactToast.notesMarkedDry} note(s) marked dry`}
|
||||
{impactToast.notesEnriched > 0 && t('brainstorm.impactNotesEnriched', { count: impactToast.notesEnriched })}
|
||||
{impactToast.notesEnriched > 0 && impactToast.notesMarkedDry > 0 && t('brainstorm.impactSeparator')}
|
||||
{impactToast.notesMarkedDry > 0 && t('brainstorm.impactNotesMarkedDry', { count: impactToast.notesMarkedDry })}
|
||||
</>
|
||||
}
|
||||
</span>
|
||||
@@ -782,11 +782,13 @@ export function BrainstormPage() {
|
||||
<Wind size={18} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold">{exportToast.noteTitle}</span>
|
||||
<span className="text-[11px] text-emerald-100">Carnet : {exportToast.notebookName}</span>
|
||||
<span className="text-[11px] text-emerald-100">
|
||||
{t('brainstorm.exportNotebookPrefix')} {exportToast.notebookName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-3 flex items-center gap-1.5 text-emerald-200 text-[10px]">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-emerald-300 animate-pulse" />
|
||||
Ouverture…
|
||||
{t('brainstorm.exportOpening')}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -807,7 +809,7 @@ export function BrainstormPage() {
|
||||
</div>
|
||||
<div className="ml-3 flex items-center gap-1.5 text-emerald-200 text-[10px]">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-emerald-300 animate-pulse" />
|
||||
Ouverture…
|
||||
{t('brainstorm.exportOpening')}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { UserPlus, Check, AlertCircle, Globe, Lock, Copy } from 'lucide-react'
|
||||
import { createBrainstormShare } from '@/app/actions/brainstorm'
|
||||
import { useUpdateBrainstormSettings } from '@/hooks/use-brainstorm'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface BrainstormShareDialogProps {
|
||||
open: boolean
|
||||
@@ -28,15 +29,15 @@ interface UserResult {
|
||||
image: string | null
|
||||
}
|
||||
|
||||
const MESSAGES: Record<string, { text: string; type: 'success' | 'info' }> = {
|
||||
invited: { text: 'Invitation envoyée !', type: 'success' },
|
||||
re_invited: { text: 'Invitation renvoyée !', type: 'success' },
|
||||
const FEEDBACK_BY_MESSAGE: Record<string, { key: string; type: 'success' | 'info' }> = {
|
||||
invited: { key: 'brainstorm.feedbackInviteSent', type: 'success' },
|
||||
re_invited: { key: 'brainstorm.feedbackInviteResent', type: 'success' },
|
||||
already_shared: {
|
||||
text: 'Cette personne a déjà accès à ce brainstorm.',
|
||||
key: 'brainstorm.feedbackAlreadyShared',
|
||||
type: 'info',
|
||||
},
|
||||
already_pending: {
|
||||
text: 'Une invitation est déjà en attente pour cette personne.',
|
||||
key: 'brainstorm.feedbackAlreadyPending',
|
||||
type: 'info',
|
||||
},
|
||||
}
|
||||
@@ -49,6 +50,7 @@ export function BrainstormShareDialog({
|
||||
isPublic = false,
|
||||
guestCanEdit = false,
|
||||
}: BrainstormShareDialogProps) {
|
||||
const { t } = useLanguage()
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<UserResult[]>([])
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
@@ -115,17 +117,17 @@ export function BrainstormShareDialog({
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await createBrainstormShare(sessionId, query.trim())
|
||||
if (result.message && MESSAGES[result.message]) {
|
||||
const m = MESSAGES[result.message]
|
||||
setFeedback({ text: m.text, type: m.type })
|
||||
if (result.message && FEEDBACK_BY_MESSAGE[result.message]) {
|
||||
const m = FEEDBACK_BY_MESSAGE[result.message]
|
||||
setFeedback({ text: t(m.key), type: m.type })
|
||||
} else {
|
||||
setFeedback({ text: 'Invitation envoyée !', type: 'success' })
|
||||
setFeedback({ text: t('brainstorm.feedbackInviteSent'), type: 'success' })
|
||||
}
|
||||
if (result.message === 'invited' || result.message === 're_invited') {
|
||||
setQuery('')
|
||||
}
|
||||
} catch (err: any) {
|
||||
setFeedback({ text: err.message || 'Erreur', type: 'error' })
|
||||
setFeedback({ text: err.message || t('brainstorm.feedbackGenericError'), type: 'error' })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -138,7 +140,7 @@ export function BrainstormShareDialog({
|
||||
<div className="w-7 h-7 rounded-lg bg-orange-500/10 flex items-center justify-center">
|
||||
<UserPlus size={14} className="text-orange-500" />
|
||||
</div>
|
||||
<span className="font-serif">Partager le brainstorm</span>
|
||||
<span className="font-serif">{t('brainstorm.shareDialogTitle')}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground text-xs italic font-serif truncate">
|
||||
{seedIdea.length > 60 ? seedIdea.substring(0, 60) + '…' : seedIdea}
|
||||
@@ -148,7 +150,7 @@ export function BrainstormShareDialog({
|
||||
<form onSubmit={handleShare} className="space-y-3 mt-2">
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<label className="text-[10px] font-bold uppercase tracking-[0.15em] text-muted-foreground mb-1.5 block">
|
||||
Rechercher une personne
|
||||
{t('brainstorm.shareSearchLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -156,7 +158,7 @@ export function BrainstormShareDialog({
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onFocus={() => results.length > 0 && setShowDropdown(true)}
|
||||
onBlur={() => setTimeout(() => setShowDropdown(false), 150)}
|
||||
placeholder="Nom ou email…"
|
||||
placeholder={t('brainstorm.shareNameOrEmailPlaceholder')}
|
||||
className="w-full px-4 py-3 text-sm border border-border rounded-xl bg-transparent focus:outline-none focus:ring-2 focus:ring-orange-500/20 focus:border-orange-500/40 transition-all"
|
||||
autoFocus
|
||||
/>
|
||||
@@ -178,7 +180,7 @@ export function BrainstormShareDialog({
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{user.name || 'Sans nom'}
|
||||
{user.name || t('brainstorm.unnamedPerson')}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground truncate">
|
||||
{user.email}
|
||||
@@ -215,12 +217,12 @@ export function BrainstormShareDialog({
|
||||
className="w-full py-3 bg-orange-500 hover:bg-orange-600 text-white text-[10px] font-bold uppercase tracking-[0.15em] rounded-xl disabled:opacity-50 transition-all flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<UserPlus size={12} />
|
||||
{isPending ? 'Envoi…' : 'Partager'}
|
||||
{isPending ? t('brainstorm.shareSubmitting') : t('brainstorm.shareSubmit')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground/60 text-center mt-1">
|
||||
La personne recevra une notification pour accepter ou refuser.
|
||||
{t('brainstorm.shareFooterHint')}
|
||||
</p>
|
||||
|
||||
<div className="border-t border-border mt-4 pt-4">
|
||||
@@ -232,7 +234,7 @@ export function BrainstormShareDialog({
|
||||
<Lock size={14} className="text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-[10px] font-bold uppercase tracking-[0.15em] text-muted-foreground">
|
||||
Lien public
|
||||
{t('brainstorm.sharePublicLink')}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
@@ -272,7 +274,7 @@ export function BrainstormShareDialog({
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
Autoriser les invités à modifier
|
||||
{t('brainstorm.shareGuestsCanEdit')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
|
||||
@@ -153,8 +153,8 @@ export function InviteDialog({
|
||||
onClick={() => setRole('viewer')}
|
||||
className={`flex-1 py-2.5 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] border transition-all ${
|
||||
role === 'viewer'
|
||||
? 'border-memento-blue/40 bg-memento-blue/5 text-memento-blue'
|
||||
: 'border-border text-muted-foreground hover:border-memento-blue/20'
|
||||
? 'border-brand-accent/40 bg-brand-accent/5 text-brand-accent'
|
||||
: 'border-border text-muted-foreground hover:border-brand-accent/20'
|
||||
}`}
|
||||
>
|
||||
{t('brainstorm.roleViewer') || 'Viewer'}
|
||||
|
||||
@@ -38,8 +38,8 @@ export function ManualIdeaDialog({
|
||||
<DialogContent className="sm:max-w-md bg-white dark:bg-[#1A1A1A] border-border rounded-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-foreground">
|
||||
<div className="w-7 h-7 rounded-lg bg-memento-blue/10 flex items-center justify-center">
|
||||
<Lightbulb size={14} className="text-memento-blue" />
|
||||
<div className="w-7 h-7 rounded-lg bg-brand-accent/10 flex items-center justify-center">
|
||||
<Lightbulb size={14} className="text-brand-accent" />
|
||||
</div>
|
||||
<span className="font-serif">{t('brainstorm.addIdea') || 'Add an idea'}</span>
|
||||
</DialogTitle>
|
||||
@@ -59,7 +59,7 @@ export function ManualIdeaDialog({
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t('brainstorm.manualIdeaTitlePlaceholder') || 'Your idea in a few words...'}
|
||||
className="w-full px-4 py-3 text-sm border border-border rounded-xl bg-transparent focus:outline-none focus:ring-2 focus:ring-memento-blue/20 focus:border-memento-blue/40 transition-all font-serif"
|
||||
className="w-full px-4 py-3 text-sm border border-border rounded-xl bg-transparent focus:outline-none focus:ring-2 focus:ring-brand-accent/20 focus:border-brand-accent/40 transition-all font-serif"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
@@ -71,7 +71,7 @@ export function ManualIdeaDialog({
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t('brainstorm.manualIdeaDescPlaceholder') || 'Elaborate on your idea...'}
|
||||
className="w-full h-24 px-4 py-3 text-sm border border-border rounded-xl bg-transparent focus:outline-none focus:ring-2 focus:ring-memento-blue/20 focus:border-memento-blue/40 resize-none transition-all"
|
||||
className="w-full h-24 px-4 py-3 text-sm border border-border rounded-xl bg-transparent focus:outline-none focus:ring-2 focus:ring-brand-accent/20 focus:border-brand-accent/40 resize-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
@@ -85,7 +85,7 @@ export function ManualIdeaDialog({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!title.trim() || isLoading}
|
||||
className="px-5 py-2.5 bg-memento-blue hover:bg-memento-blue text-white text-[10px] font-bold uppercase tracking-[0.15em] rounded-xl disabled:opacity-50 transition-all flex items-center gap-1.5"
|
||||
className="px-5 py-2.5 bg-brand-accent hover:bg-brand-accent text-white text-[10px] font-bold uppercase tracking-[0.15em] rounded-xl disabled:opacity-50 transition-all flex items-center gap-1.5"
|
||||
>
|
||||
<Lightbulb size={12} />
|
||||
{isLoading ? (t('brainstorm.adding') || 'Adding...') : (t('brainstorm.addIdea') || 'Add idea')}
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { Play, Pause, SkipBack, SkipForward, ChevronDown, ChevronUp, RotateCcw } from 'lucide-react'
|
||||
import { useBrainstormSnapshots } from '@/hooks/use-brainstorm'
|
||||
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
interface SnapshotIdea {
|
||||
id: string
|
||||
title: string
|
||||
@@ -32,6 +32,7 @@ interface PlaybackBarProps {
|
||||
}
|
||||
|
||||
export function PlaybackBar({ sessionId, onSnapshotSelect, onExitPlayback }: PlaybackBarProps) {
|
||||
const { t } = useLanguage()
|
||||
const { data: snapshots } = useBrainstormSnapshots(sessionId)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [currentStep, setCurrentStep] = useState(-1)
|
||||
@@ -105,7 +106,9 @@ export function PlaybackBar({ sessionId, onSnapshotSelect, onExitPlayback }: Pla
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-2 h-2 rounded-full ${isLive ? 'bg-emerald-500 animate-pulse' : isPlaying ? 'bg-orange-500 animate-pulse' : 'bg-muted-foreground'}`} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{isLive ? 'Live' : `Step ${currentStep + 1}/${parsedSnapshots.length}`}
|
||||
{isLive
|
||||
? t('brainstorm.liveStatus')
|
||||
: t('brainstorm.playbackStep', { current: currentStep + 1, total: parsedSnapshots.length })}
|
||||
</span>
|
||||
{parsedSnapshots[currentStep]?.label && (
|
||||
<span className="text-[10px] text-muted-foreground/60 truncate max-w-[200px]">
|
||||
@@ -115,12 +118,12 @@ export function PlaybackBar({ sessionId, onSnapshotSelect, onExitPlayback }: Pla
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isLive ? (
|
||||
<span className="text-[9px] font-bold text-emerald-500 uppercase tracking-wider">Live</span>
|
||||
<span className="text-[9px] font-bold text-emerald-500 uppercase tracking-wider">{t('brainstorm.liveStatus')}</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleStepChange(-1) }}
|
||||
className="p-1.5 hover:bg-foreground/10 rounded-lg transition-colors"
|
||||
title="Return to live"
|
||||
title={t('brainstorm.playbackReturnToLive')}
|
||||
>
|
||||
<RotateCcw size={12} className="text-muted-foreground" />
|
||||
</button>
|
||||
@@ -167,8 +170,10 @@ export function PlaybackBar({ sessionId, onSnapshotSelect, onExitPlayback }: Pla
|
||||
className="w-full h-1.5 bg-border rounded-full appearance-none cursor-pointer accent-orange-500"
|
||||
/>
|
||||
<div className="flex justify-between mt-1">
|
||||
<span className="text-[8px] text-muted-foreground/40">Live</span>
|
||||
<span className="text-[8px] text-muted-foreground/40">{parsedSnapshots.length} steps</span>
|
||||
<span className="text-[8px] text-muted-foreground/40">{t('brainstorm.liveStatus')}</span>
|
||||
<span className="text-[8px] text-muted-foreground/40">
|
||||
{t('brainstorm.playbackStepsCount', { count: parsedSnapshots.length })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import * as d3 from 'd3'
|
||||
import { BrainstormSession } from '@/types/brainstorm'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface WaveCanvasProps {
|
||||
session: BrainstormSession
|
||||
@@ -31,6 +32,7 @@ export const WaveCanvas: React.FC<WaveCanvasProps> = ({
|
||||
manualEditTrigger,
|
||||
playbackIdeas,
|
||||
}) => {
|
||||
const { t, language } = useLanguage()
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const nodeRef = useRef<d3.Selection<SVGGElement, any, SVGGElement, unknown> | null>(null)
|
||||
@@ -376,7 +378,7 @@ export const WaveCanvas: React.FC<WaveCanvasProps> = ({
|
||||
)
|
||||
.text((d) =>
|
||||
d.type === 'root'
|
||||
? 'SEED'
|
||||
? t('brainstorm.seedNodeBadge')
|
||||
: d.title.length > 18
|
||||
? d.title.substring(0, 18) + '...'
|
||||
: d.title
|
||||
@@ -476,7 +478,7 @@ export const WaveCanvas: React.FC<WaveCanvasProps> = ({
|
||||
return () => {
|
||||
simulation.stop()
|
||||
}
|
||||
}, [sessionId, ideasKey, isDark, toSvgCoords, toScreenCoords, playbackIdeas])
|
||||
}, [sessionId, ideasKey, isDark, toSvgCoords, toScreenCoords, playbackIdeas, language, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (!nodeRef.current) return
|
||||
@@ -533,13 +535,13 @@ export const WaveCanvas: React.FC<WaveCanvasProps> = ({
|
||||
>
|
||||
<div className="w-[260px] bg-white dark:bg-[#1A1A1A] rounded-2xl shadow-2xl border border-black/10 dark:border-white/10 overflow-hidden">
|
||||
<div className="px-3.5 pt-3 pb-1 flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded-md bg-memento-blue/10 flex items-center justify-center">
|
||||
<div className="w-5 h-5 rounded-md bg-brand-accent/10 flex items-center justify-center">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" strokeWidth="2.5" strokeLinecap="round">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold uppercase tracking-[0.15em] text-foreground/50">
|
||||
{editingNode.parentId ? 'Réponse' : 'Nouvelle idée'}
|
||||
{editingNode.parentId ? t('brainstorm.canvasEditTitleReply') : t('brainstorm.canvasEditTitleNewIdea')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-3.5 pb-3">
|
||||
@@ -551,19 +553,19 @@ export const WaveCanvas: React.FC<WaveCanvasProps> = ({
|
||||
if (e.key === 'Enter' && editText.trim()) handleSubmitIdea()
|
||||
if (e.key === 'Escape') { setEditingNode(null); setEditText('') }
|
||||
}}
|
||||
placeholder={editingNode.parentId ? 'Votre réponse…' : 'Votre idée…'}
|
||||
className="w-full px-3 py-2.5 text-sm font-serif bg-black/[0.03] dark:bg-white/[0.06] border border-black/[0.06] dark:border-white/[0.12] rounded-xl outline-none focus:border-memento-blue/50 focus:bg-white dark:focus:bg-white/[0.08] transition-all placeholder:text-foreground/25 text-foreground"
|
||||
placeholder={editingNode.parentId ? t('brainstorm.canvasPlaceholderReply') : t('brainstorm.canvasPlaceholderIdea')}
|
||||
className="w-full px-3 py-2.5 text-sm font-serif bg-black/[0.03] dark:bg-white/[0.06] border border-black/[0.06] dark:border-white/[0.12] rounded-xl outline-none focus:border-brand-accent/50 focus:bg-white dark:focus:bg-white/[0.08] transition-all placeholder:text-foreground/25 text-foreground"
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-1.5 px-0.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<kbd className="text-[9px] text-foreground/30 font-mono bg-black/[0.04] px-1.5 py-0.5 rounded">↵</kbd>
|
||||
<span className="text-[9px] text-foreground/25">enregistrer</span>
|
||||
<span className="text-[9px] text-foreground/25">{t('brainstorm.canvasShortcutSave')}</span>
|
||||
<kbd className="text-[9px] text-foreground/30 font-mono bg-black/[0.04] px-1.5 py-0.5 rounded">esc</kbd>
|
||||
<span className="text-[9px] text-foreground/25">annuler</span>
|
||||
<span className="text-[9px] text-foreground/25">{t('brainstorm.canvasShortcutCancel')}</span>
|
||||
</div>
|
||||
{editingNode.parentId && (
|
||||
<span className="text-[9px] font-medium text-memento-blue/60 flex items-center gap-0.5">
|
||||
→ enfant
|
||||
<span className="text-[9px] font-medium text-brand-accent/60 flex items-center gap-0.5">
|
||||
→ {t('brainstorm.canvasChildBranch')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -577,7 +579,7 @@ export const WaveCanvas: React.FC<WaveCanvasProps> = ({
|
||||
|
||||
<div className="absolute bottom-6 left-6 pointer-events-none">
|
||||
<p className="text-[10px] font-bold tracking-[0.3em] uppercase text-gray-400 dark:text-gray-600 opacity-60">
|
||||
Double-clic pour ajouter une idée
|
||||
{t('brainstorm.canvasDoubleClickHint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,16 +25,16 @@ import { toast } from 'sonner'
|
||||
|
||||
// ── Custom Toast Helper ──────────────────────────────────────────────────────
|
||||
const mToast = {
|
||||
success: (msg: string, options?: any) => toast.success(msg, {
|
||||
...options,
|
||||
success: (msg: string, options?: any) => toast.success(msg, {
|
||||
...options,
|
||||
className: 'memento-toast memento-toast-success',
|
||||
}),
|
||||
error: (msg: string, options?: any) => toast.error(msg, {
|
||||
...options,
|
||||
error: (msg: string, options?: any) => toast.error(msg, {
|
||||
...options,
|
||||
className: 'memento-toast memento-toast-error',
|
||||
}),
|
||||
loading: (msg: string, options?: any) => toast.loading(msg, {
|
||||
...options,
|
||||
loading: (msg: string, options?: any) => toast.loading(msg, {
|
||||
...options,
|
||||
className: 'memento-toast memento-toast-info',
|
||||
}),
|
||||
}
|
||||
@@ -66,12 +66,12 @@ function getMessageContent(msg: UIMessage): string {
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const TONES = [
|
||||
{ id: 'professional', label: 'PRO', full: 'Professional', icon: Briefcase },
|
||||
{ id: 'creative', label: 'CRE', full: 'Creative', icon: Palette },
|
||||
{ id: 'academic', label: 'ACA', full: 'Academic', icon: GraduationCap },
|
||||
{ id: 'casual', label: 'CAS', full: 'Casual', icon: Coffee },
|
||||
]
|
||||
const TONE_ICONS = [
|
||||
{ id: 'professional', icon: Briefcase },
|
||||
{ id: 'creative', icon: Palette },
|
||||
{ id: 'academic', icon: GraduationCap },
|
||||
{ id: 'casual', icon: Coffee },
|
||||
] as const
|
||||
|
||||
interface ActionDef {
|
||||
id: string
|
||||
@@ -155,10 +155,10 @@ function CopyPreviewButton({ text }: { text: string }) {
|
||||
ta.focus()
|
||||
ta.setSelectionRange(0, ta.value.length)
|
||||
let ok = false
|
||||
try { ok = document.execCommand('copy') } catch {}
|
||||
try { ok = document.execCommand('copy') } catch { }
|
||||
document.body.removeChild(ta)
|
||||
if (!ok) {
|
||||
try { navigator.clipboard.writeText(text) } catch {}
|
||||
try { navigator.clipboard.writeText(text) } catch { }
|
||||
}
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
@@ -195,40 +195,40 @@ export function ContextualAIChat({
|
||||
const { t, language } = useLanguage()
|
||||
const webSearchAvailable = useWebSearchAvailable()
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'chat' | 'actions' | 'resource'>('actions')
|
||||
const [selectedTone, setSelectedTone] = useState('professional')
|
||||
const [input, setInput] = useState('')
|
||||
const [chatScope, setChatScope] = useState<'note' | 'all' | string>('note')
|
||||
const [webSearch, setWebSearch] = useState(false)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<'chat' | 'actions' | 'resource'>('actions')
|
||||
const [selectedTone, setSelectedTone] = useState('professional')
|
||||
const [input, setInput] = useState('')
|
||||
const [chatScope, setChatScope] = useState<'note' | 'all' | string>('note')
|
||||
const [webSearch, setWebSearch] = useState(false)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
// Action state
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
const [actionPreview, setActionPreview] = useState<{ label: string; text: string } | null>(null)
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
const [actionPreview, setActionPreview] = useState<{ label: string; text: string } | null>(null)
|
||||
const [showLangPicker, setShowLangPicker] = useState(false)
|
||||
const [translateTarget, setTranslateTarget] = useState('')
|
||||
|
||||
// Generate slides / diagram state
|
||||
const [generateLoading, setGenerateLoading] = useState<'slides' | 'diagram' | null>(null)
|
||||
const [generateResult, setGenerateResult] = useState<GenerateResult | null>(null)
|
||||
const [generateResult, setGenerateResult] = useState<GenerateResult | null>(null)
|
||||
const [customLangInput, setCustomLangInput] = useState('')
|
||||
// Generation options
|
||||
const [slideTheme, setSlideTheme] = useState('architectural_mono')
|
||||
const [slideStyle, setSlideStyle] = useState('professional')
|
||||
const [diagramType, setDiagramType] = useState('logic_flow')
|
||||
const [slideTheme, setSlideTheme] = useState('architectural_mono')
|
||||
const [slideStyle, setSlideStyle] = useState('professional')
|
||||
const [diagramType, setDiagramType] = useState('logic_flow')
|
||||
const [diagramStyle, setDiagramStyle] = useState('polished')
|
||||
const [diagramEmbedLoading, setDiagramEmbedLoading] = useState(false)
|
||||
|
||||
// Resource tab state
|
||||
const [resourceUrl, setResourceUrl] = useState('')
|
||||
const [resourceText, setResourceText] = useState('')
|
||||
const [resourceUrl, setResourceUrl] = useState('')
|
||||
const [resourceText, setResourceText] = useState('')
|
||||
const [resourceScraping, setResourceScraping] = useState(false)
|
||||
const [resourceMode, setResourceMode] = useState<'replace' | 'complete' | 'merge'>('complete')
|
||||
const [resourceMode, setResourceMode] = useState<'replace' | 'complete' | 'merge'>('complete')
|
||||
const [resourcePreview, setResourcePreview] = useState<{ text: string; source: string } | null>(null)
|
||||
const [resourceEnriching, setResourceEnriching] = useState(false)
|
||||
const [resourcePreviewFormat, setResourcePreviewFormat] = useState<'rendered' | 'markdown'>('rendered')
|
||||
// hoveredMsgId: which chat message shows inject actions
|
||||
const [hoveredMsgId, setHoveredMsgId] = useState<string | null>(null)
|
||||
const [hoveredMsgId, setHoveredMsgId] = useState<string | null>(null)
|
||||
|
||||
// Label regeneration state
|
||||
const [regenerateLabelsLoading, setRegenerateLabelsLoading] = useState(false)
|
||||
@@ -239,8 +239,8 @@ export function ContextualAIChat({
|
||||
const transport = useRef(new DefaultChatTransport({ api: '/api/chat' })).current
|
||||
|
||||
const buildChatBody = () => {
|
||||
const body: Record<string, any> = {
|
||||
language,
|
||||
const body: Record<string, any> = {
|
||||
language,
|
||||
webSearch,
|
||||
format: diagramInsertFormat || 'markdown'
|
||||
}
|
||||
@@ -509,7 +509,7 @@ export function ContextualAIChat({
|
||||
const handleInjectFromChat = async (msgText: string, mode: 'replace' | 'complete' | 'merge') => {
|
||||
setResourceText(msgText)
|
||||
setResourceMode(mode)
|
||||
|
||||
|
||||
if (mode === 'replace') {
|
||||
setResourcePreview({ text: msgText, source: 'chat' })
|
||||
return
|
||||
@@ -555,40 +555,37 @@ export function ContextualAIChat({
|
||||
/>
|
||||
)}
|
||||
<aside className={cn(
|
||||
'border-l border-border bg-memento-paper dark:bg-background flex flex-col flex-shrink-0 z-10 transition-all duration-300 shadow-2xl',
|
||||
'border-l border-border bg-[#FDFCFB] dark:bg-[#0D0D0D] flex flex-col z-10 transition-all duration-300 shadow-2xl',
|
||||
expanded
|
||||
? 'fixed right-0 top-0 h-screen w-[640px] z-[200]'
|
||||
: 'h-full w-[360px]',
|
||||
: 'self-stretch h-full w-[400px]',
|
||||
!expanded && className,
|
||||
)}>
|
||||
|
||||
<div className="p-6 border-b border-border shrink-0">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<h2 className="font-serif text-xl font-medium text-foreground flex items-center gap-2 leading-tight">
|
||||
<Sparkles className="h-[18px] w-[18px] shrink-0 text-memento-accent" />
|
||||
IA Assistant
|
||||
</h2>
|
||||
<p className="text-[11px] text-foreground/60 uppercase tracking-wider font-medium opacity-60 truncate">
|
||||
<div className="p-6 border-b border-border/60 space-y-1.5 bg-white/50 dark:bg-black/20 backdrop-blur-md shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="flex items-center gap-2 font-serif text-xl font-medium text-ink">
|
||||
<Sparkles size={18} className="text-brand-accent" />
|
||||
{t('ai.assistantTitle') || 'IA Assistant'}
|
||||
</h3>
|
||||
<p className="text-[11px] text-concrete uppercase tracking-wider font-medium opacity-60 truncate">
|
||||
"{noteTitle || t('ai.currentNote')}"
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0 -mt-1">
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
className="p-1.5 hover:bg-muted/60 rounded-full transition-colors text-foreground/40"
|
||||
className="p-1.5 hover:bg-slate-100 dark:hover:bg-white/10 rounded-lg transition-colors text-concrete"
|
||||
title={expanded ? t('ai.shrinkPanel') : t('ai.expandPanel')}
|
||||
>
|
||||
{expanded ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 hover:bg-muted/60 rounded-full transition-colors text-foreground/40 group"
|
||||
className="p-1.5 hover:bg-slate-100 dark:hover:bg-white/10 rounded-lg transition-colors text-concrete"
|
||||
>
|
||||
<div className="relative w-5 h-5 flex items-center justify-center">
|
||||
<ChevronRight size={20} className="transition-all duration-200 group-hover:opacity-0 group-hover:scale-0" />
|
||||
<X size={18} className="absolute inset-0 m-auto opacity-0 scale-0 transition-all duration-200 group-hover:opacity-100 group-hover:scale-100" />
|
||||
</div>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -604,13 +601,13 @@ export function ContextualAIChat({
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-2 py-3.5 text-[10px] font-bold uppercase tracking-[0.2em] transition-all relative",
|
||||
activeTab === tab.id ? 'text-foreground' : 'text-foreground/60 hover:text-foreground'
|
||||
"flex-1 py-3 text-[10px] font-bold uppercase tracking-[0.2em] transition-all relative",
|
||||
activeTab === tab.id ? 'text-brand-accent' : 'text-concrete hover:text-ink/60'
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{activeTab === tab.id && (
|
||||
<motion.div layoutId="activeTab" className="absolute bottom-0 left-0 right-0 h-[2px] bg-memento-blue" />
|
||||
<motion.div layoutId="activeTab" className="absolute bottom-0 left-0 right-0 h-0.5 bg-brand-accent" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
@@ -618,80 +615,150 @@ export function ContextualAIChat({
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0 relative">
|
||||
{actionPreview && (
|
||||
<div className="absolute inset-0 z-20 flex flex-col bg-memento-paper/95 dark:bg-background/95 backdrop-blur-md animate-in fade-in slide-in-from-top-4 duration-300">
|
||||
<div className="absolute inset-0 z-20 flex flex-col bg-[#FDFCFB]/95 dark:bg-[#0D0D0D]/95 backdrop-blur-md animate-in fade-in slide-in-from-top-4 duration-300">
|
||||
<div className="px-6 py-4 border-b border-border flex items-center justify-between shrink-0">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-memento-blue">{actionPreview.label}</p>
|
||||
<button onClick={handleDiscardPreview} className="text-foreground/40 hover:text-foreground"><X size={18} /></button>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-brand-accent">{actionPreview.label}</p>
|
||||
<button onClick={handleDiscardPreview} className="text-concrete hover:text-ink"><X size={18} /></button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar">
|
||||
<div className="bg-card/60 backdrop-blur-sm border border-border p-6 rounded-2xl shadow-sm leading-relaxed text-sm">
|
||||
<div className="bg-white/60 dark:bg-white/5 border border-border p-6 rounded-2xl leading-relaxed text-sm">
|
||||
<MarkdownContent content={actionPreview.text} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 border-t border-border flex gap-3 shrink-0">
|
||||
<button onClick={handleDiscardPreview} className="flex-1 py-3.5 text-[10px] font-bold uppercase tracking-widest text-foreground/40 hover:text-foreground transition-all">{t('ai.cancel')}</button>
|
||||
<button onClick={handleDiscardPreview} className="flex-1 py-3.5 text-[10px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-all">{t('ai.cancel')}</button>
|
||||
<CopyPreviewButton text={actionPreview.text} />
|
||||
<button onClick={handleApplyPreview} className="flex-1 py-3.5 bg-foreground text-background rounded-xl text-[10px] font-bold uppercase tracking-widest shadow-lg transition-all hover:opacity-90">{t('ai.applyToNote')}</button>
|
||||
<button onClick={handleApplyPreview} className="flex-1 py-3.5 bg-ink text-paper rounded-xl text-[10px] font-bold uppercase tracking-widest shadow-lg transition-all hover:opacity-90">{t('ai.applyToNote')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resourcePreview && (
|
||||
<div className="absolute inset-0 z-20 flex flex-col bg-memento-paper/95 dark:bg-background/95 backdrop-blur-md animate-in fade-in slide-in-from-top-4 duration-300">
|
||||
<div className="px-6 py-4 border-b border-border/40 flex items-center justify-between shrink-0">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-memento-blue">
|
||||
<div className="absolute inset-0 z-20 flex flex-col bg-[#FDFCFB]/95 dark:bg-[#0D0D0D]/95 backdrop-blur-md animate-in fade-in slide-in-from-top-4 duration-300">
|
||||
<div className="px-6 py-4 border-b border-border flex items-center justify-between shrink-0">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-brand-accent">
|
||||
{resourcePreview.source === 'chat' ? t('ai.resourcePreviewInjectFromChat') : t('ai.resourcePreviewAiTitle')}
|
||||
</p>
|
||||
<button onClick={() => setResourcePreview(null)} className="text-foreground/40 hover:text-foreground">
|
||||
<button onClick={() => setResourcePreview(null)} className="text-concrete hover:text-ink">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar">
|
||||
<div className="bg-card/60 backdrop-blur-sm border border-border p-6 rounded-2xl shadow-sm leading-relaxed text-sm">
|
||||
<div className="bg-white/60 dark:bg-white/5 border border-border p-6 rounded-2xl leading-relaxed text-sm">
|
||||
<MarkdownContent content={resourcePreview.text} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 border-t border-border flex gap-3 shrink-0">
|
||||
<button onClick={() => setResourcePreview(null)} className="flex-1 py-3.5 text-[10px] font-bold uppercase tracking-widest text-foreground/40 hover:text-foreground transition-all">{t('ai.cancel')}</button>
|
||||
<button onClick={() => setResourcePreview(null)} className="flex-1 py-3.5 text-[10px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-all">{t('ai.cancel')}</button>
|
||||
<CopyPreviewButton text={resourcePreview.text} />
|
||||
<button onClick={handleApplyResourcePreview} className="flex-1 py-3.5 bg-memento-blue text-white rounded-xl text-[10px] font-bold uppercase tracking-widest shadow-lg shadow-memento-blue/20 transition-all hover:opacity-90">{t('ai.applyToNote')}</button>
|
||||
<button onClick={handleApplyResourcePreview} className="flex-1 py-3.5 bg-brand-accent text-white rounded-xl text-[10px] font-bold uppercase tracking-widest shadow-lg shadow-brand-accent/20 transition-all hover:opacity-90">{t('ai.applyToNote')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{activeTab === 'chat' && (
|
||||
<motion.div
|
||||
<motion.div
|
||||
key="chat"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
className="flex flex-col flex-1 min-h-0"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="flex flex-col flex-1 overflow-hidden"
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar space-y-8">
|
||||
{messages.length === 0 && (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center space-y-6 py-12">
|
||||
<div className="w-20 h-20 rounded-full bg-card/40 backdrop-blur-sm border border-dashed border-border flex items-center justify-center shadow-sm">
|
||||
<MessageSquare size={32} className="text-memento-blue/60" />
|
||||
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar space-y-6 min-h-0">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-concrete">{t('ai.writingTone')}</label>
|
||||
<div className="flex items-center gap-1">
|
||||
{TONE_ICONS.map((tone) => {
|
||||
const Icon = tone.icon
|
||||
return (
|
||||
<button
|
||||
key={tone.id}
|
||||
onClick={() => setSelectedTone(tone.id)}
|
||||
title={t(`ai.tones.${tone.id}`)}
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-lg flex items-center justify-center text-[9px] font-bold transition-all border',
|
||||
selectedTone === tone.id
|
||||
? 'bg-brand-accent text-white border-brand-accent shadow-sm'
|
||||
: 'bg-white/50 dark:bg-white/5 border-border/40 text-concrete hover:border-brand-accent/40'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs font-serif italic text-foreground/40 leading-relaxed max-w-[200px]">{t('ai.askToStart')}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setChatScope('note')}
|
||||
className={cn(
|
||||
'w-full p-2.5 border rounded-xl text-[11px] flex items-center justify-between transition-all',
|
||||
chatScope === 'note' ? 'bg-brand-accent/5 border-brand-accent/30' : 'bg-white/50 dark:bg-white/5 border-border/40 hover:border-ink/20'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<BookOpen size={14} className="text-brand-accent/60" />
|
||||
<span className={cn('font-medium', chatScope === 'note' ? 'text-brand-accent' : 'text-concrete')}>{t('ai.thisNote')}</span>
|
||||
</div>
|
||||
{chatScope === 'note' && (
|
||||
<span className="text-[8px] bg-brand-accent/10 text-brand-accent px-1.5 py-0.5 rounded-full uppercase font-bold">{t('ai.scopeAutoBadge')}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
<span className="text-[9px] font-bold text-concrete uppercase tracking-widest">+ Carnet</span>
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
</div>
|
||||
|
||||
<HierarchicalNotebookSelector
|
||||
notebooks={(notebooks || []).filter(nb => !nb.trashedAt)}
|
||||
selectedId={chatScope !== 'note' && chatScope !== 'all' ? chatScope : null}
|
||||
onSelect={(id) => setChatScope(id)}
|
||||
placeholder={t('ai.chatNotebookSelectPlaceholder')}
|
||||
className="w-full"
|
||||
size="sm"
|
||||
dropUp
|
||||
/>
|
||||
</div>
|
||||
|
||||
{messages.length === 0 && (
|
||||
<div className="h-48 flex flex-col items-center justify-center text-center space-y-3 text-concrete/30">
|
||||
<div className="w-12 h-12 rounded-full border border-dashed border-concrete/10 flex items-center justify-center">
|
||||
<MessageSquare size={18} />
|
||||
</div>
|
||||
<p className="text-[11px] italic leading-relaxed px-12">{t('ai.welcomeMsg')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg: UIMessage) => {
|
||||
const content = getMessageContent(msg)
|
||||
if (msg.role === 'assistant' && !content) return null
|
||||
const isAssistant = msg.role === 'assistant'
|
||||
return (
|
||||
<div key={msg.id} className={cn('flex flex-col gap-3', !isAssistant && 'items-end')} onMouseEnter={() => isAssistant && setHoveredMsgId(msg.id)} onMouseLeave={() => setHoveredMsgId(null)}>
|
||||
<div className="relative group max-w-[95%]">
|
||||
<div className={cn('p-5 rounded-2xl text-sm leading-relaxed transition-all shadow-sm', !isAssistant ? 'bg-foreground text-background' : 'bg-card/60 backdrop-blur-sm border border-border text-foreground')}>
|
||||
{isAssistant ? <MarkdownContent content={content} /> : <p className="font-medium">{content}</p>}
|
||||
<div key={msg.id} className={cn('flex gap-3', !isAssistant && 'flex-row-reverse')} onMouseEnter={() => isAssistant && setHoveredMsgId(msg.id)} onMouseLeave={() => setHoveredMsgId(null)}>
|
||||
<div className={cn(
|
||||
'w-8 h-8 rounded-xl flex items-center justify-center flex-shrink-0',
|
||||
!isAssistant ? 'bg-ink text-paper' : 'bg-brand-accent/10 text-brand-accent',
|
||||
)}>
|
||||
{!isAssistant ? 'U' : <Bot className="h-4 w-4" />}
|
||||
</div>
|
||||
<div className="relative group/msg max-w-[85%]">
|
||||
<div className={cn(
|
||||
'p-3.5 rounded-2xl text-sm leading-relaxed',
|
||||
!isAssistant
|
||||
? 'bg-ink text-paper rounded-tr-sm'
|
||||
: 'bg-white/60 dark:bg-white/5 border border-border rounded-tl-sm text-ink',
|
||||
)}>
|
||||
{isAssistant ? <MarkdownContent content={content} /> : <p>{content}</p>}
|
||||
</div>
|
||||
{isAssistant && onApplyToNote && (hoveredMsgId === msg.id || messages.at(-1)?.id === msg.id) && (
|
||||
<div className="flex gap-2 mt-3 opacity-0 group-hover:opacity-100 transition-all">
|
||||
<button onClick={() => handleInjectFromChat(content, 'replace')} className="px-3 py-1.5 rounded-lg text-[9px] font-bold uppercase tracking-widest bg-foreground text-background hover:opacity-90">{t('ai.injectReplace')}</button>
|
||||
<button onClick={() => handleInjectFromChat(content, 'complete')} className="px-3 py-1.5 rounded-lg text-[9px] font-bold uppercase tracking-widest bg-card/40 backdrop-blur-sm border border-border text-foreground hover:bg-card/60">{t('ai.injectComplete')}</button>
|
||||
<button onClick={() => handleInjectFromChat(content, 'merge')} className="px-3 py-1.5 rounded-lg text-[9px] font-bold uppercase tracking-widest bg-card/40 backdrop-blur-sm border border-border text-foreground hover:bg-card/60">{t('ai.injectMerge')}</button>
|
||||
<div className="flex gap-2 mt-2 opacity-0 group-hover/msg:opacity-100 transition-all">
|
||||
<button onClick={() => handleInjectFromChat(content, 'replace')} className="px-2.5 py-1 rounded-lg text-[8px] font-bold uppercase tracking-widest bg-ink text-paper hover:opacity-90">{t('ai.injectReplace')}</button>
|
||||
<button onClick={() => handleInjectFromChat(content, 'complete')} className="px-2.5 py-1 rounded-lg text-[8px] font-bold uppercase tracking-widest bg-white/50 dark:bg-white/5 border border-border text-ink hover:bg-white dark:hover:bg-white/10">{t('ai.injectComplete')}</button>
|
||||
<button onClick={() => handleInjectFromChat(content, 'merge')} className="px-2.5 py-1 rounded-lg text-[8px] font-bold uppercase tracking-widest bg-white/50 dark:bg-white/5 border border-border text-ink hover:bg-white dark:hover:bg-white/10">{t('ai.injectMerge')}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -699,104 +766,22 @@ export function ContextualAIChat({
|
||||
)
|
||||
})}
|
||||
{isLoading && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="bg-card/60 backdrop-blur-sm border border-border p-5 rounded-2xl shadow-sm w-fit">
|
||||
<div className="flex gap-1.5"><span className="w-1.5 h-1.5 bg-memento-blue rounded-full animate-pulse" /><span className="w-1.5 h-1.5 bg-memento-blue rounded-full animate-pulse delay-75" /><span className="w-1.5 h-1.5 bg-memento-blue rounded-full animate-pulse delay-150" /></div>
|
||||
<div className="flex gap-3">
|
||||
<div className="w-8 h-8 rounded-xl bg-brand-accent/10 text-brand-accent flex items-center justify-center flex-shrink-0">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
<div className="bg-white/60 dark:bg-white/5 border border-border p-3.5 rounded-2xl rounded-tl-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-concrete" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* ── Chat Footer: Context + Tone + Input ── */}
|
||||
<div className="px-6 py-8 border-t border-border shrink-0 space-y-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="space-y-3">
|
||||
<label className="text-[10px] uppercase tracking-[0.25em] font-bold text-foreground/40 px-1">{t('ai.chatPanelContext')}</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() => setChatScope('note')}
|
||||
className={cn(
|
||||
'w-full p-3 border rounded-lg text-xs flex items-center gap-2 transition-all',
|
||||
chatScope === 'note' ? 'bg-blueprint/10 border-blueprint/30 text-blueprint font-bold' : 'bg-card/60 border-border hover:border-foreground/20 text-foreground/60'
|
||||
)}
|
||||
>
|
||||
<BookOpen size={14} className="text-blueprint/60" />
|
||||
<span>{t('ai.thisNote')}</span>
|
||||
<span className="ml-auto text-[8px] bg-blueprint/10 text-blueprint px-1.5 py-0.5 rounded uppercase font-bold">{t('ai.scopeAutoBadge')}</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
<span className="text-[9px] font-bold text-muted-foreground uppercase tracking-widest">{t('ai.chatPanelNotebookPlus')}</span>
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
</div>
|
||||
<HierarchicalNotebookSelector
|
||||
notebooks={(notebooks || []).filter(nb => !nb.trashedAt)}
|
||||
selectedId={chatScope !== 'note' && chatScope !== 'all' ? chatScope : null}
|
||||
onSelect={(id) => setChatScope(id)}
|
||||
placeholder={t('ai.chatNotebookSelectPlaceholder')}
|
||||
className="w-full"
|
||||
size="sm"
|
||||
dropUp
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<label className="text-[10px] uppercase tracking-[0.25em] font-bold text-foreground/40 px-1">{t('ai.chatPanelWritingTone')}</label>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{TONES.map((tone) => {
|
||||
const Icon = tone.icon
|
||||
const isActive = selectedTone === tone.id
|
||||
return (
|
||||
<button
|
||||
key={tone.id}
|
||||
onClick={() => setSelectedTone(tone.id)}
|
||||
className={cn(
|
||||
'h-[52px] rounded-xl border transition-all flex flex-col items-center justify-center gap-1.5 shadow-sm',
|
||||
isActive
|
||||
? 'bg-memento-blue/10 border-memento-blue text-memento-blue'
|
||||
: 'bg-card/60 border-border text-foreground/40 hover:border-foreground/20'
|
||||
)}
|
||||
>
|
||||
<Icon size={14} className={isActive ? 'text-memento-blue' : 'text-foreground/40'} />
|
||||
<span className="text-[9px] font-bold uppercase tracking-tight">{tone.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<textarea
|
||||
rows={4}
|
||||
className="w-full bg-card/60 border border-border rounded-2xl p-5 pr-14 text-sm outline-none focus:border-memento-blue transition-all resize-none leading-relaxed font-light custom-scrollbar shadow-sm text-foreground"
|
||||
placeholder={t('ai.chatNoteQuestionPlaceholder')}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() } }}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<div className="absolute right-4 bottom-4 flex gap-2">
|
||||
<button
|
||||
onClick={() => setWebSearch(!webSearch)}
|
||||
className={cn("p-2.5 rounded-xl transition-colors", webSearch ? "text-memento-blue bg-memento-blue/10" : "text-foreground/20 hover:text-foreground")}
|
||||
title={t('ai.webSearchLabel')}
|
||||
>
|
||||
<Globe size={18} />
|
||||
</button>
|
||||
<button onClick={handleSend} disabled={!input.trim() || isLoading} className="p-2.5 bg-memento-blue text-white rounded-xl transition-all hover:scale-105 active:scale-95 shadow-lg shadow-memento-blue/20 disabled:opacity-30">
|
||||
<Send size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[9px] text-foreground/30 text-center mt-2 uppercase tracking-[0.2em] font-bold italic">{t('ai.newLineHint')}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeTab === 'actions' && (
|
||||
<motion.div
|
||||
<motion.div
|
||||
key="actions"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
@@ -804,31 +789,31 @@ export function ContextualAIChat({
|
||||
className="flex flex-col flex-1 overflow-y-auto p-6 space-y-10 custom-scrollbar"
|
||||
>
|
||||
{notebookId && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
<h4 className="text-[9px] uppercase tracking-[0.3em] font-bold text-foreground/40 whitespace-nowrap">{t('ai.organization') || 'Organisation'}</h4>
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRegenerateLabels}
|
||||
className="w-full flex items-center gap-3 p-4 bg-card border border-border rounded-xl transition-all hover:border-memento-blue/30 cursor-pointer"
|
||||
>
|
||||
<div className="p-2 bg-card rounded-lg text-memento-blue shrink-0"><TagIcon size={18} /></div>
|
||||
<div className="flex-1 text-left">
|
||||
<h5 className="text-[10px] font-bold text-foreground">{t('ai.autoLabels.regenerate') || 'Labels IA'}</h5>
|
||||
<p className="text-[8px] text-foreground/40 uppercase tracking-tight">{notebookName || ''}</p>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
<h4 className="text-[10px] uppercase tracking-[0.25em] font-bold text-concrete whitespace-nowrap">{t('ai.organization') || 'Organisation'}</h4>
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
</div>
|
||||
<RefreshCw size={14} className="text-memento-blue shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRegenerateLabels}
|
||||
className="w-full flex items-center gap-3 p-4 bg-white/50 dark:bg-white/5 border border-border rounded-xl transition-all hover:border-brand-accent/30 cursor-pointer"
|
||||
>
|
||||
<div className="p-2 bg-slate-50 dark:bg-white/10 rounded-lg text-brand-accent shrink-0"><TagIcon size={18} /></div>
|
||||
<div className="flex-1 text-left">
|
||||
<h5 className="text-[10px] font-bold text-ink">{t('ai.autoLabels.regenerate') || 'Labels IA'}</h5>
|
||||
<p className="text-[8px] text-concrete uppercase tracking-tight">{notebookName || ''}</p>
|
||||
</div>
|
||||
<RefreshCw size={14} className="text-brand-accent shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
<h4 className="text-[9px] uppercase tracking-[0.3em] font-bold text-foreground/40 whitespace-nowrap">{t('ai.transformations')}</h4>
|
||||
<h4 className="text-[10px] uppercase tracking-[0.25em] font-bold text-concrete whitespace-nowrap">{t('ai.transformations')}</h4>
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
</div>
|
||||
|
||||
@@ -837,7 +822,7 @@ export function ContextualAIChat({
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
onClick={onUndoLastAction}
|
||||
className="w-full py-3.5 bg-memento-blue/20 border border-memento-blue/50 rounded-xl flex items-center justify-center gap-2 text-[11px] font-bold text-memento-blue uppercase tracking-[0.2em] hover:bg-memento-blue/30 transition-all shadow-md"
|
||||
className="w-full py-3.5 bg-brand-accent/20 border border-brand-accent/50 rounded-xl flex items-center justify-center gap-2 text-[11px] font-bold text-brand-accent uppercase tracking-[0.2em] hover:bg-brand-accent/30 transition-all shadow-md"
|
||||
>
|
||||
<RotateCcw size={12} /> {t('ai.undoLastAction')}
|
||||
</motion.button>
|
||||
@@ -848,25 +833,25 @@ export function ContextualAIChat({
|
||||
const isActive = action.id === 'translate' && showLangPicker
|
||||
const Icon = action.icon
|
||||
return (
|
||||
<button key={i} onClick={() => action.id === 'translate' ? setShowLangPicker(v => !v) : handleAction(action)} disabled={!!actionLoading} className={cn("flex flex-col items-center gap-3 p-4 bg-card/40 backdrop-blur-sm border rounded-xl transition-all group shadow-sm", isActive ? "border-memento-blue bg-memento-blue/5" : "border-border hover:border-foreground/20")}>
|
||||
<div className={cn("p-2 rounded-lg bg-card/60 transition-colors group-hover:bg-foreground group-hover:text-background shadow-sm", loading && "animate-pulse", isActive && "bg-memento-blue text-white")}>
|
||||
<button key={i} onClick={() => action.id === 'translate' ? setShowLangPicker(v => !v) : handleAction(action)} disabled={!!actionLoading} className={cn("flex flex-col items-center gap-3 p-4 bg-white/50 dark:bg-white/5 border border-border rounded-xl transition-all group hover:border-ink/20 shadow-sm", isActive ? "border-brand-accent bg-brand-accent/5" : "")}>
|
||||
<div className={cn("p-2 rounded-lg bg-slate-50 dark:bg-white/10 transition-colors group-hover:bg-brand-accent group-hover:text-white shadow-sm text-concrete", loading && "animate-pulse", isActive && "bg-brand-accent text-white")}>
|
||||
{loading ? <Loader2 size={14} className="animate-spin" /> : <Icon size={14} />}
|
||||
</div>
|
||||
<span className={cn("text-[10px] font-bold uppercase tracking-widest", isActive ? "text-memento-blue" : "text-foreground/80")}>{t(action.i18nKey)}</span>
|
||||
<span className={cn("text-[10px] font-bold text-ink/80 uppercase tracking-widest", isActive ? "text-brand-accent" : "")}>{t(action.i18nKey)}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{showLangPicker && (
|
||||
<motion.div
|
||||
<motion.div
|
||||
key="lang-picker"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="col-span-2 overflow-hidden"
|
||||
>
|
||||
<div className="mt-2 p-5 bg-card/40 backdrop-blur-sm border border-memento-blue/30 rounded-2xl space-y-5 shadow-sm">
|
||||
<div className="mt-2 p-5 bg-white/50 dark:bg-white/5 border border-brand-accent/30 rounded-2xl space-y-5">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{TRANSLATE_LANGUAGE_OPTIONS.map(({ api, labelKey }) => (
|
||||
<button
|
||||
@@ -875,18 +860,18 @@ export function ContextualAIChat({
|
||||
className={cn(
|
||||
"py-2 px-1 rounded-lg border text-[10px] font-bold uppercase tracking-tighter transition-all",
|
||||
translateTarget === api
|
||||
? "bg-memento-blue border-memento-blue text-white shadow-md shadow-memento-blue/20"
|
||||
: "bg-card/60 border-border text-foreground/60 hover:border-foreground/20"
|
||||
? "bg-brand-accent border-brand-accent text-white shadow-md shadow-brand-accent/20"
|
||||
: "bg-white/50 dark:bg-white/5 border-border text-concrete hover:border-ink/20"
|
||||
)}
|
||||
>
|
||||
{t(labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.otherLanguage')}</span>
|
||||
<input
|
||||
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-concrete px-1">{t('ai.otherLanguage')}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={customLangInput}
|
||||
onChange={(e) => {
|
||||
@@ -894,14 +879,14 @@ export function ContextualAIChat({
|
||||
setTranslateTarget(e.target.value)
|
||||
}}
|
||||
placeholder={t('languages.customPlaceholder')}
|
||||
className="w-full bg-card/60 border border-border rounded-xl px-4 py-2.5 text-[11px] outline-none focus:border-memento-blue transition-all text-foreground"
|
||||
className="w-full bg-white/50 dark:bg-white/5 border border-border rounded-xl px-4 py-2.5 text-[11px] outline-none focus:border-brand-accent transition-all text-ink"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
<button
|
||||
onClick={() => handleAction(ACTION_IDS.find(a => a.id === 'translate')!, translateTarget)}
|
||||
disabled={!translateTarget || !!actionLoading}
|
||||
className="w-full py-3 bg-memento-blue text-white rounded-xl text-[10px] font-bold uppercase tracking-[0.2em] flex items-center justify-center gap-2 hover:opacity-90 disabled:opacity-50 shadow-lg shadow-memento-blue/20"
|
||||
className="w-full py-3 bg-brand-accent text-white rounded-xl text-[10px] font-bold uppercase tracking-[0.2em] flex items-center justify-center gap-2 hover:opacity-90 disabled:opacity-50 shadow-lg shadow-brand-accent/20"
|
||||
>
|
||||
<Languages size={14} /> {t('ai.translateNow')}
|
||||
</button>
|
||||
@@ -911,21 +896,21 @@ export function ContextualAIChat({
|
||||
</AnimatePresence>
|
||||
|
||||
{diagramInsertFormat === 'html' ? (
|
||||
<button
|
||||
onClick={() => handleAction(ACTION_IDS.find(a => a.id === 'markdown')!)}
|
||||
<button
|
||||
onClick={() => handleAction(ACTION_IDS.find(a => a.id === 'markdown')!)}
|
||||
disabled={!!actionLoading}
|
||||
className="col-span-2 flex items-center justify-center gap-3 py-3.5 bg-card/40 backdrop-blur-sm border border-border rounded-xl text-[10px] font-bold text-foreground/80 hover:bg-card/60 transition-all uppercase tracking-[0.2em] shadow-sm disabled:opacity-50"
|
||||
className="col-span-2 flex items-center justify-center gap-3 py-3.5 bg-white/50 dark:bg-white/5 border border-border rounded-xl text-[10px] font-bold text-ink/80 hover:bg-white dark:hover:bg-white/10 transition-all hover:border-ink/20 uppercase tracking-[0.2em] shadow-sm disabled:opacity-50"
|
||||
>
|
||||
<Code size={14} className="text-foreground/40" />
|
||||
<Code size={14} className="text-concrete" />
|
||||
{actionLoading === 'markdown' ? <Loader2 size={14} className="animate-spin" /> : t('ai.action.toMarkdown')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleAction(ACTION_IDS.find(a => a.id === 'toRichText')!)}
|
||||
<button
|
||||
onClick={() => handleAction(ACTION_IDS.find(a => a.id === 'toRichText')!)}
|
||||
disabled={!!actionLoading}
|
||||
className="col-span-2 flex items-center justify-center gap-3 py-3.5 bg-card/40 backdrop-blur-sm border border-border rounded-xl text-[10px] font-bold text-foreground/80 hover:bg-card/60 transition-all uppercase tracking-[0.2em] shadow-sm disabled:opacity-50"
|
||||
className="col-span-2 flex items-center justify-center gap-3 py-3.5 bg-white/50 dark:bg-white/5 border border-border rounded-xl text-[10px] font-bold text-ink/80 hover:bg-white dark:hover:bg-white/10 transition-all hover:border-ink/20 uppercase tracking-[0.2em] shadow-sm disabled:opacity-50"
|
||||
>
|
||||
<Wand2 size={14} className="text-foreground/40" />
|
||||
<Wand2 size={14} className="text-concrete" />
|
||||
{actionLoading === 'toRichText' ? <Loader2 size={14} className="animate-spin" /> : t('ai.action.toRichText')}
|
||||
</button>
|
||||
)}
|
||||
@@ -935,17 +920,17 @@ export function ContextualAIChat({
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
<h4 className="text-[9px] uppercase tracking-[0.3em] font-bold text-foreground/40 whitespace-nowrap">{t('ai.generationTools')}</h4>
|
||||
<h4 className="text-[10px] uppercase tracking-[0.25em] font-bold text-concrete whitespace-nowrap">{t('ai.generationTools')}</h4>
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
</div>
|
||||
|
||||
<div className="group relative p-6 rounded-2xl bg-card/40 backdrop-blur-sm border border-border hover:border-memento-blue/30 transition-all duration-500 overflow-hidden shadow-sm">
|
||||
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-brand-accent/30 transition-all duration-500 overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
||||
<Layout size={80} className="text-memento-blue" />
|
||||
<Layout size={80} className="text-brand-accent" />
|
||||
</div>
|
||||
<div className="relative space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-card/60 rounded-lg text-memento-blue"><Layout size={18} /></div>
|
||||
<div className="p-2 bg-slate-50 rounded-lg text-brand-accent"><Layout size={18} /></div>
|
||||
<div className="space-y-0.5">
|
||||
<h5 className="text-sm font-bold text-foreground leading-none">{t('ai.generate.slides')}</h5>
|
||||
<p className="text-[9px] text-foreground/40 uppercase tracking-tight">{t('ai.generate.sectionLabel')}</p>
|
||||
@@ -954,7 +939,7 @@ export function ContextualAIChat({
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.theme')}</span>
|
||||
<select value={slideTheme} onChange={e => setSlideTheme(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-memento-blue/10 transition-all cursor-pointer text-foreground">
|
||||
<select value={slideTheme} onChange={e => setSlideTheme(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
|
||||
<option value="architectural_mono">{t('ai.generate.themeArchitecturalMono')}</option>
|
||||
<option value="vibrant_tech">{t('ai.generate.themeVibrantTech')}</option>
|
||||
<option value="minimal_silk">{t('ai.generate.themeMinimalSilk')}</option>
|
||||
@@ -962,25 +947,25 @@ export function ContextualAIChat({
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.style')}</span>
|
||||
<select value={slideStyle} onChange={e => setSlideStyle(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-memento-blue/10 transition-all cursor-pointer text-foreground">
|
||||
<select value={slideStyle} onChange={e => setSlideStyle(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
|
||||
<option value="professional">{t('ai.generate.styleProfessional')}</option>
|
||||
<option value="creative">{t('ai.generate.styleCreative')}</option>
|
||||
<option value="brutalist">{t('ai.generate.styleBrutalist')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => handleGenerate('slides')} disabled={!!generateLoading} className="w-full py-3.5 bg-memento-blue text-white rounded-xl text-[10px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-memento-blue/20 uppercase tracking-[0.2em] disabled:opacity-50">
|
||||
<button onClick={() => handleGenerate('slides')} disabled={!!generateLoading} className="w-full py-3.5 bg-brand-accent text-white rounded-xl text-[10px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-brand-accent/20 uppercase tracking-[0.2em] disabled:opacity-50">
|
||||
{generateLoading === 'slides' ? <Loader2 size={14} className="animate-spin" /> : <><Presentation size={14} className="opacity-80" /> {t('ai.generating')}</>}
|
||||
</button>
|
||||
|
||||
{generateResult?.type === 'slides' && generateResult.canvasId && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mt-4 p-4 bg-memento-blue/10 border border-memento-blue/20 rounded-xl space-y-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[9px] font-bold text-memento-blue uppercase tracking-widest flex items-center gap-1.5">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mt-4 p-4 bg-brand-accent/10 border border-brand-accent/20 rounded-xl space-y-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[9px] font-bold text-brand-accent uppercase tracking-widest flex items-center gap-1.5">
|
||||
<Check size={12} /> {t('ai.presentationReadyBadge')}
|
||||
</span>
|
||||
<a
|
||||
@@ -1017,7 +1002,7 @@ export function ContextualAIChat({
|
||||
mToast.error(t('ai.downloadFailedToast'))
|
||||
}
|
||||
}}
|
||||
className="flex items-center justify-center gap-2 w-full py-2.5 bg-memento-blue text-white rounded-lg text-[10px] font-bold uppercase tracking-[0.15em] hover:opacity-90 transition-opacity shadow-sm"
|
||||
className="flex items-center justify-center gap-2 w-full py-2.5 bg-brand-accent text-white rounded-lg text-[10px] font-bold uppercase tracking-[0.15em] hover:opacity-90 transition-opacity shadow-sm"
|
||||
>
|
||||
<Download size={13} />
|
||||
{t('ai.pptxDownloadButton')}
|
||||
@@ -1028,13 +1013,13 @@ export function ContextualAIChat({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="group relative p-6 rounded-2xl bg-card/40 backdrop-blur-sm border border-border hover:border-primary/30 transition-all duration-500 overflow-hidden shadow-sm">
|
||||
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-brand-accent/30 transition-all duration-500 overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
||||
<BookOpen size={80} className="text-primary" />
|
||||
<BookOpen size={80} className="text-brand-accent" />
|
||||
</div>
|
||||
<div className="relative space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-card/60 rounded-lg text-primary"><BookOpen size={18} /></div>
|
||||
<div className="p-2 bg-slate-50 rounded-lg text-brand-accent"><BookOpen size={18} /></div>
|
||||
<div className="space-y-0.5">
|
||||
<h5 className="text-sm font-bold text-foreground leading-none">{t('ai.generate.diagram')}</h5>
|
||||
<p className="text-[9px] text-foreground/40 uppercase tracking-tight">{t('ai.generate.diagramReadyHint')}</p>
|
||||
@@ -1043,7 +1028,7 @@ export function ContextualAIChat({
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.diagramType')}</span>
|
||||
<select value={diagramType} onChange={e => setDiagramType(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-primary/10 transition-all cursor-pointer text-foreground">
|
||||
<select value={diagramType} onChange={e => setDiagramType(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
|
||||
<option value="auto">{t('ai.generate.typeAuto')}</option>
|
||||
<option value="flowchart">{t('ai.generate.typeFlowchart')}</option>
|
||||
<option value="mind_map">{t('ai.generate.typeMindMap')}</option>
|
||||
@@ -1055,7 +1040,7 @@ export function ContextualAIChat({
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.style')}</span>
|
||||
<select value={diagramStyle} onChange={e => setDiagramStyle(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-primary/10 transition-all cursor-pointer text-foreground">
|
||||
<select value={diagramStyle} onChange={e => setDiagramStyle(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
|
||||
<option value="sketchy">{t('ai.generate.styleSketchy')}</option>
|
||||
<option value="soft">{t('ai.generate.styleSoft')}</option>
|
||||
<option value="minimal">{t('ai.generate.styleMinimal')}</option>
|
||||
@@ -1066,20 +1051,20 @@ export function ContextualAIChat({
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => handleGenerate('diagram')} disabled={!!generateLoading} className="w-full py-3.5 bg-primary text-primary-foreground rounded-xl text-[10px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-primary/20 uppercase tracking-[0.2em] disabled:opacity-50">
|
||||
<button onClick={() => handleGenerate('diagram')} disabled={!!generateLoading} className="w-full py-3.5 bg-brand-accent text-white rounded-xl text-[10px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-brand-accent/20 uppercase tracking-[0.2em] disabled:opacity-50">
|
||||
{generateLoading === 'diagram' ? <Loader2 size={14} className="animate-spin" /> : <>{t('ai.generating')} <ArrowRightLeft size={14} className="opacity-60" /></>}
|
||||
</button>
|
||||
|
||||
{generateResult?.type === 'diagram' && generateResult.canvasId && (
|
||||
<motion.div
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mt-4 p-4 bg-primary/10 border border-primary/20 rounded-xl space-y-3"
|
||||
className="mt-4 p-4 bg-brand-accent/10 border border-brand-accent/20 rounded-xl space-y-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[9px] font-bold text-primary uppercase tracking-widest">{t('ai.generate.diagramReady')}</span>
|
||||
<span className="text-[9px] font-bold text-brand-accent uppercase tracking-widest">{t('ai.generate.diagramReady')}</span>
|
||||
<div className="flex gap-2">
|
||||
<a
|
||||
<a
|
||||
href={`/lab?id=${generateResult.canvasId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@@ -1088,10 +1073,10 @@ export function ContextualAIChat({
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</a>
|
||||
<button
|
||||
<button
|
||||
onClick={() => handleEmbedDiagramInNote(generateResult.canvasId!)}
|
||||
disabled={diagramEmbedLoading}
|
||||
className="p-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
className="p-2 bg-brand-accent text-white rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
title={t('ai.generate.insertDiagramInNote')}
|
||||
>
|
||||
{diagramEmbedLoading ? <Loader2 size={14} className="animate-spin" /> : <ImagePlus size={14} />}
|
||||
@@ -1105,14 +1090,14 @@ export function ContextualAIChat({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-4 py-8 opacity-20">
|
||||
<PenTool size={20} />
|
||||
<span className="text-[9px] font-bold uppercase tracking-[0.3em] whitespace-nowrap italic text-center">{t('nav.workspace')}</span>
|
||||
<PenTool size={20} />
|
||||
<span className="text-[9px] font-bold uppercase tracking-[0.3em] whitespace-nowrap italic text-center">{t('nav.workspace')}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeTab === 'resource' && (
|
||||
<motion.div
|
||||
<motion.div
|
||||
key="resource"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
@@ -1121,32 +1106,32 @@ export function ContextualAIChat({
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<label className="text-[9px] uppercase tracking-[0.3em] font-bold text-foreground/40">{t('ai.resource.urlLabel')}</label>
|
||||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-concrete">{t('ai.resource.urlLabel')}</label>
|
||||
<div className="relative">
|
||||
<input type="text" placeholder="https://..." className="w-full bg-card/40 backdrop-blur-sm border border-border rounded-xl pl-4 pr-12 py-3.5 text-xs outline-none focus:border-memento-blue transition-colors shadow-sm text-foreground" value={resourceUrl} onChange={e => setResourceUrl(e.target.value)} />
|
||||
<Globe size={16} className="absolute right-4 top-1/2 -translate-y-1/2 text-foreground/20" />
|
||||
<input type="text" placeholder="https://..." className="w-full bg-white/50 dark:bg-white/5 border border-border rounded-xl pl-4 pr-12 py-3 text-xs outline-none focus:border-brand-accent transition-colors text-ink" value={resourceUrl} onChange={e => setResourceUrl(e.target.value)} />
|
||||
<Globe size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-concrete/40" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<label className="text-[9px] uppercase tracking-[0.3em] font-bold text-foreground/40">{t('ai.resource.resourceText')}</label>
|
||||
<textarea rows={10} placeholder={t('ai.resource.resourcePlaceholder')} className="w-full bg-card/40 backdrop-blur-sm border border-border rounded-xl p-5 text-sm outline-none focus:border-memento-blue transition-colors resize-none leading-relaxed font-light shadow-sm text-foreground" value={resourceText} onChange={e => setResourceText(e.target.value)} />
|
||||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-concrete">{t('ai.resource.resourceText')}</label>
|
||||
<textarea rows={10} placeholder={t('ai.resource.resourcePlaceholder')} className="w-full bg-white/50 dark:bg-white/5 border border-border rounded-xl p-4 text-xs outline-none focus:border-brand-accent transition-colors resize-none leading-relaxed text-ink" value={resourceText} onChange={e => setResourceText(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<label className="text-[9px] uppercase tracking-[0.3em] font-bold text-foreground/40">{t('ai.resource.integrationMode')}</label>
|
||||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-concrete">{t('ai.resource.integrationMode')}</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{[
|
||||
{ id: 'replace', label: t('ai.resource.modeReplace'), sub: t('ai.resource.modeReplaceDesc') },
|
||||
{ id: 'complete', label: t('ai.resource.modeComplete'), sub: t('ai.resource.modeCompleteDesc') },
|
||||
{ id: 'merge', label: t('ai.resource.modeMerge'), sub: t('ai.resource.modeMergeDesc') },
|
||||
].map((mode) => (
|
||||
<button key={mode.id} onClick={() => setResourceMode(mode.id as any)} className={cn("flex flex-col items-center justify-center p-3 rounded-xl border transition-all text-center", resourceMode === mode.id ? 'bg-memento-blue/10 border-memento-blue ring-1 ring-memento-blue/20' : 'bg-card/40 backdrop-blur-sm border-border hover:bg-card/60 shadow-sm')}>
|
||||
<span className={cn("text-[10px] font-bold uppercase tracking-wider", resourceMode === mode.id ? 'text-memento-blue' : 'text-foreground')}>{mode.label}</span>
|
||||
<span className="text-[8px] text-foreground/40 leading-tight mt-1 font-medium">{mode.sub}</span>
|
||||
<button key={mode.id} onClick={() => setResourceMode(mode.id as any)} className={cn("flex flex-col items-center justify-center p-3 rounded-xl border transition-all text-center", resourceMode === mode.id ? 'bg-brand-accent/5 border-brand-accent/30' : 'bg-white border-border hover:bg-slate-50 dark:hover:bg-white/5')}>
|
||||
<span className={cn("text-[10px] font-bold", resourceMode === mode.id ? 'text-brand-accent' : 'text-ink')}>{mode.label}</span>
|
||||
<span className="text-[8px] text-concrete opacity-60 leading-tight mt-1 font-medium">{mode.sub}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleResourcePreview} disabled={resourceEnriching || resourceScraping || !resourceText.trim()} className="w-full py-4 bg-memento-blue text-white rounded-xl text-[11px] font-bold uppercase tracking-[0.2em] flex items-center justify-center gap-3 hover:opacity-90 transition-opacity shadow-lg shadow-memento-blue/20 disabled:opacity-50">
|
||||
<button onClick={handleResourcePreview} disabled={resourceEnriching || resourceScraping || !resourceText.trim()} className="w-full py-4 bg-brand-accent text-white rounded-xl text-[11px] font-bold uppercase tracking-[0.2em] flex items-center justify-center gap-3 hover:opacity-90 transition-opacity shadow-lg shadow-brand-accent/20 disabled:opacity-50">
|
||||
{resourceEnriching || resourceScraping ? <Loader2 size={18} className="animate-spin" /> : <Sparkles size={18} />}
|
||||
{t('ai.resource.generatePreview')}
|
||||
</button>
|
||||
@@ -1155,6 +1140,54 @@ export function ContextualAIChat({
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Textarea — only on chat tab */}
|
||||
<AnimatePresence>
|
||||
{activeTab === 'chat' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="p-6 bg-white/40 dark:bg-black/20 border-t border-border backdrop-blur-xl shrink-0"
|
||||
>
|
||||
<div className="relative group/chat">
|
||||
<textarea
|
||||
rows={4}
|
||||
placeholder={t('ai.chatPlaceholder')}
|
||||
className="w-full bg-white/80 dark:bg-white/5 border border-border rounded-[24px] p-5 pr-14 text-sm outline-none focus:border-brand-accent focus:ring-4 ring-brand-accent/5 transition-all resize-none leading-relaxed font-light shadow-inner text-ink"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
|
||||
}}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<div className="absolute right-4 bottom-4 flex flex-col gap-2">
|
||||
{isLoading ? (
|
||||
<button onClick={() => stop()} className="p-2.5 bg-rose-500 text-white rounded-xl transition-all hover:scale-110 active:scale-95 shadow-lg shadow-rose-500/20">
|
||||
<Square size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={handleSend} disabled={!input.trim()} className="p-2.5 bg-brand-accent text-white rounded-xl transition-all hover:scale-110 active:scale-95 shadow-lg shadow-brand-accent/20 disabled:opacity-40 disabled:pointer-events-none">
|
||||
<Send size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute left-6 bottom-4 flex gap-3 text-concrete/40">
|
||||
<button
|
||||
onClick={() => webSearchAvailable && setWebSearch(!webSearch)}
|
||||
className={cn("hover:text-brand-accent transition-colors", webSearch && "text-brand-accent")}
|
||||
>
|
||||
<Globe size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center mt-4">
|
||||
<p className="text-[9px] text-concrete/40 uppercase tracking-[0.3em] font-bold">Shift+Enter for new line</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</aside>
|
||||
|
||||
{autoLabelOpen && notebookId && (
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { FlaskConical, Zap, Target, Lightbulb } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
@@ -34,68 +32,54 @@ export function DemoModeToggle({ demoMode, onToggle }: DemoModeToggleProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={`border-2 transition-all ${
|
||||
<div className={`border rounded-2xl p-8 flex items-center justify-between group transition-all duration-300 ${
|
||||
demoMode
|
||||
? 'border-amber-300 bg-gradient-to-br from-amber-50 to-white dark:from-amber-950/30 dark:to-background'
|
||||
: 'border-amber-100 dark:border-amber-900/30'
|
||||
? 'bg-ochre/10 dark:bg-ochre/10 border-ochre/30'
|
||||
: 'bg-ochre/5 dark:bg-ochre/10 border-ochre/20 hover:bg-ochre/10'
|
||||
}`}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`p-2 rounded-full transition-colors ${
|
||||
demoMode
|
||||
? 'bg-amber-200 dark:bg-amber-900/50'
|
||||
: 'bg-gray-100 dark:bg-gray-800'
|
||||
}`}>
|
||||
<FlaskConical className={`h-5 w-5 ${
|
||||
demoMode ? 'text-amber-600 dark:text-amber-400' : 'text-gray-500'
|
||||
}`} />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
🧪 {t('demoMode.title')}
|
||||
{demoMode && <Zap className="h-4 w-4 text-amber-500 animate-pulse" />}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs mt-1">
|
||||
{t('demoMode.description')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={demoMode}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={isPending}
|
||||
className="data-[state=checked]:bg-amber-600"
|
||||
/>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="p-3 bg-paper dark:bg-ochre/20 rounded-2xl text-ochre border border-ochre/30">
|
||||
<FlaskConical size={20} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{demoMode && (
|
||||
<CardContent className="pt-0 space-y-2">
|
||||
<div className="rounded-lg bg-white dark:bg-zinc-900 border border-amber-200 dark:border-amber-900/30 p-3">
|
||||
<p className="text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('demoMode.parametersActive')}
|
||||
</p>
|
||||
<div className="space-y-1.5 text-xs text-gray-600 dark:text-gray-400">
|
||||
<div className="flex items-start gap-2">
|
||||
<Target className="h-3.5 w-3.5 mt-0.5 text-amber-600 flex-shrink-0" />
|
||||
<span>{t('demoMode.similarityThreshold')}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Zap className="h-3.5 w-3.5 mt-0.5 text-amber-600 flex-shrink-0" />
|
||||
<span>{t('demoMode.delayBetweenNotes')}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Lightbulb className="h-3.5 w-3.5 mt-0.5 text-amber-600 flex-shrink-0" />
|
||||
<span>{t('demoMode.unlimitedInsights')}</span>
|
||||
<div className="space-y-1.5 text-left">
|
||||
<h4 className="text-sm font-bold text-ink flex items-center gap-3">
|
||||
🧪 {t('demoMode.title')}
|
||||
{demoMode && <Zap className="h-4 w-4 text-ochre animate-pulse" />}
|
||||
</h4>
|
||||
<p className="text-[11px] text-concrete leading-relaxed font-medium">
|
||||
{t('demoMode.description')}
|
||||
</p>
|
||||
{demoMode && (
|
||||
<div className="mt-3 rounded-lg bg-white/60 dark:bg-black/20 border border-ochre/20 p-3 space-y-1.5">
|
||||
<p className="text-[10px] font-bold text-ink uppercase tracking-wider">{t('demoMode.parametersActive')}</p>
|
||||
<div className="space-y-1 text-[10px] text-concrete">
|
||||
<div className="flex items-start gap-2">
|
||||
<Target className="h-3 w-3 mt-0.5 text-ochre shrink-0" />
|
||||
<span>{t('demoMode.similarityThreshold')}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Zap className="h-3 w-3 mt-0.5 text-ochre shrink-0" />
|
||||
<span>{t('demoMode.delayBetweenNotes')}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Lightbulb className="h-3 w-3 mt-0.5 text-ochre shrink-0" />
|
||||
<span>{t('demoMode.unlimitedInsights')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-amber-700 dark:text-amber-400 text-center">
|
||||
💡 {t('demoMode.createNotesTip')}
|
||||
</p>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer shrink-0 ml-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={demoMode}
|
||||
onChange={(e) => handleToggle(e.target.checked)}
|
||||
disabled={isPending}
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-ochre" />
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export function GhostTags({ suggestions, addedTags, isAnalyzing, onSelectTag, on
|
||||
return (
|
||||
<div className={cn("flex flex-wrap items-center gap-2 mt-2 min-h-[24px] transition-all duration-500", className)}>
|
||||
{isAnalyzing && (
|
||||
<div className="flex items-center gap-1.5 text-memento-blue animate-pulse">
|
||||
<div className="flex items-center gap-1.5 text-brand-accent animate-pulse">
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
<span className="text-[9px] font-bold uppercase tracking-wider">{t('ai.analyzing')}</span>
|
||||
</div>
|
||||
@@ -39,7 +39,7 @@ export function GhostTags({ suggestions, addedTags, isAnalyzing, onSelectTag, on
|
||||
return (
|
||||
<div
|
||||
key={suggestion.tag}
|
||||
className="flex items-center px-2.5 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider border bg-memento-blue/5 border-memento-blue/20 text-memento-blue opacity-50 cursor-default"
|
||||
className="flex items-center px-2.5 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider border bg-brand-accent/5 border-brand-accent/20 text-brand-accent opacity-50 cursor-default"
|
||||
>
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
{suggestion.tag}
|
||||
@@ -50,7 +50,7 @@ export function GhostTags({ suggestions, addedTags, isAnalyzing, onSelectTag, on
|
||||
return (
|
||||
<div
|
||||
key={suggestion.tag}
|
||||
className="group flex items-center border border-dashed rounded-full transition-all cursor-pointer animate-in fade-in zoom-in duration-300 opacity-80 hover:opacity-100 border-memento-blue/20 bg-memento-blue/5"
|
||||
className="group flex items-center border border-dashed rounded-full transition-all cursor-pointer animate-in fade-in zoom-in duration-300 opacity-80 hover:opacity-100 border-brand-accent/20 bg-brand-accent/5"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -59,7 +59,7 @@ export function GhostTags({ suggestions, addedTags, isAnalyzing, onSelectTag, on
|
||||
e.stopPropagation()
|
||||
onSelectTag(suggestion.tag)
|
||||
}}
|
||||
className="flex items-center px-2.5 py-0.5 text-[9px] font-bold uppercase tracking-wider text-memento-blue"
|
||||
className="flex items-center px-2.5 py-0.5 text-[9px] font-bold uppercase tracking-wider text-brand-accent"
|
||||
title={isNewLabel ? t('ai.autoLabels.createNewLabel') : t('ai.clickToAddTag')}
|
||||
>
|
||||
{isNewLabel ? (
|
||||
@@ -80,7 +80,7 @@ export function GhostTags({ suggestions, addedTags, isAnalyzing, onSelectTag, on
|
||||
e.stopPropagation()
|
||||
onDismissTag(suggestion.tag)
|
||||
}}
|
||||
className="pr-2 pl-1 text-memento-blue/60 hover:text-red-500 transition-colors"
|
||||
className="pr-2 pl-1 text-brand-accent/60 hover:text-red-500 transition-colors"
|
||||
title={t('ai.ignoreSuggestion')}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
|
||||
@@ -111,7 +111,7 @@ export function HierarchicalNotebookSelector({
|
||||
if (!searchQuery) setIsOpen(false)
|
||||
}}
|
||||
className={`flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all group
|
||||
${isSelected ? 'bg-memento-blue/15 text-memento-blue font-bold dark:bg-memento-blue/20' : 'hover:bg-muted dark:hover:bg-white/5 text-ink'}`}
|
||||
${isSelected ? 'bg-brand-accent/15 text-brand-accent font-bold dark:bg-brand-accent/20' : 'hover:bg-muted dark:hover:bg-white/5 text-ink'}`}
|
||||
>
|
||||
<div className="w-4 flex items-center justify-center">
|
||||
{hasChildren ? (
|
||||
@@ -124,7 +124,7 @@ export function HierarchicalNotebookSelector({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={`p-1 rounded ${isSelected ? 'bg-memento-blue/25 dark:bg-memento-blue/30' : 'bg-muted/50 dark:bg-white/5 group-hover:bg-white/40'}`}>
|
||||
<div className={`p-1 rounded ${isSelected ? 'bg-brand-accent/25 dark:bg-brand-accent/30' : 'bg-muted/50 dark:bg-white/5 group-hover:bg-white/40'}`}>
|
||||
{isExpanded && hasChildren ? <FolderOpen size={13} /> : <Folder size={13} />}
|
||||
</div>
|
||||
|
||||
@@ -157,9 +157,9 @@ export function HierarchicalNotebookSelector({
|
||||
<div
|
||||
ref={triggerRef}
|
||||
onClick={() => setIsOpen(prev => !prev)}
|
||||
className={`w-full bg-card dark:bg-white/5 border border-border/80 rounded-xl outline-none focus:ring-4 ring-memento-blue/10 focus:border-memento-blue/40 transition-all cursor-pointer text-ink flex items-center gap-3 ${size === 'sm' ? 'px-3 py-2 text-xs' : 'px-4 py-3 text-sm'}`}
|
||||
className={`w-full bg-card dark:bg-white/5 border border-border/80 rounded-xl outline-none focus:ring-4 ring-brand-accent/10 focus:border-brand-accent/40 transition-all cursor-pointer text-ink flex items-center gap-3 ${size === 'sm' ? 'px-3 py-2 text-xs' : 'px-4 py-3 text-sm'}`}
|
||||
>
|
||||
<Folder size={size === 'sm' ? 14 : 16} className="text-memento-blue/70 shrink-0" />
|
||||
<Folder size={size === 'sm' ? 14 : 16} className="text-brand-accent/70 shrink-0" />
|
||||
<div className="flex-1 flex items-center gap-1 min-w-0">
|
||||
{path.length > 0 ? (
|
||||
<div className="flex items-center gap-1.5 truncate">
|
||||
@@ -201,7 +201,7 @@ export function HierarchicalNotebookSelector({
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full bg-card border border-border rounded-lg pl-9 pr-4 py-2 text-xs outline-none focus:border-memento-blue transition-colors"
|
||||
className="w-full bg-card border border-border rounded-lg pl-9 pr-4 py-2 text-xs outline-none focus:border-brand-accent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -216,7 +216,7 @@ export function HierarchicalNotebookSelector({
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-[10px] font-bold text-memento-blue hover:underline"
|
||||
className="text-[10px] font-bold text-brand-accent hover:underline"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
|
||||
@@ -23,8 +23,9 @@ import { NoteHistoryModal } from '@/components/note-history-modal'
|
||||
import { CreateNotebookDialog } from '@/components/create-notebook-dialog'
|
||||
import { toast } from 'sonner'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { PageEntry } from '@/components/page-entry'
|
||||
|
||||
type SortOrder = 'newest' | 'oldest' | 'alpha'
|
||||
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
|
||||
|
||||
const NoteEditor = dynamic(
|
||||
() => import('@/components/note-editor').then(m => ({ default: m.NoteEditor })),
|
||||
@@ -409,6 +410,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
if (sortOrder === 'newest') sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
if (sortOrder === 'oldest') sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||
if (sortOrder === 'alpha') sorted.sort((a, b) => (a.title || '').localeCompare(b.title || ''))
|
||||
if (sortOrder === 'manual') sorted.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||
|
||||
if (selectedTagIds.length > 0) {
|
||||
const selectedNames = selectedTagIds
|
||||
@@ -430,6 +432,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
newest: t('sidebar.sortNewest') || 'Plus récentes',
|
||||
oldest: t('sidebar.sortOldest') || 'Plus anciennes',
|
||||
alpha: t('sidebar.sortAlpha') || 'A → Z',
|
||||
manual: t('sidebar.sortManual') || 'Libre',
|
||||
}
|
||||
|
||||
|
||||
@@ -454,6 +457,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
}, [refreshNotes])
|
||||
|
||||
return (
|
||||
<PageEntry>
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full min-h-0 flex-1 flex-col gap-3 py-1'
|
||||
@@ -599,7 +603,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
{currentNotebook && initialSettings.aiAssistantEnabled && (
|
||||
<button
|
||||
onClick={() => setOrganizeNotebookOpen(true)}
|
||||
className="flex items-center gap-2 text-[13px] text-blueprint font-medium hover:opacity-70 transition-opacity"
|
||||
className="flex items-center gap-2 text-[13px] text-brand-accent font-medium hover:opacity-70 transition-opacity"
|
||||
title={t('notebook.organizeNotebookWithAITooltip')}
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
@@ -623,7 +627,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setSortOrder(s => s === 'newest' ? 'oldest' : s === 'oldest' ? 'alpha' : 'newest')}
|
||||
onClick={() => setSortOrder(s => s === 'newest' ? 'oldest' : s === 'oldest' ? 'alpha' : s === 'alpha' ? 'manual' : 'newest')}
|
||||
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
|
||||
>
|
||||
<ArrowUpDown size={16} />
|
||||
@@ -639,7 +643,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
<TagIcon size={12} />
|
||||
<span>{t('labels.filterByTags') || 'Filter by Tags'}</span>
|
||||
{selectedTagIds.length > 0 && (
|
||||
<span className="bg-memento-blue/10 text-memento-blue px-2 py-0.5 rounded-full text-[9px] lowercase tracking-normal">
|
||||
<span className="bg-brand-accent/10 text-brand-accent px-2 py-0.5 rounded-full text-[9px] lowercase tracking-normal">
|
||||
{selectedTagIds.length} active
|
||||
</span>
|
||||
)}
|
||||
@@ -648,7 +652,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('labels.searchTags') || 'Search tags...'}
|
||||
className="bg-transparent border-b border-foreground/10 text-[10px] outline-none focus:border-memento-blue/40 py-1 px-2 w-32 transition-all focus:w-48 placeholder:text-muted-foreground/40"
|
||||
className="bg-transparent border-b border-foreground/10 text-[10px] outline-none focus:border-brand-accent/40 py-1 px-2 w-32 transition-all focus:w-48 placeholder:text-muted-foreground/40"
|
||||
value={tagSearchQuery}
|
||||
onChange={e => setTagSearchQuery(e.target.value)}
|
||||
/>
|
||||
@@ -677,7 +681,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
{tag.type === 'ai' && (
|
||||
<Sparkles
|
||||
size={10}
|
||||
className={isActive ? 'text-memento-blue' : 'text-memento-blue/60'}
|
||||
className={isActive ? 'text-brand-accent' : 'text-brand-accent/60'}
|
||||
/>
|
||||
)}
|
||||
{tag.name}
|
||||
@@ -833,5 +837,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PageEntry>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -135,13 +135,13 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
|
||||
<div key={label.id} className="flex items-center justify-between p-2 rounded-md hover:bg-accent/50 group">
|
||||
<div className="flex items-center gap-3 flex-1 relative">
|
||||
{isAI ? (
|
||||
<Sparkles className={cn("h-4 w-4", "text-memento-blue")} />
|
||||
<Sparkles className={cn("h-4 w-4", "text-brand-accent")} />
|
||||
) : (
|
||||
<div className={cn("h-3 w-3 rounded-full", colorClasses.bg)} />
|
||||
)}
|
||||
<span className="font-medium text-sm">{label.name}</span>
|
||||
{isAI && (
|
||||
<span className="text-[8px] px-1.5 py-0.5 rounded-full bg-memento-blue/10 text-memento-blue font-bold uppercase">IA</span>
|
||||
<span className="text-[8px] px-1.5 py-0.5 rounded-full bg-brand-accent/10 text-brand-accent font-bold uppercase">IA</span>
|
||||
)}
|
||||
|
||||
{isEditing && (
|
||||
|
||||
@@ -201,7 +201,7 @@ export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelMana
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[9px] font-bold uppercase tracking-wider cursor-pointer',
|
||||
isAI
|
||||
? 'bg-memento-blue/5 border-memento-blue/20 text-memento-blue'
|
||||
? 'bg-brand-accent/5 border-brand-accent/20 text-brand-accent'
|
||||
: `${colorClasses.bg} ${colorClasses.text} ${colorClasses.border}`
|
||||
)}
|
||||
onClick={() => setEditingColor(isEditing ? null : label)}
|
||||
@@ -244,7 +244,7 @@ export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelMana
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[9px] font-bold uppercase tracking-wider cursor-pointer transition-all hover:opacity-80',
|
||||
isAI
|
||||
? 'bg-memento-blue/5 border-memento-blue/20 text-memento-blue'
|
||||
? 'bg-brand-accent/5 border-brand-accent/20 text-brand-accent'
|
||||
: `${colorClasses.bg} ${colorClasses.text} ${colorClasses.border}`
|
||||
)}
|
||||
onClick={() => handleSelectExisting(label.name)}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -12,10 +14,6 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Info,
|
||||
Key,
|
||||
@@ -37,6 +35,8 @@ import {
|
||||
type McpKeyInfo,
|
||||
type McpServerStatus,
|
||||
} from '@/app/actions/mcp-keys'
|
||||
import { motion } from 'motion/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface McpSettingsPanelProps {
|
||||
initialKeys: McpKeyInfo[]
|
||||
@@ -54,10 +54,8 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
|
||||
try {
|
||||
const result = await generateMcpKey(name)
|
||||
setCreateOpen(false)
|
||||
// Show the raw key in a new dialog
|
||||
setShowRawKey(result.rawKey)
|
||||
setRawKeyName(result.info.name)
|
||||
// Refresh keys
|
||||
setKeys(prev => [
|
||||
{
|
||||
shortId: result.info.shortId,
|
||||
@@ -104,7 +102,6 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
|
||||
})
|
||||
}
|
||||
|
||||
// Raw key display state
|
||||
const [showRawKey, setShowRawKey] = useState<string | null>(null)
|
||||
const [rawKeyName, setRawKeyName] = useState('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
@@ -116,26 +113,29 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="columns-1 lg:columns-2 gap-6 space-y-6">
|
||||
{/* Section 1: What is MCP */}
|
||||
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid">
|
||||
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||
<div className="w-10 h-10 rounded-full bg-zinc-500/10 flex items-center justify-center text-zinc-500 shrink-0">
|
||||
<Info className="h-5 w-5" />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="grid grid-cols-1 lg:grid-cols-2 gap-6"
|
||||
>
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl overflow-hidden">
|
||||
<div className="flex items-center gap-5 p-6 border-b border-border/40">
|
||||
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl text-concrete border border-border">
|
||||
<Info size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold text-foreground">{t('mcpSettings.whatIsMcp.title')}</h2>
|
||||
<h4 className="text-[13px] font-bold text-ink">{t('mcpSettings.whatIsMcp.title')}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
<p className="text-[11px] text-concrete leading-relaxed">
|
||||
{t('mcpSettings.whatIsMcp.description')}
|
||||
</p>
|
||||
<a
|
||||
href="https://modelcontextprotocol.io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm text-zinc-600 hover:underline mt-4"
|
||||
className="inline-flex items-center gap-1.5 text-[10px] font-bold text-brand-accent uppercase tracking-widest hover:underline mt-4"
|
||||
>
|
||||
{t('mcpSettings.whatIsMcp.learnMore')}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
@@ -143,26 +143,27 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 2: Server Status */}
|
||||
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid">
|
||||
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||
<Server className="h-5 w-5" />
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl overflow-hidden">
|
||||
<div className="flex items-center gap-5 p-6 border-b border-border/40">
|
||||
<div className="p-3 bg-brand-accent/10 rounded-2xl text-brand-accent border border-brand-accent/20">
|
||||
<Server size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold text-foreground">{t('mcpSettings.serverStatus.title')}</h2>
|
||||
<h4 className="text-[13px] font-bold text-ink">{t('mcpSettings.serverStatus.title')}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-zinc-600">{t('mcpSettings.serverStatus.mode')}</span>
|
||||
<Badge variant="secondary">{serverStatus.mode.toUpperCase()}</Badge>
|
||||
<span className="text-[11px] font-bold text-concrete uppercase tracking-widest">{t('mcpSettings.serverStatus.mode')}</span>
|
||||
<span className="text-[10px] font-bold text-ink uppercase tracking-widest bg-paper dark:bg-white/10 px-3 py-1 rounded-lg border border-border">
|
||||
{serverStatus.mode.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
{serverStatus.mode === 'sse' && serverStatus.url && (
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-muted-foreground block">{t('mcpSettings.serverStatus.url')}</span>
|
||||
<code className="text-xs bg-muted p-2 rounded block break-all font-mono">
|
||||
<div className="space-y-2">
|
||||
<span className="text-[10px] font-bold text-concrete uppercase tracking-widest">{t('mcpSettings.serverStatus.url')}</span>
|
||||
<code className="text-[10px] bg-paper dark:bg-black/30 p-3 rounded-xl block break-all font-mono border border-border text-ink">
|
||||
{serverStatus.url}
|
||||
</code>
|
||||
</div>
|
||||
@@ -171,112 +172,85 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 3: API Keys */}
|
||||
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid">
|
||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||
<Key className="h-5 w-5" />
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl overflow-hidden">
|
||||
<div className="flex items-center justify-between p-6 border-b border-border/40">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-violet-500/10 rounded-2xl text-violet-500 border border-violet-500/20">
|
||||
<Key size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold text-foreground">{t('mcpSettings.apiKeys.title')}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('mcpSettings.apiKeys.description')}
|
||||
</p>
|
||||
<h4 className="text-[13px] font-bold text-ink">{t('mcpSettings.apiKeys.title')}</h4>
|
||||
<p className="text-[10px] text-concrete mt-0.5">{t('mcpSettings.apiKeys.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" className="gap-1.5">
|
||||
<Plus className="h-4 w-4" />
|
||||
<button className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-ink text-paper text-[10px] font-bold uppercase tracking-[0.15em] hover:scale-[1.02] active:scale-95 transition-all duration-300 shadow-lg shadow-ink/20">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t('mcpSettings.apiKeys.generate')}
|
||||
</Button>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<CreateKeyDialog
|
||||
onGenerate={handleGenerate}
|
||||
isPending={isPending}
|
||||
/>
|
||||
<CreateKeyDialog onGenerate={handleGenerate} isPending={isPending} />
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{keys.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Key className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>{t('mcpSettings.apiKeys.empty')}</p>
|
||||
<div className="text-center py-8">
|
||||
<Key className="h-8 w-8 mx-auto mb-2 text-concrete opacity-30" />
|
||||
<p className="text-[11px] text-concrete">{t('mcpSettings.apiKeys.empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{keys.map(k => (
|
||||
<KeyCard
|
||||
key={k.shortId}
|
||||
keyInfo={k}
|
||||
onRevoke={handleRevoke}
|
||||
onDelete={handleDelete}
|
||||
isPending={isPending}
|
||||
/>
|
||||
<KeyCard key={k.shortId} keyInfo={k} onRevoke={handleRevoke} onDelete={handleDelete} isPending={isPending} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 4: Configuration Instructions */}
|
||||
<div className="break-inside-avoid">
|
||||
<div>
|
||||
<ConfigInstructions serverStatus={serverStatus} />
|
||||
</div>
|
||||
|
||||
{/* Raw Key Display Dialog */}
|
||||
<Dialog open={!!showRawKey} onOpenChange={(open) => { if (!open) setShowRawKey(null) }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('mcpSettings.createDialog.successTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('mcpSettings.createDialog.successDescription')}
|
||||
</DialogDescription>
|
||||
<DialogDescription>{t('mcpSettings.createDialog.successDescription')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">{rawKeyName}</Label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<code className="flex-1 text-xs bg-muted p-3 rounded break-all font-mono">
|
||||
<Label className="text-[10px] font-bold text-concrete uppercase tracking-widest">{rawKeyName}</Label>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<code className="flex-1 text-[10px] bg-paper dark:bg-black/30 p-3 rounded-xl break-all font-mono border border-border text-ink">
|
||||
{showRawKey}
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
<button
|
||||
onClick={() => handleCopy(showRawKey!)}
|
||||
className="shrink-0"
|
||||
className="shrink-0 p-2.5 rounded-xl border border-border hover:bg-paper dark:hover:bg-white/10 transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
{copied ? <Check className="h-4 w-4 text-emerald-500" /> : <Copy className="h-4 w-4 text-concrete" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setShowRawKey(null)}>
|
||||
<button
|
||||
onClick={() => setShowRawKey(null)}
|
||||
className="px-6 py-2.5 rounded-xl bg-ink text-paper text-[10px] font-bold uppercase tracking-[0.15em]"
|
||||
>
|
||||
{t('mcpSettings.createDialog.done')}
|
||||
</Button>
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Sub-components ──────────────────────────────────────────────────────────────
|
||||
|
||||
function CreateKeyDialog({
|
||||
onGenerate,
|
||||
isPending,
|
||||
}: {
|
||||
onGenerate: (name: string) => void
|
||||
isPending: boolean
|
||||
}) {
|
||||
function CreateKeyDialog({ onGenerate, isPending }: { onGenerate: (name: string) => void; isPending: boolean }) {
|
||||
const [name, setName] = useState('')
|
||||
const { t } = useLanguage()
|
||||
|
||||
@@ -284,109 +258,82 @@ function CreateKeyDialog({
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('mcpSettings.createDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('mcpSettings.createDialog.description')}
|
||||
</DialogDescription>
|
||||
<DialogDescription>{t('mcpSettings.createDialog.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="key-name">{t('mcpSettings.createDialog.nameLabel')}</Label>
|
||||
<Label htmlFor="key-name" className="text-[10px] font-bold text-concrete uppercase tracking-widest">{t('mcpSettings.createDialog.nameLabel')}</Label>
|
||||
<Input
|
||||
id="key-name"
|
||||
placeholder={t('mcpSettings.createDialog.namePlaceholder')}
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="mt-1"
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
<button
|
||||
onClick={() => onGenerate(name)}
|
||||
disabled={isPending}
|
||||
className="px-6 py-2.5 rounded-xl bg-ink text-paper text-[10px] font-bold uppercase tracking-[0.15em] disabled:opacity-60"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-1" />
|
||||
{t('mcpSettings.createDialog.generating')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Key className="h-4 w-4 mr-1" />
|
||||
{t('mcpSettings.createDialog.generate')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Key className="h-4 w-4" />}
|
||||
{isPending ? t('mcpSettings.createDialog.generating') : t('mcpSettings.createDialog.generate')}
|
||||
</div>
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
|
||||
function KeyCard({
|
||||
keyInfo,
|
||||
onRevoke,
|
||||
onDelete,
|
||||
isPending,
|
||||
}: {
|
||||
keyInfo: McpKeyInfo
|
||||
onRevoke: (shortId: string) => void
|
||||
onDelete: (shortId: string) => void
|
||||
isPending: boolean
|
||||
}) {
|
||||
function KeyCard({ keyInfo, onRevoke, onDelete, isPending }: { keyInfo: McpKeyInfo; onRevoke: (shortId: string) => void; onDelete: (shortId: string) => void; isPending: boolean }) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
const formatDate = (iso: string | null) => {
|
||||
if (!iso) return t('mcpSettings.apiKeys.never')
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 rounded-lg border bg-muted/50">
|
||||
<div className="flex items-center justify-between p-4 rounded-2xl border border-border/60 bg-paper/30 dark:bg-black/10">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{keyInfo.name}</span>
|
||||
<Badge variant={keyInfo.active ? 'default' : 'secondary'} className="text-xs">
|
||||
{keyInfo.active
|
||||
? t('mcpSettings.apiKeys.active')
|
||||
: t('mcpSettings.apiKeys.revoked')}
|
||||
</Badge>
|
||||
<span className="text-[13px] font-bold text-ink">{keyInfo.name}</span>
|
||||
<span className={cn(
|
||||
'text-[9px] font-bold uppercase tracking-widest px-2 py-0.5 rounded-lg',
|
||||
keyInfo.active
|
||||
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
||||
: 'bg-concrete/10 text-concrete'
|
||||
)}>
|
||||
{keyInfo.active ? t('mcpSettings.apiKeys.active') : t('mcpSettings.apiKeys.revoked')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t('mcpSettings.apiKeys.createdAt')}: {formatDate(keyInfo.createdAt)}
|
||||
</span>
|
||||
<span>
|
||||
{t('mcpSettings.apiKeys.lastUsed')}: {formatDate(keyInfo.lastUsedAt)}
|
||||
</span>
|
||||
<div className="flex gap-4 text-[10px] text-concrete">
|
||||
<span>{t('mcpSettings.apiKeys.createdAt')}: {formatDate(keyInfo.createdAt)}</span>
|
||||
<span>{t('mcpSettings.apiKeys.lastUsed')}: {formatDate(keyInfo.lastUsedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{keyInfo.active ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
<button
|
||||
onClick={() => onRevoke(keyInfo.shortId)}
|
||||
disabled={isPending}
|
||||
className="gap-1"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl border border-border text-[10px] font-bold uppercase tracking-widest text-concrete hover:text-ink hover:border-ink/30 transition-colors disabled:opacity-60"
|
||||
>
|
||||
<Ban className="h-3.5 w-3.5" />
|
||||
<Ban className="h-3 w-3" />
|
||||
{t('mcpSettings.apiKeys.revoke')}
|
||||
</Button>
|
||||
</button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
<button
|
||||
onClick={() => onDelete(keyInfo.shortId)}
|
||||
disabled={isPending}
|
||||
className="gap-1"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-rose-500/10 text-rose-600 dark:text-rose-400 text-[10px] font-bold uppercase tracking-widest hover:bg-rose-500/20 transition-colors disabled:opacity-60"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{t('mcpSettings.apiKeys.delete')}
|
||||
</Button>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -396,6 +343,7 @@ function KeyCard({
|
||||
function ConfigInstructions({ serverStatus }: { serverStatus: McpServerStatus }) {
|
||||
const { t } = useLanguage()
|
||||
const [expanded, setExpanded] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const baseUrl = serverStatus.url || 'http://localhost:3001'
|
||||
|
||||
@@ -430,9 +378,7 @@ function ConfigInstructions({ serverStatus }: { serverStatus: McpServerStatus })
|
||||
mcpServers: {
|
||||
'memento-note': {
|
||||
url: baseUrl + '/mcp',
|
||||
headers: {
|
||||
'x-api-key': 'YOUR_API_KEY',
|
||||
},
|
||||
headers: { 'x-api-key': 'YOUR_API_KEY' },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -444,47 +390,55 @@ function ConfigInstructions({ serverStatus }: { serverStatus: McpServerStatus })
|
||||
id: 'n8n',
|
||||
title: t('mcpSettings.configInstructions.n8n.title'),
|
||||
description: t('mcpSettings.configInstructions.n8n.description'),
|
||||
snippet: `MCP Server URL: ${baseUrl}/mcp
|
||||
Header: x-api-key: YOUR_API_KEY
|
||||
Transport: Streamable HTTP`,
|
||||
snippet: `MCP Server URL: ${baseUrl}/mcp\nHeader: x-api-key: YOUR_API_KEY\nTransport: Streamable HTTP`,
|
||||
},
|
||||
]
|
||||
|
||||
const handleCopySnippet = async (text: string) => {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||
<ExternalLink className="h-5 w-5" />
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl overflow-hidden">
|
||||
<div className="flex items-center gap-5 p-6 border-b border-border/40">
|
||||
<div className="p-3 bg-emerald-500/10 rounded-2xl text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
|
||||
<ExternalLink size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold text-foreground">
|
||||
{t('mcpSettings.configInstructions.title')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('mcpSettings.configInstructions.description')}
|
||||
</p>
|
||||
<h4 className="text-[13px] font-bold text-ink">{t('mcpSettings.configInstructions.title')}</h4>
|
||||
<p className="text-[10px] text-concrete mt-0.5">{t('mcpSettings.configInstructions.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 space-y-2">
|
||||
<div className="p-6 space-y-3">
|
||||
{configs.map(cfg => (
|
||||
<div key={cfg.id} className="border rounded-lg overflow-hidden">
|
||||
<div key={cfg.id} className="border border-border/60 rounded-2xl overflow-hidden">
|
||||
<button
|
||||
className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-muted transition-colors"
|
||||
className="w-full flex items-center justify-between px-5 py-3.5 text-left hover:bg-paper/50 dark:hover:bg-white/5 transition-colors"
|
||||
onClick={() => setExpanded(expanded === cfg.id ? null : cfg.id)}
|
||||
>
|
||||
<span className="font-medium text-sm">{cfg.title}</span>
|
||||
<span className="text-[11px] font-bold text-ink">{cfg.title}</span>
|
||||
{expanded === cfg.id ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
<ChevronDown className="h-4 w-4 text-concrete" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<ChevronRight className="h-4 w-4 text-concrete" />
|
||||
)}
|
||||
</button>
|
||||
{expanded === cfg.id && (
|
||||
<div className="px-4 pb-4">
|
||||
<p className="text-sm text-muted-foreground mb-2">{cfg.description}</p>
|
||||
<pre className="text-xs bg-muted p-3 rounded overflow-x-auto">
|
||||
<code>{cfg.snippet}</code>
|
||||
</pre>
|
||||
<div className="px-5 pb-5">
|
||||
<p className="text-[11px] text-concrete mb-3">{cfg.description}</p>
|
||||
<div className="relative">
|
||||
<pre className="text-[10px] bg-paper dark:bg-black/30 p-4 rounded-xl overflow-x-auto border border-border">
|
||||
<code>{cfg.snippet}</code>
|
||||
</pre>
|
||||
<button
|
||||
onClick={() => handleCopySnippet(cfg.snippet)}
|
||||
className="absolute top-3 right-3 p-1.5 rounded-lg border border-border bg-paper dark:bg-black/30 hover:bg-white dark:hover:bg-black/50 transition-colors"
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3 text-emerald-500" /> : <Copy className="h-3 w-3 text-concrete" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -165,7 +165,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
if (!seed.trim()) return
|
||||
window.open(`/brainstorm?seed=${encodeURIComponent(seed.slice(0, 300))}&sourceNoteId=${note.id}`, '_self')
|
||||
}}
|
||||
className="p-1.5 rounded-full border border-orange-300 dark:border-orange-700 text-orange-500 hover:bg-orange-50 dark:hover:bg-orange-900/20 transition-all"
|
||||
className="p-1.5 rounded-full border border-brand-accent/30 dark:border-brand-accent/50 text-brand-accent hover:bg-brand-accent/10 dark:hover:bg-brand-accent/20 transition-all"
|
||||
>
|
||||
<Wind size={16} />
|
||||
</button>
|
||||
|
||||
@@ -565,7 +565,7 @@ export function NoteInlineEditor({
|
||||
{isProcessingAI
|
||||
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
: <Sparkles className="h-3.5 w-3.5" />}
|
||||
<span className="hidden sm:inline">IA Note</span>
|
||||
<span className="hidden sm:inline">{t('ai.aiNoteTitle')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -328,7 +328,7 @@ function NoteTag({ labelName, allLabels }: { labelName: string; allLabels: any[]
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-paper dark:bg-white/5 text-[9px] font-bold uppercase tracking-[0.15em] text-muted-foreground border border-border/40">
|
||||
{isAI && <Sparkles size={8} className="text-blueprint" />}
|
||||
{isAI && <Sparkles size={8} className="text-brand-accent" />}
|
||||
{labelName}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -200,7 +200,7 @@ export function NotificationPanel() {
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="relative h-9 w-9 flex items-center justify-center rounded-full bg-white border border-border text-muted-foreground hover:text-foreground hover:bg-white/40 transition-all"
|
||||
className="relative p-1.5 text-muted-ink hover:text-ink transition-all hover:bg-white/50 dark:hover:bg-white/10 rounded-lg border border-transparent hover:border-border"
|
||||
>
|
||||
<Bell className="h-4 w-4 transition-transform duration-200 hover:scale-110" />
|
||||
{pendingCount > 0 && (
|
||||
|
||||
@@ -151,8 +151,8 @@ export function OrganizeNotebookDialog({
|
||||
{/* Header */}
|
||||
<div className="px-6 py-5 border-b border-border/60 flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-blueprint/10 flex items-center justify-center">
|
||||
<Sparkles size={16} className="text-blueprint" />
|
||||
<div className="w-8 h-8 rounded-lg bg-brand-accent/10 flex items-center justify-center">
|
||||
<Sparkles size={16} className="text-brand-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-[13px] font-semibold text-ink">{t('organizeNotebook.title')}</h2>
|
||||
@@ -194,7 +194,7 @@ export function OrganizeNotebookDialog({
|
||||
<ul className="space-y-2">
|
||||
{[t('organizeNotebook.bulletThemes'), t('organizeNotebook.bulletSubfolders'), t('organizeNotebook.bulletPreview')].map(item => (
|
||||
<li key={item} className="flex items-center gap-2 text-[12px] text-muted-ink">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-blueprint shrink-0" />
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-brand-accent shrink-0" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
@@ -213,13 +213,13 @@ export function OrganizeNotebookDialog({
|
||||
className="flex flex-col items-center justify-center p-12 gap-6 min-h-[300px]"
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="w-16 h-16 rounded-2xl bg-blueprint/10 flex items-center justify-center">
|
||||
<Sparkles size={28} className="text-blueprint" />
|
||||
<div className="w-16 h-16 rounded-2xl bg-brand-accent/10 flex items-center justify-center">
|
||||
<Sparkles size={28} className="text-brand-accent" />
|
||||
</div>
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: 'linear' }}
|
||||
className="absolute -inset-2 rounded-2xl border-2 border-transparent border-t-blueprint/40"
|
||||
className="absolute -inset-2 rounded-2xl border-2 border-transparent border-t-brand-accent/40"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center space-y-1.5">
|
||||
@@ -232,7 +232,7 @@ export function OrganizeNotebookDialog({
|
||||
key={i}
|
||||
animate={{ opacity: [0.3, 1, 0.3] }}
|
||||
transition={{ duration: 1.2, repeat: Infinity, delay: i * 0.2 }}
|
||||
className="w-1.5 h-1.5 rounded-full bg-blueprint"
|
||||
className="w-1.5 h-1.5 rounded-full bg-brand-accent"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -249,9 +249,9 @@ export function OrganizeNotebookDialog({
|
||||
className="p-5 space-y-4"
|
||||
>
|
||||
{/* Summary bar */}
|
||||
<div className="flex items-center gap-3 p-3 rounded-xl bg-blueprint/5 border border-blueprint/20">
|
||||
<Sparkles size={12} className="text-blueprint shrink-0" />
|
||||
<p className="text-[11px] text-blueprint font-medium">
|
||||
<div className="flex items-center gap-3 p-3 rounded-xl bg-brand-accent/5 border border-brand-accent/20">
|
||||
<Sparkles size={12} className="text-brand-accent shrink-0" />
|
||||
<p className="text-[11px] text-brand-accent font-medium">
|
||||
{t('organizeNotebook.previewSummary', {
|
||||
groups: editableGroups.length,
|
||||
notes: totalNotes,
|
||||
@@ -282,17 +282,17 @@ export function OrganizeNotebookDialog({
|
||||
<div className="flex items-center gap-2 px-3 py-2.5">
|
||||
<div className="w-6 h-6 rounded-md flex items-center justify-center shrink-0">
|
||||
{group.isNew
|
||||
? <FolderPlus size={13} className="text-blueprint" />
|
||||
? <FolderPlus size={13} className="text-brand-accent" />
|
||||
: <Folder size={13} className="text-muted-ink" />
|
||||
}
|
||||
</div>
|
||||
<input
|
||||
value={group.name}
|
||||
onChange={e => handleRenameGroup(idx, e.target.value)}
|
||||
className="flex-1 bg-transparent text-[12px] font-semibold text-ink outline-none focus:text-blueprint transition-colors min-w-0"
|
||||
className="flex-1 bg-transparent text-[12px] font-semibold text-ink outline-none focus:text-brand-accent transition-colors min-w-0"
|
||||
/>
|
||||
{group.isNew && (
|
||||
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider bg-blueprint/10 text-blueprint shrink-0">
|
||||
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider bg-brand-accent/10 text-brand-accent shrink-0">
|
||||
{t('organizeNotebook.badgeNew')}
|
||||
</span>
|
||||
)}
|
||||
@@ -328,8 +328,8 @@ export function OrganizeNotebookDialog({
|
||||
className="flex items-center gap-2 py-1.5 rounded-lg px-2 hover:bg-foreground/3 transition-colors group cursor-pointer"
|
||||
onClick={() => handleToggleNote(idx, note.id)}
|
||||
>
|
||||
<div className="w-4 h-4 rounded border border-border flex items-center justify-center shrink-0 group-hover:border-blueprint/50 transition-colors">
|
||||
<Check size={10} className="text-blueprint" />
|
||||
<div className="w-4 h-4 rounded border border-border flex items-center justify-center shrink-0 group-hover:border-brand-accent/50 transition-colors">
|
||||
<Check size={10} className="text-brand-accent" />
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-ink truncate group-hover:text-ink transition-colors">
|
||||
{note.title || t('organizeNotebook.untitledNote')}
|
||||
@@ -361,7 +361,7 @@ export function OrganizeNotebookDialog({
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex flex-col items-center justify-center p-12 gap-5 min-h-[300px]"
|
||||
>
|
||||
<Loader2 size={32} className="text-blueprint animate-spin" />
|
||||
<Loader2 size={32} className="text-brand-accent animate-spin" />
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-[14px] font-medium text-ink">{t('organizeNotebook.executingTitle')}</p>
|
||||
<p className="text-[12px] text-muted-ink">{t('organizeNotebook.executingSubtitle')}</p>
|
||||
|
||||
16
memento-note/components/page-entry.tsx
Normal file
16
memento-note/components/page-entry.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
|
||||
export function PageEntry({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: [0.23, 1, 0.32, 1] }}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
9
memento-note/components/page-transition.tsx
Normal file
9
memento-note/components/page-transition.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
'use client'
|
||||
|
||||
export function PageTransition({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
49
memento-note/components/quota-paywall.tsx
Normal file
49
memento-note/components/quota-paywall.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { Zap } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/lib/i18n';
|
||||
|
||||
interface QuotaPaywallProps {
|
||||
feature?: string;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
export function QuotaPaywall({ feature: _feature, onDismiss }: QuotaPaywallProps) {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-[#D4A373]/40 bg-[#D4A373]/5 p-5 space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[#D4A373]/20">
|
||||
<Zap className="h-4 w-4 text-[#D4A373]" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{t('billing.upgradeToPro')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('billing.proDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/settings/billing"
|
||||
className="px-4 py-1.5 text-xs font-medium rounded-lg bg-[#D4A373] text-white hover:bg-[#C49363] transition-colors"
|
||||
>
|
||||
{t('billing.startCheckout')}
|
||||
</Link>
|
||||
{onDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{t('common.later') ?? 'Later'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -59,6 +59,26 @@ type SlashItem = {
|
||||
command: (editor: Editor, range?: any) => void
|
||||
}
|
||||
|
||||
type SlashCategoryId = 'basic' | 'media' | 'formatting' | 'ai'
|
||||
|
||||
type SlashMenuItem = SlashItem & { categoryId: SlashCategoryId }
|
||||
|
||||
const ORDERED_SLASH_CATEGORIES: SlashCategoryId[] = ['basic', 'media', 'formatting', 'ai']
|
||||
|
||||
function slashCategoryLabel(id: SlashCategoryId, t: (key: string) => string): string {
|
||||
switch (id) {
|
||||
case 'basic': return t('richTextEditor.slashCatBasic')
|
||||
case 'media': return t('richTextEditor.slashCatMedia')
|
||||
case 'formatting': return t('richTextEditor.slashCatFormatting')
|
||||
case 'ai': return t('richTextEditor.slashCatAi')
|
||||
}
|
||||
}
|
||||
|
||||
/** Sent to /api/ai/reformulate as target language (unchanged for prompt compatibility). */
|
||||
const TRANSLATE_TARGET_API_VALUES = ['Francais', 'English', 'Espanol', 'Deutsch', 'Persan', 'Portugais', 'Italiano', 'Chinois', 'Japonais'] as const
|
||||
|
||||
const AI_REFORMULATE_FALLBACK = '__RICH_TEXT_AI_FALLBACK__'
|
||||
|
||||
const CustomImage = Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
@@ -129,7 +149,10 @@ async function aiReformulate(text: string, option: string, language?: string): P
|
||||
body: JSON.stringify({ text, option, format: 'html', language }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || 'AI failed')
|
||||
if (!res.ok) {
|
||||
const serverMsg = typeof data?.error === 'string' && data.error.trim() ? data.error.trim() : AI_REFORMULATE_FALLBACK
|
||||
throw new Error(serverMsg)
|
||||
}
|
||||
return data.reformulatedText || data.text || text
|
||||
}
|
||||
|
||||
@@ -286,7 +309,7 @@ function ImageModal({ onConfirm, onCancel }: { onConfirm: (url: string) => void;
|
||||
value={url}
|
||||
onChange={(e) => { setUrl(e.target.value); setError(''); setPreview(null) }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleConfirm(); if (e.key === 'Escape') onCancel() }}
|
||||
placeholder="https://example.com/image.png"
|
||||
placeholder={t('richTextEditor.imageUrlPlaceholder')}
|
||||
className="notion-modal-input"
|
||||
/>
|
||||
{url.trim() && !preview && (
|
||||
@@ -309,10 +332,14 @@ function ImageModal({ onConfirm, onCancel }: { onConfirm: (url: string) => void;
|
||||
)
|
||||
}
|
||||
|
||||
const AI_LANGS = ['Francais', 'English', 'Espanol', 'Deutsch', 'Persan', 'Portugais', 'Italiano', 'Chinois', 'Japonais']
|
||||
|
||||
function BubbleToolbar({ editor }: { editor: Editor | null }) {
|
||||
const { t, language } = useLanguage()
|
||||
|
||||
const toastAiError = (err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : ''
|
||||
toast.error(msg && msg !== AI_REFORMULATE_FALLBACK ? msg : t('richTextEditor.aiReformulateFailed'))
|
||||
}
|
||||
|
||||
const [, setTick] = useState(0)
|
||||
const [aiOpen, setAiOpen] = useState(false)
|
||||
const [aiLoading, setAiLoading] = useState(false)
|
||||
@@ -365,6 +392,7 @@ function BubbleToolbar({ editor }: { editor: Editor | null }) {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('AI error:', err)
|
||||
toastAiError(err)
|
||||
} finally {
|
||||
setAiLoading(false)
|
||||
}
|
||||
@@ -444,8 +472,10 @@ function BubbleToolbar({ editor }: { editor: Editor | null }) {
|
||||
<button className="notion-ai-subitem" onClick={() => setTranslateOpen(v => !v)}><Languages className="w-3.5 h-3.5 text-indigo-500" /><span>{t('ai.action.translate')}</span></button>
|
||||
{translateOpen && (
|
||||
<div className="notion-ai-lang-picker">
|
||||
{AI_LANGS.map(l => (
|
||||
<button key={l} className="notion-ai-lang-item" onClick={() => handleAI('translate', l)}>{l}</button>
|
||||
{TRANSLATE_TARGET_API_VALUES.map((apiValue) => (
|
||||
<button key={apiValue} className="notion-ai-lang-item" onClick={() => handleAI('translate', apiValue)}>
|
||||
{t(`richTextEditor.translateTargets.${apiValue}`)}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex items-center gap-1 px-1 pt-1 w-full border-t border-border/40 mt-1">
|
||||
<input
|
||||
@@ -498,49 +528,42 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null)
|
||||
const [activeCategory, setActiveCategory] = useState<SlashCategoryId | null>(null)
|
||||
const [coords, setCoords] = useState({ top: 0, left: 0 })
|
||||
const [aiLoading, setAiLoading] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const selectedItemRef = useRef<HTMLButtonElement>(null)
|
||||
const menuInteracting = useRef(false)
|
||||
|
||||
const CAT_LABELS: Record<string, string> = {
|
||||
'Basic blocks': t('richTextEditor.slashCatBasic'),
|
||||
'Media': t('richTextEditor.slashCatMedia'),
|
||||
'Formatting': t('richTextEditor.slashCatFormatting'),
|
||||
'IA Note': t('richTextEditor.slashCatAi'),
|
||||
}
|
||||
|
||||
const localCommands: SlashItem[] = [
|
||||
{ ...slashCommands[0], title: t('richTextEditor.slashText'), description: t('richTextEditor.slashTextDesc'), category: t('richTextEditor.slashCatBasic') },
|
||||
{ ...slashCommands[1], title: t('richTextEditor.slashH1'), description: t('richTextEditor.slashH1Desc'), category: t('richTextEditor.slashCatBasic') },
|
||||
{ ...slashCommands[2], title: t('richTextEditor.slashH2'), description: t('richTextEditor.slashH2Desc'), category: t('richTextEditor.slashCatBasic') },
|
||||
{ ...slashCommands[3], title: t('richTextEditor.slashH3'), description: t('richTextEditor.slashH3Desc'), category: t('richTextEditor.slashCatBasic') },
|
||||
{ ...slashCommands[4], title: t('richTextEditor.slashTable'), description: t('richTextEditor.slashTableDesc'), category: t('richTextEditor.slashCatBasic') },
|
||||
{ ...slashCommands[5], title: t('richTextEditor.slashBullet'), description: t('richTextEditor.slashBulletDesc'), category: t('richTextEditor.slashCatBasic') },
|
||||
{ ...slashCommands[6], title: t('richTextEditor.slashNumbered'), description: t('richTextEditor.slashNumberedDesc'), category: t('richTextEditor.slashCatBasic') },
|
||||
{ ...slashCommands[7], title: t('richTextEditor.slashTodo'), description: t('richTextEditor.slashTodoDesc'), category: t('richTextEditor.slashCatBasic') },
|
||||
{ ...slashCommands[8], title: t('richTextEditor.slashQuote'), description: t('richTextEditor.slashQuoteDesc'), category: t('richTextEditor.slashCatBasic') },
|
||||
{ ...slashCommands[9], title: t('richTextEditor.slashCode'), description: t('richTextEditor.slashCodeDesc'), category: t('richTextEditor.slashCatBasic') },
|
||||
{ ...slashCommands[10], title: t('richTextEditor.slashDivider'), description: t('richTextEditor.slashDividerDesc'), category: t('richTextEditor.slashCatBasic') },
|
||||
{ ...slashCommands[11], title: t('richTextEditor.slashImage'), description: t('richTextEditor.slashImageDesc'), category: t('richTextEditor.slashCatMedia') },
|
||||
{ ...slashCommands[12], title: t('richTextEditor.slashAlignLeft'), description: t('richTextEditor.slashAlignLeftDesc'), category: t('richTextEditor.slashCatFormatting') },
|
||||
{ ...slashCommands[13], title: t('richTextEditor.slashAlignCenter'), description: t('richTextEditor.slashAlignCenterDesc'), category: t('richTextEditor.slashCatFormatting') },
|
||||
{ ...slashCommands[14], title: t('richTextEditor.slashAlignRight'), description: t('richTextEditor.slashAlignRightDesc'), category: t('richTextEditor.slashCatFormatting') },
|
||||
{ ...slashCommands[15], title: t('richTextEditor.slashClarify'), description: t('richTextEditor.slashClarifyDesc'), category: t('richTextEditor.slashCatAi') },
|
||||
{ ...slashCommands[16], title: t('richTextEditor.slashShorten'), description: t('richTextEditor.slashShortenDesc'), category: t('richTextEditor.slashCatAi') },
|
||||
{ ...slashCommands[17], title: t('richTextEditor.slashImprove'), description: t('richTextEditor.slashImproveDesc'), category: t('richTextEditor.slashCatAi') },
|
||||
{ ...slashCommands[18], title: t('richTextEditor.slashExpand'), description: t('richTextEditor.slashExpandDesc'), category: t('richTextEditor.slashCatAi') },
|
||||
{ ...slashCommands[19], title: t('richTextEditor.bold'), description: t('richTextEditor.bold'), category: t('richTextEditor.slashCatFormatting') },
|
||||
{ ...slashCommands[20], title: t('richTextEditor.italic'), description: t('richTextEditor.italic'), category: t('richTextEditor.slashCatFormatting') },
|
||||
{ ...slashCommands[21], title: t('richTextEditor.underline'), description: t('richTextEditor.underline'), category: t('richTextEditor.slashCatFormatting') },
|
||||
{ ...slashCommands[22], title: t('richTextEditor.strike'), description: t('richTextEditor.strike'), category: t('richTextEditor.slashCatFormatting') },
|
||||
{ ...slashCommands[23], title: t('richTextEditor.highlight'), description: t('richTextEditor.highlight'), category: t('richTextEditor.slashCatFormatting') },
|
||||
{ ...slashCommands[24], title: t('richTextEditor.slashSuperscript'), description: t('richTextEditor.slashSuperscriptDesc'), category: t('richTextEditor.slashCatFormatting') },
|
||||
{ ...slashCommands[25], title: t('richTextEditor.slashSubscript'), description: t('richTextEditor.slashSubscriptDesc'), category: t('richTextEditor.slashCatFormatting') },
|
||||
{ ...slashCommands[26], title: t('richTextEditor.slashDiagram'), description: t('richTextEditor.slashDiagramDesc'), category: t('richTextEditor.slashCatAi') },
|
||||
{ ...slashCommands[27], title: t('richTextEditor.slashSlides'), description: t('richTextEditor.slashSlidesDesc'), category: t('richTextEditor.slashCatAi') },
|
||||
const localCommands: SlashMenuItem[] = [
|
||||
{ ...slashCommands[0], title: t('richTextEditor.slashText'), description: t('richTextEditor.slashTextDesc'), categoryId: 'basic' },
|
||||
{ ...slashCommands[1], title: t('richTextEditor.slashH1'), description: t('richTextEditor.slashH1Desc'), categoryId: 'basic' },
|
||||
{ ...slashCommands[2], title: t('richTextEditor.slashH2'), description: t('richTextEditor.slashH2Desc'), categoryId: 'basic' },
|
||||
{ ...slashCommands[3], title: t('richTextEditor.slashH3'), description: t('richTextEditor.slashH3Desc'), categoryId: 'basic' },
|
||||
{ ...slashCommands[4], title: t('richTextEditor.slashTable'), description: t('richTextEditor.slashTableDesc'), categoryId: 'basic' },
|
||||
{ ...slashCommands[5], title: t('richTextEditor.slashBullet'), description: t('richTextEditor.slashBulletDesc'), categoryId: 'basic' },
|
||||
{ ...slashCommands[6], title: t('richTextEditor.slashNumbered'), description: t('richTextEditor.slashNumberedDesc'), categoryId: 'basic' },
|
||||
{ ...slashCommands[7], title: t('richTextEditor.slashTodo'), description: t('richTextEditor.slashTodoDesc'), categoryId: 'basic' },
|
||||
{ ...slashCommands[8], title: t('richTextEditor.slashQuote'), description: t('richTextEditor.slashQuoteDesc'), categoryId: 'basic' },
|
||||
{ ...slashCommands[9], title: t('richTextEditor.slashCode'), description: t('richTextEditor.slashCodeDesc'), categoryId: 'basic' },
|
||||
{ ...slashCommands[10], title: t('richTextEditor.slashDivider'), description: t('richTextEditor.slashDividerDesc'), categoryId: 'basic' },
|
||||
{ ...slashCommands[11], title: t('richTextEditor.slashImage'), description: t('richTextEditor.slashImageDesc'), categoryId: 'media' },
|
||||
{ ...slashCommands[12], title: t('richTextEditor.slashAlignLeft'), description: t('richTextEditor.slashAlignLeftDesc'), categoryId: 'formatting' },
|
||||
{ ...slashCommands[13], title: t('richTextEditor.slashAlignCenter'), description: t('richTextEditor.slashAlignCenterDesc'), categoryId: 'formatting' },
|
||||
{ ...slashCommands[14], title: t('richTextEditor.slashAlignRight'), description: t('richTextEditor.slashAlignRightDesc'), categoryId: 'formatting' },
|
||||
{ ...slashCommands[15], title: t('richTextEditor.slashClarify'), description: t('richTextEditor.slashClarifyDesc'), categoryId: 'ai' },
|
||||
{ ...slashCommands[16], title: t('richTextEditor.slashShorten'), description: t('richTextEditor.slashShortenDesc'), categoryId: 'ai' },
|
||||
{ ...slashCommands[17], title: t('richTextEditor.slashImprove'), description: t('richTextEditor.slashImproveDesc'), categoryId: 'ai' },
|
||||
{ ...slashCommands[18], title: t('richTextEditor.slashExpand'), description: t('richTextEditor.slashExpandDesc'), categoryId: 'ai' },
|
||||
{ ...slashCommands[19], title: t('richTextEditor.bold'), description: t('richTextEditor.bold'), categoryId: 'formatting' },
|
||||
{ ...slashCommands[20], title: t('richTextEditor.italic'), description: t('richTextEditor.italic'), categoryId: 'formatting' },
|
||||
{ ...slashCommands[21], title: t('richTextEditor.underline'), description: t('richTextEditor.underline'), categoryId: 'formatting' },
|
||||
{ ...slashCommands[22], title: t('richTextEditor.strike'), description: t('richTextEditor.strike'), categoryId: 'formatting' },
|
||||
{ ...slashCommands[23], title: t('richTextEditor.highlight'), description: t('richTextEditor.highlight'), categoryId: 'formatting' },
|
||||
{ ...slashCommands[24], title: t('richTextEditor.slashSuperscript'), description: t('richTextEditor.slashSuperscriptDesc'), categoryId: 'formatting' },
|
||||
{ ...slashCommands[25], title: t('richTextEditor.slashSubscript'), description: t('richTextEditor.slashSubscriptDesc'), categoryId: 'formatting' },
|
||||
{ ...slashCommands[26], title: t('richTextEditor.slashDiagram'), description: t('richTextEditor.slashDiagramDesc'), categoryId: 'ai' },
|
||||
{ ...slashCommands[27], title: t('richTextEditor.slashSlides'), description: t('richTextEditor.slashSlidesDesc'), categoryId: 'ai' },
|
||||
]
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
@@ -557,7 +580,11 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
const handleSelect = useCallback(async (item: SlashItem) => {
|
||||
const handleSelect = useCallback(async (item: SlashMenuItem) => {
|
||||
const toastAi = (err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : ''
|
||||
toast.error(msg && msg !== AI_REFORMULATE_FALLBACK ? msg : t('richTextEditor.aiReformulateFailed'))
|
||||
}
|
||||
if (item.isImage) {
|
||||
deleteSlashText(); closeMenu(); onInsertImage(editor)
|
||||
} else if (item.isAi && item.aiOption) {
|
||||
@@ -567,31 +594,35 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
|
||||
if (!allText || allText.split(/\s+/).length < 5) return
|
||||
const result = await aiReformulate(allText, item.aiOption)
|
||||
editor.chain().focus().setContent(result).run()
|
||||
} catch (err) { console.error('AI slash error:', err) }
|
||||
} catch (err) {
|
||||
console.error('AI slash error:', err)
|
||||
toastAi(err)
|
||||
}
|
||||
finally { setAiLoading(false) }
|
||||
} else {
|
||||
deleteSlashText(); item.command(editor); closeMenu()
|
||||
}
|
||||
}, [editor, closeMenu, deleteSlashText, onInsertImage])
|
||||
}, [editor, closeMenu, deleteSlashText, onInsertImage, t])
|
||||
|
||||
const allCategories = Array.from(new Set(localCommands.map(c => c.category || 'Basic blocks')))
|
||||
const presentCategoryIds = new Set(localCommands.map(c => c.categoryId))
|
||||
const allCategories = ORDERED_SLASH_CATEGORIES.filter(id => presentCategoryIds.has(id))
|
||||
|
||||
const textFiltered = localCommands.filter(c => c.title.toLowerCase().includes(query.toLowerCase()) || c.description.toLowerCase().includes(query.toLowerCase()))
|
||||
const filtered = activeCategory ? textFiltered.filter(c => (c.category || 'Basic blocks') === activeCategory) : textFiltered
|
||||
const filtered = activeCategory ? textFiltered.filter(c => c.categoryId === activeCategory) : textFiltered
|
||||
|
||||
const availableCategoriesInSearch = textFiltered.reduce((acc, item) => {
|
||||
const cat = item.category || 'Basic blocks'
|
||||
if (!acc[cat]) acc[cat] = []
|
||||
acc[cat].push(item)
|
||||
const id = item.categoryId
|
||||
if (!acc[id]) acc[id] = []
|
||||
acc[id].push(item)
|
||||
return acc
|
||||
}, {} as Record<string, SlashItem[]>)
|
||||
}, {} as Record<SlashCategoryId, SlashMenuItem[]>)
|
||||
|
||||
const categories = filtered.reduce((acc, item) => {
|
||||
const cat = item.category || 'Basic blocks'
|
||||
if (!acc[cat]) acc[cat] = []
|
||||
acc[cat].push(item)
|
||||
const id = item.categoryId
|
||||
if (!acc[id]) acc[id] = []
|
||||
acc[id].push(item)
|
||||
return acc
|
||||
}, {} as Record<string, SlashItem[]>)
|
||||
}, {} as Record<SlashCategoryId, SlashMenuItem[]>)
|
||||
|
||||
// Auto-scroll selected item into view
|
||||
useEffect(() => {
|
||||
@@ -605,7 +636,7 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(i => (i - 1 + filtered.length) % filtered.length); return }
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
|
||||
e.preventDefault()
|
||||
const availableTabs = [null, ...allCategories.filter(cat => availableCategoriesInSearch[cat])]
|
||||
const availableTabs = [null as SlashCategoryId | null, ...allCategories.filter(id => (availableCategoriesInSearch[id]?.length ?? 0) > 0)]
|
||||
const currentIndex = availableTabs.indexOf(activeCategory)
|
||||
const nextIndex = e.key === 'ArrowRight'
|
||||
? (currentIndex + 1) % availableTabs.length
|
||||
@@ -623,7 +654,7 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
|
||||
if (e.key === 'Escape') { e.preventDefault(); closeMenu(); return }
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
const availableTabs = [null, ...allCategories.filter(cat => availableCategoriesInSearch[cat])]
|
||||
const availableTabs = [null as SlashCategoryId | null, ...allCategories.filter(id => (availableCategoriesInSearch[id]?.length ?? 0) > 0)]
|
||||
const nextIndex = (availableTabs.indexOf(activeCategory) + 1) % availableTabs.length
|
||||
setActiveCategory(availableTabs[nextIndex])
|
||||
setSelectedIndex(0)
|
||||
@@ -632,7 +663,7 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown, true)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown, true)
|
||||
}, [isOpen, selectedIndex, filtered, handleSelect, closeMenu, categories, activeCategory])
|
||||
}, [isOpen, selectedIndex, filtered, handleSelect, closeMenu, activeCategory, allCategories, availableCategoriesInSearch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
@@ -683,7 +714,8 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
|
||||
if (!isOpen || filtered.length === 0) return null
|
||||
|
||||
let flatIndex = -1
|
||||
const totalVisible = Object.keys(categories).length
|
||||
const sectionIds = ORDERED_SLASH_CATEGORIES.filter(id => (categories[id]?.length ?? 0) > 0)
|
||||
const totalVisible = sectionIds.length
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
@@ -702,16 +734,16 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
|
||||
>
|
||||
{t('richTextEditor.slashTabAll')}
|
||||
</button>
|
||||
{allCategories.filter(cat => availableCategoriesInSearch[cat]).map(cat => (
|
||||
{allCategories.filter(id => (availableCategoriesInSearch[id]?.length ?? 0) > 0).map(catId => (
|
||||
<button
|
||||
key={cat}
|
||||
className={cn('notion-slash-tab', activeCategory === cat && 'notion-slash-tab-active', cat === 'IA Note' && 'notion-slash-tab-ai')}
|
||||
key={catId}
|
||||
className={cn('notion-slash-tab', activeCategory === catId && 'notion-slash-tab-active', catId === 'ai' && 'notion-slash-tab-ai')}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => { setActiveCategory(activeCategory === cat ? null : cat); setSelectedIndex(0) }}
|
||||
onClick={() => { setActiveCategory(activeCategory === catId ? null : catId); setSelectedIndex(0) }}
|
||||
>
|
||||
{cat === 'IA Note' && <Sparkles className="w-2.5 h-2.5" />}
|
||||
{cat}
|
||||
<span className="notion-slash-tab-count">{availableCategoriesInSearch[cat]?.length ?? 0}</span>
|
||||
{catId === 'ai' && <Sparkles className="w-2.5 h-2.5" />}
|
||||
{slashCategoryLabel(catId, t)}
|
||||
<span className="notion-slash-tab-count">{availableCategoriesInSearch[catId]?.length ?? 0}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -728,40 +760,43 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
|
||||
<span>{t('richTextEditor.slashLoading')}</span>
|
||||
</div>
|
||||
)}
|
||||
{!aiLoading && Object.entries(categories).map(([cat, items]) => (
|
||||
<div key={cat} className="notion-slash-section">
|
||||
<div className={cn('notion-slash-label', cat === 'IA Note' && 'notion-slash-label-ai')}>
|
||||
{cat === 'IA Note' && <Sparkles className="w-3 h-3 inline mr-1" />}
|
||||
{CAT_LABELS[cat] || cat}
|
||||
{!aiLoading && sectionIds.map((catId) => {
|
||||
const items = categories[catId]!
|
||||
return (
|
||||
<div key={catId} className="notion-slash-section">
|
||||
<div className={cn('notion-slash-label', catId === 'ai' && 'notion-slash-label-ai')}>
|
||||
{catId === 'ai' && <Sparkles className="w-3 h-3 inline mr-1" />}
|
||||
{slashCategoryLabel(catId, t)}
|
||||
</div>
|
||||
{items.map((item) => {
|
||||
flatIndex++
|
||||
const idx = flatIndex
|
||||
const isSelected = idx === selectedIndex
|
||||
return (
|
||||
<button
|
||||
key={`${catId}-${item.title}-${item.shortcut ?? ''}`}
|
||||
ref={isSelected ? selectedItemRef : null}
|
||||
className={cn('notion-slash-item', isSelected && 'notion-slash-item-selected')}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => handleSelect(item)}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<div className={cn('notion-slash-icon', item.isAi && 'notion-slash-icon-ai')}>
|
||||
<item.icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="notion-slash-content">
|
||||
<span className="notion-slash-title">{item.title}</span>
|
||||
<span className="notion-slash-desc">{item.description}</span>
|
||||
</div>
|
||||
{item.shortcut && (
|
||||
<span className="notion-slash-shortcut">{item.shortcut}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{items.map((item) => {
|
||||
flatIndex++
|
||||
const idx = flatIndex
|
||||
const isSelected = idx === selectedIndex
|
||||
return (
|
||||
<button
|
||||
key={item.title}
|
||||
ref={isSelected ? selectedItemRef : null}
|
||||
className={cn('notion-slash-item', isSelected && 'notion-slash-item-selected')}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => handleSelect(item)}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<div className={cn('notion-slash-icon', item.isAi && 'notion-slash-icon-ai')}>
|
||||
<item.icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="notion-slash-content">
|
||||
<span className="notion-slash-title">{item.title}</span>
|
||||
<span className="notion-slash-desc">{item.description}</span>
|
||||
</div>
|
||||
{item.shortcut && (
|
||||
<span className="notion-slash-shortcut">{item.shortcut}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
|
||||
@@ -2,16 +2,9 @@
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { Settings, Sparkles, Palette, User, Database, Info, Key } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Settings, Sparkles, Palette, User, Database, Info, Key, CreditCard } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface SettingsSection {
|
||||
id: string
|
||||
label: string
|
||||
icon: React.ReactNode
|
||||
href: string
|
||||
}
|
||||
import { motion } from 'motion/react'
|
||||
|
||||
interface SettingsNavProps {
|
||||
className?: string
|
||||
@@ -21,33 +14,37 @@ export function SettingsNav({ className }: SettingsNavProps) {
|
||||
const pathname = usePathname()
|
||||
const { t } = useLanguage()
|
||||
|
||||
const sections: SettingsSection[] = [
|
||||
{ id: 'general', label: t('generalSettings.title'), icon: <Settings className="h-4 w-4" />, href: '/settings/general' },
|
||||
{ id: 'ai', label: t('aiSettings.title'), icon: <Sparkles className="h-4 w-4" />, href: '/settings/ai' },
|
||||
{ id: 'appearance', label: t('appearance.title'), icon: <Palette className="h-4 w-4" />, href: '/settings/appearance' },
|
||||
{ id: 'profile', label: t('profile.title'), icon: <User className="h-4 w-4" />, href: '/settings/profile' },
|
||||
{ id: 'data', label: t('dataManagement.title'), icon: <Database className="h-4 w-4" />, href: '/settings/data' },
|
||||
{ id: 'mcp', label: t('mcpSettings.title'), icon: <Key className="h-4 w-4" />, href: '/settings/mcp' },
|
||||
{ id: 'about', label: t('about.title'), icon: <Info className="h-4 w-4" />, href: '/settings/about' },
|
||||
const tabs = [
|
||||
{ id: 'general', label: t('generalSettings.title'), icon: <Settings size={14} />, href: '/settings/general' },
|
||||
{ id: 'ai', label: t('aiSettings.title'), icon: <Sparkles size={14} />, href: '/settings/ai' },
|
||||
{ id: 'billing', label: t('billing.title'), icon: <CreditCard size={14} />, href: '/settings/billing' },
|
||||
{ id: 'appearance', label: t('appearance.title'), icon: <Palette size={14} />, href: '/settings/appearance' },
|
||||
{ id: 'profile', label: t('profile.title'), icon: <User size={14} />, href: '/settings/profile' },
|
||||
{ id: 'data', label: t('dataManagement.title'), icon: <Database size={14} />, href: '/settings/data' },
|
||||
{ id: 'mcp', label: t('mcpSettings.title'), icon: <Key size={14} />, href: '/settings/mcp' },
|
||||
{ id: 'about', label: t('about.title'), icon: <Info size={14} />, href: '/settings/about' },
|
||||
]
|
||||
|
||||
const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/')
|
||||
|
||||
return (
|
||||
<nav className={cn('flex items-center gap-6 border-b border-border/40', className)}>
|
||||
{sections.map((section) => (
|
||||
<nav className={`flex items-center gap-1 border-b border-border/40 pb-px ${className || ''}`}>
|
||||
{tabs.map((tab) => (
|
||||
<Link
|
||||
key={section.id}
|
||||
href={section.href}
|
||||
className={cn(
|
||||
'flex items-center gap-2 pb-3 pt-4 text-[11px] font-bold uppercase tracking-[0.15em] transition-all whitespace-nowrap border-b-2',
|
||||
isActive(section.href)
|
||||
? 'border-[#D4A373] text-ink'
|
||||
: 'border-transparent text-muted-ink hover:text-ink'
|
||||
)}
|
||||
key={tab.id}
|
||||
href={tab.href}
|
||||
className="flex items-center gap-2.5 px-6 py-5 text-[10px] font-bold uppercase tracking-[0.18em] transition-all relative whitespace-nowrap text-concrete hover:text-ink/60"
|
||||
style={{ color: isActive(tab.href) ? 'var(--ink)' : undefined }}
|
||||
>
|
||||
{section.icon}
|
||||
<span>{section.label}</span>
|
||||
<span style={{ color: isActive(tab.href) ? 'var(--ink)' : 'var(--concrete)' }}>{tab.icon}</span>
|
||||
{tab.label}
|
||||
{isActive(tab.href) && (
|
||||
<motion.div
|
||||
layoutId="activeSettingsTabLine"
|
||||
className="absolute bottom-0 left-0 right-0 h-0.5 bg-ink"
|
||||
transition={{ type: 'spring', bounce: 0.1, duration: 0.8 }}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
48
memento-note/components/settings/billing-history.tsx
Normal file
48
memento-note/components/settings/billing-history.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { ExternalLink, Receipt } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useLanguage } from '@/lib/i18n';
|
||||
|
||||
export function BillingHistory() {
|
||||
const { t } = useLanguage();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleOpenPortal = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/billing/portal', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? 'Failed');
|
||||
window.location.href = data.url;
|
||||
} catch {
|
||||
// ignore — portal not configured
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border/40 bg-paper p-6 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Receipt className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-semibold text-foreground">{t('billing.billingHistory')}</h3>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('billing.noUsage')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenPortal}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors disabled:opacity-60"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t('billing.viewInvoices')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
432
memento-note/components/settings/billing-plans.tsx
Normal file
432
memento-note/components/settings/billing-plans.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js';
|
||||
import { Check, Shield, Zap, Crown, X, ExternalLink, Loader2, CheckCircle2, Activity, Clock, ArrowRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useLanguage } from '@/lib/i18n';
|
||||
import { toast } from 'sonner';
|
||||
import { format } from 'date-fns';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
type Tier = 'PRO' | 'BUSINESS';
|
||||
type Interval = 'month' | 'year';
|
||||
|
||||
interface BillingStatus {
|
||||
tier: string;
|
||||
effectiveTier: string;
|
||||
status: string;
|
||||
currentPeriodEnd: string | null;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
hasStripeSubscription: boolean;
|
||||
}
|
||||
|
||||
const billingEnabled = process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true';
|
||||
|
||||
let stripePromise: ReturnType<typeof loadStripe> | null = null;
|
||||
function getStripePromise() {
|
||||
if (!billingEnabled) return null;
|
||||
if (!stripePromise && process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) {
|
||||
stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY);
|
||||
}
|
||||
return stripePromise;
|
||||
}
|
||||
|
||||
export function BillingPlans() {
|
||||
const { t } = useLanguage();
|
||||
const queryClient = useQueryClient();
|
||||
const [interval, setInterval] = useState<Interval>('month');
|
||||
const [checkoutClientSecret, setCheckoutClientSecret] = useState<string | null>(null);
|
||||
const [isCheckoutOpen, setIsCheckoutOpen] = useState(false);
|
||||
const [checkoutLoading, setCheckoutLoading] = useState<Tier | null>(null);
|
||||
const [portalLoading, setPortalLoading] = useState(false);
|
||||
const [successBanner, setSuccessBanner] = useState<string | null>(null);
|
||||
|
||||
const { data: status, isLoading } = useQuery<BillingStatus>({
|
||||
queryKey: ['billing', 'status'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/billing/status');
|
||||
if (!res.ok) throw new Error('Failed to fetch billing status');
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const { data: quotas } = useQuery({
|
||||
queryKey: ['usage', 'current'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/usage/current');
|
||||
if (!res.ok) throw new Error('Failed to fetch quotas');
|
||||
const data = await res.json();
|
||||
return data.quotas as Record<string, { remaining: number; limit: number; used: number }>;
|
||||
},
|
||||
refetchInterval: 60000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const sessionId = params.get('session_id');
|
||||
if (sessionId) {
|
||||
const tier = status?.effectiveTier ?? 'Pro';
|
||||
setSuccessBanner(t('billing.checkoutSuccessBody').replace('{tier}', tier));
|
||||
queryClient.invalidateQueries({ queryKey: ['usage', 'current'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['billing', 'status'] });
|
||||
window.history.replaceState({}, '', '/settings/billing');
|
||||
}
|
||||
}, [status, t, queryClient]);
|
||||
|
||||
const handleCheckout = async (tier: Tier) => {
|
||||
if (!billingEnabled) return;
|
||||
setCheckoutLoading(tier);
|
||||
try {
|
||||
const res = await fetch('/api/billing/create-checkout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tier, interval }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? 'Failed to create checkout');
|
||||
if (data.clientSecret) {
|
||||
setCheckoutClientSecret(data.clientSecret);
|
||||
setIsCheckoutOpen(true);
|
||||
} else if (data.url) {
|
||||
window.location.href = data.url;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[BillingPlans] checkout error:', err);
|
||||
toast.error('Failed to start checkout. Please try again.');
|
||||
} finally {
|
||||
setCheckoutLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePortal = async () => {
|
||||
setPortalLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/billing/portal', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? 'Failed to open portal');
|
||||
window.location.href = data.url;
|
||||
} catch (err) {
|
||||
console.error('[BillingPlans] portal error:', err);
|
||||
toast.error('Failed to open billing portal.');
|
||||
} finally {
|
||||
setPortalLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckoutComplete = useCallback(() => {
|
||||
setIsCheckoutOpen(false);
|
||||
setCheckoutClientSecret(null);
|
||||
const tier = status?.effectiveTier ?? 'Pro';
|
||||
setSuccessBanner(t('billing.checkoutSuccessBody').replace('{tier}', tier));
|
||||
queryClient.invalidateQueries({ queryKey: ['usage', 'current'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['billing', 'status'] });
|
||||
}, [status, t, queryClient]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const effectiveTier = status?.effectiveTier ?? 'BASIC';
|
||||
const isPaid = effectiveTier !== 'BASIC';
|
||||
|
||||
const aiUsed = quotas?.semantic_search?.used ?? 0;
|
||||
const aiLimit = quotas?.semantic_search?.limit ?? 50;
|
||||
const aiPct = aiLimit > 0 ? (aiUsed / aiLimit) * 100 : 0;
|
||||
|
||||
const plans = [
|
||||
{
|
||||
id: 'free',
|
||||
name: t('billing.freePlan'),
|
||||
price: t('billing.freePrice') || 'Gratuit',
|
||||
period: '',
|
||||
description: t('billing.freeDescription') || 'Pour découvrir la magie de Momento.',
|
||||
features: [
|
||||
t('billing.freeF1') || '100 Notes max',
|
||||
t('billing.freeF2') || '3 Carnets',
|
||||
t('billing.freeF3') || '50 crédits IA (Lifetime)',
|
||||
t('billing.freeF4') || 'Recherche sémantique',
|
||||
t('billing.freeF5') || 'Historique 7 jours',
|
||||
],
|
||||
current: effectiveTier === 'BASIC',
|
||||
buttonText: effectiveTier === 'BASIC' ? (t('billing.currentPlan') || 'Plan Actuel') : t('billing.startCheckout'),
|
||||
buttonClass: effectiveTier === 'BASIC'
|
||||
? 'bg-paper text-concrete cursor-default'
|
||||
: 'bg-ink text-white shadow-xl shadow-ink/20 hover:scale-[1.02] active:scale-95',
|
||||
onClick: () => {},
|
||||
},
|
||||
{
|
||||
id: 'pro',
|
||||
name: t('billing.proPlan'),
|
||||
price: interval === 'month' ? (t('billing.proPrice') || '9,90€') : (t('billing.proAnnualPrice') || '99€'),
|
||||
period: interval === 'month' ? '/mois' : '/an',
|
||||
description: t('billing.proDescription') || 'Pour les consultants et créateurs exigeants.',
|
||||
features: [
|
||||
t('billing.proFeature1') || 'Notes illimitées',
|
||||
t('billing.proFeature2') || 'BYOK (OpenAI/Anthropic)',
|
||||
t('billing.proFeature3') || '200 recherches sémantiques',
|
||||
t('billing.proFeature4') || 'Agents (12 runs/mois)',
|
||||
t('billing.proFeature5') || 'Historique 30 jours',
|
||||
t('billing.proFeature6') || 'Support Email',
|
||||
],
|
||||
current: effectiveTier === 'PRO',
|
||||
popular: true,
|
||||
buttonText: effectiveTier === 'PRO' ? (t('billing.currentPlan') || 'Plan Actuel') : (t('billing.proCta') || 'Passer au Plan Pro'),
|
||||
buttonClass: effectiveTier === 'PRO'
|
||||
? 'bg-paper text-concrete cursor-default'
|
||||
: 'bg-brand-accent text-white shadow-xl shadow-brand-accent/20 hover:scale-[1.02] active:scale-95',
|
||||
onClick: () => handleCheckout('PRO'),
|
||||
},
|
||||
{
|
||||
id: 'business',
|
||||
name: t('billing.businessPlan'),
|
||||
price: interval === 'month' ? (t('billing.businessPrice') || '29,90€') : (t('billing.businessAnnualPrice') || '299€'),
|
||||
period: interval === 'month' ? '/mois' : '/an',
|
||||
description: t('billing.businessDescription') || 'Pour les équipes et chefs de produit.',
|
||||
features: [
|
||||
t('billing.businessFeature1') || '10 Collaborateurs inclus',
|
||||
t('billing.businessFeature2') || 'BYOK (13 fournisseurs)',
|
||||
t('billing.businessFeature3') || '1000 recherches sémantiques',
|
||||
t('billing.businessFeature4') || 'Agents (60 runs/mois)',
|
||||
t('billing.businessFeature5') || 'Brainstorm illimité',
|
||||
t('billing.businessFeature6') || 'Accès API',
|
||||
],
|
||||
current: effectiveTier === 'BUSINESS',
|
||||
buttonText: effectiveTier === 'BUSINESS' ? (t('billing.currentPlan') || 'Plan Actuel') : (t('billing.businessCta') || 'Choisir Plan Business'),
|
||||
buttonClass: effectiveTier === 'BUSINESS'
|
||||
? 'bg-paper text-concrete cursor-default'
|
||||
: 'bg-ink text-white shadow-xl shadow-ink/20 hover:scale-[1.02] active:scale-95',
|
||||
onClick: () => handleCheckout('BUSINESS'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-12"
|
||||
>
|
||||
{/* Success Banner */}
|
||||
{successBanner && (
|
||||
<div className="flex items-start gap-3 rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-4">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-foreground">{t('billing.checkoutSuccessTitle')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{successBanner}</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setSuccessBanner(null)} className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete opacity-60">
|
||||
{t('billing.subtitle') || 'Gérer votre abonnement et votre facturation'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Usage Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-3xl p-8 space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-brand-accent/10 text-brand-accent rounded-xl">
|
||||
<Activity size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-ink">{t('billing.currentUsage') || 'Utilisation actuelle'}</h4>
|
||||
<p className="text-[10px] text-concrete uppercase tracking-widest">{t('billing.currentPeriod') || 'Période en cours'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-[11px] font-medium text-concrete uppercase tracking-wider">
|
||||
<span>{t('billing.aiCredits') || 'Crédits IA'}</span>
|
||||
<span>{aiUsed} / {aiLimit} {t('billing.used') || 'utilisés'}</span>
|
||||
</div>
|
||||
<div className="h-2 w-full bg-slate-100 dark:bg-white/5 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-brand-accent rounded-full" style={{ width: `${aiPct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-3xl p-8 space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-paper dark:bg-white/10 text-concrete rounded-xl">
|
||||
<Clock size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-ink">{t('billing.billing') || 'Facturation'}</h4>
|
||||
<p className="text-[10px] text-concrete uppercase tracking-widest">{t('billing.renewal') || 'Renouvellement'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-concrete font-light">
|
||||
{isPaid
|
||||
? t('billing.paidPlanDesc') || 'Votre abonnement se renouvelle automatiquement.'
|
||||
: t('billing.freePlanDesc') || 'Votre plan gratuit n\'expire jamais. Passez à la vitesse supérieure pour débloquer toute la puissance de Momento.'}
|
||||
</p>
|
||||
{isPaid && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePortal}
|
||||
disabled={portalLoading}
|
||||
className="mt-2 flex items-center gap-2 text-[10px] font-bold text-brand-accent uppercase tracking-widest hover:underline disabled:opacity-60"
|
||||
>
|
||||
{portalLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : <ExternalLink className="h-3 w-3" />}
|
||||
{t('billing.openPortal')}
|
||||
</button>
|
||||
)}
|
||||
<div className="pt-4 flex items-center justify-between border-t border-border/40 mt-4">
|
||||
<span className="text-[11px] font-bold text-ink uppercase tracking-widest">{t('billing.currentPlan')}</span>
|
||||
<span className="text-[11px] font-bold text-brand-accent uppercase tracking-widest">
|
||||
{effectiveTier === 'BASIC' ? 'GRATUIT' : effectiveTier}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interval Toggle */}
|
||||
{billingEnabled && effectiveTier === 'BASIC' && (
|
||||
<div className="flex items-center gap-2 justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInterval('month')}
|
||||
className={cn(
|
||||
'px-4 py-1.5 text-xs font-medium rounded-full transition-all',
|
||||
interval === 'month' ? 'bg-ink text-paper' : 'text-concrete hover:text-ink'
|
||||
)}
|
||||
>
|
||||
{t('billing.monthly')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInterval('year')}
|
||||
className={cn(
|
||||
'px-4 py-1.5 text-xs font-medium rounded-full transition-all',
|
||||
interval === 'year' ? 'bg-ink text-paper' : 'text-concrete hover:text-ink'
|
||||
)}
|
||||
>
|
||||
{t('billing.annual')}
|
||||
<span className="ms-1 text-emerald-600 dark:text-emerald-400">{t('billing.save')} ~17%</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plan Cards */}
|
||||
{(billingEnabled || true) && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{plans.map((plan) => (
|
||||
<div
|
||||
key={plan.id}
|
||||
className={cn(
|
||||
'relative p-8 rounded-[40px] border transition-all duration-500 overflow-hidden group flex flex-col',
|
||||
plan.popular
|
||||
? 'bg-white dark:bg-paper border-brand-accent shadow-2xl shadow-brand-accent/10 scale-105 z-10'
|
||||
: 'bg-white/40 dark:bg-white/5 border-border hover:border-concrete/30'
|
||||
)}
|
||||
>
|
||||
{plan.popular && (
|
||||
<div className="absolute top-0 right-0 py-1.5 px-6 bg-brand-accent text-white text-[9px] font-bold uppercase tracking-widest rounded-bl-2xl">
|
||||
{t('billing.recommended') || 'Recommandé'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-8 space-y-2">
|
||||
<h4 className="text-xl font-serif font-bold text-ink">{plan.name}</h4>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-4xl font-serif font-bold text-ink">{plan.price}</span>
|
||||
<span className="text-concrete text-xs font-light italic">{plan.period}</span>
|
||||
</div>
|
||||
<p className="text-xs text-concrete font-light leading-relaxed pe-4">{plan.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mb-10 flex-1">
|
||||
{plan.features.map((feature, i) => (
|
||||
<div key={i} className="flex items-start gap-3">
|
||||
<div className={cn('mt-1 p-0.5 rounded-full', plan.popular ? 'bg-brand-accent/10 text-brand-accent' : 'bg-concrete/10 text-concrete')}>
|
||||
<Check size={10} />
|
||||
</div>
|
||||
<span className="text-xs font-light text-ink/80">{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={plan.onClick}
|
||||
disabled={plan.current || checkoutLoading !== null}
|
||||
className={cn('w-full py-4 rounded-2xl text-[10px] font-bold uppercase tracking-[0.2em] transition-all duration-300', plan.buttonClass)}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{checkoutLoading && !plan.current ? <Loader2 className="h-4 w-4 animate-spin" /> : plan.buttonText}
|
||||
{!plan.current && <ArrowRight size={14} />}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer Info */}
|
||||
<div className="bg-slate-50 dark:bg-black/20 rounded-[32px] p-8 border border-border/40 flex flex-col md:flex-row items-center justify-between gap-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-white dark:bg-paper rounded-2xl shadow-sm border border-border">
|
||||
<Shield size={24} className="text-brand-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="text-sm font-bold text-ink">{t('billing.secureTransactions') || 'Transactions sécurisées'}</h5>
|
||||
<p className="text-xs text-concrete font-light">{t('billing.secureDesc') || 'Paiement via Stripe. Annulez à tout moment, sans engagement.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap size={16} className="text-ochre" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-concrete">{t('billing.instantActivation') || 'Activation instantanée'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Crown size={16} className="text-amber-500" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-concrete">{t('billing.satisfactionGuarantee') || 'Garantie satisfait'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Embedded Checkout Modal */}
|
||||
{isCheckoutOpen && checkoutClientSecret && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<h3 className="font-semibold text-foreground">{t('billing.upgradeTitle')}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsCheckoutOpen(false);
|
||||
setCheckoutClientSecret(null);
|
||||
toast.info(t('billing.checkoutCanceled'));
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<EmbeddedCheckoutProvider
|
||||
stripe={getStripePromise()}
|
||||
options={{ clientSecret: checkoutClientSecret, onComplete: handleCheckoutComplete }}
|
||||
>
|
||||
<EmbeddedCheckout />
|
||||
</EmbeddedCheckoutProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -4,3 +4,6 @@ export { SettingToggle } from './SettingToggle'
|
||||
export { SettingSelect } from './SettingSelect'
|
||||
export { SettingInput } from './SettingInput'
|
||||
export { SettingsSearch } from './SettingsSearch'
|
||||
export { BillingPlans } from './billing-plans'
|
||||
export { UsageBreakdown } from './usage-breakdown'
|
||||
export { BillingHistory } from './billing-history'
|
||||
|
||||
99
memento-note/components/settings/usage-breakdown.tsx
Normal file
99
memento-note/components/settings/usage-breakdown.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Loader2, BarChart2 } from 'lucide-react';
|
||||
import { useLanguage } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface QuotaEntry {
|
||||
remaining: number;
|
||||
limit: number;
|
||||
used: number;
|
||||
}
|
||||
|
||||
type Quotas = Record<string, QuotaEntry>;
|
||||
|
||||
const FEATURE_LABEL_KEYS: Record<string, string> = {
|
||||
aiSummary: 'sidebar.aiSummary',
|
||||
aiFlashcards: 'sidebar.aiFlashcards',
|
||||
aiMindmap: 'sidebar.aiMindmap',
|
||||
aiTranscribe: 'sidebar.aiTranscribe',
|
||||
aiDiagram: 'sidebar.aiDiagram',
|
||||
aiAgent: 'sidebar.aiAgent',
|
||||
};
|
||||
|
||||
function UsageBar({ used, limit, isUnlimited }: { used: number; limit: number; isUnlimited: boolean }) {
|
||||
const pct = isUnlimited ? 0 : Math.min(100, limit > 0 ? (used / limit) * 100 : 0);
|
||||
const color =
|
||||
pct >= 90 ? 'bg-rose-500' : pct >= 70 ? 'bg-amber-500' : 'bg-emerald-500';
|
||||
|
||||
return (
|
||||
<div className="h-1.5 w-full rounded-full bg-foreground/10 overflow-hidden">
|
||||
{!isUnlimited && (
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all', color)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UsageBreakdown() {
|
||||
const { t } = useLanguage();
|
||||
|
||||
const { data, isLoading } = useQuery<{ quotas: Quotas }>({
|
||||
queryKey: ['usage', 'current'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/usage/current');
|
||||
if (!res.ok) throw new Error('Failed to fetch usage');
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const quotas = data?.quotas ?? {};
|
||||
const entries = Object.entries(quotas);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border/40 bg-paper p-6 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart2 className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{t('billing.usageThisPeriod')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground py-2">{t('billing.noUsage')}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{entries.map(([feature, quota]) => {
|
||||
const isUnlimited = quota.limit === -1 || !isFinite(quota.limit);
|
||||
const labelKey = FEATURE_LABEL_KEYS[feature];
|
||||
const label = labelKey ? t(labelKey) : feature;
|
||||
|
||||
return (
|
||||
<div key={feature} className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-xs font-medium text-foreground tabular-nums">
|
||||
{isUnlimited ? (
|
||||
<span className="text-emerald-600 dark:text-emerald-400">{t('billing.unlimited')}</span>
|
||||
) : (
|
||||
`${quota.used} / ${quota.limit}`
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<UsageBar used={quota.used} limit={quota.limit} isUnlimited={isUnlimited} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,14 +28,15 @@ import {
|
||||
Pin,
|
||||
PinOff,
|
||||
Sparkles,
|
||||
Home,
|
||||
} from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { applyDocumentTheme } from '@/lib/apply-document-theme'
|
||||
import { getAllNotes, getTrashCount } from '@/app/actions/notes'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { Notebook, Note } from '@/lib/types'
|
||||
import { toast } from 'sonner'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { getNoteDisplayTitle } from '@/lib/note-preview'
|
||||
import { CreateNotebookDialog } from './create-notebook-dialog'
|
||||
@@ -50,9 +51,10 @@ import {
|
||||
import { signOut } from 'next-auth/react'
|
||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||
import { useBrainstormSessions, useDeleteBrainstorm } from '@/hooks/use-brainstorm'
|
||||
import { UsageMeter } from './usage-meter'
|
||||
|
||||
type NavigationView = 'notebooks' | 'agents' | 'reminders' | 'brainstorms'
|
||||
type SortOrder = 'newest' | 'oldest' | 'alpha'
|
||||
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
|
||||
|
||||
function NoteLink({
|
||||
title,
|
||||
@@ -103,11 +105,11 @@ function SidebarBrainstorms() {
|
||||
if (!sessions || sessions.length === 0) {
|
||||
return (
|
||||
<div className="px-4 py-6 text-center">
|
||||
<Sparkles size={20} className="mx-auto text-orange-400/40 mb-2" />
|
||||
<p className="text-[11px] text-muted-foreground">{t('brainstorm.noSessions')}</p>
|
||||
<Sparkles size={20} className="mx-auto text-brand-accent/40 mb-2" />
|
||||
<p className="text-[11px] text-concrete">{t('brainstorm.noSessions')}</p>
|
||||
<button
|
||||
onClick={() => router.push('/brainstorm')}
|
||||
className="mt-2 text-[11px] text-orange-500 hover:text-orange-400 font-medium"
|
||||
className="mt-2 text-[11px] text-brand-accent hover:text-brand-accent/80 font-medium"
|
||||
>
|
||||
{t('brainstorm.startOne')} →
|
||||
</button>
|
||||
@@ -121,21 +123,23 @@ function SidebarBrainstorms() {
|
||||
<div key={s.id} className="relative group/item">
|
||||
<button
|
||||
onClick={() => router.replace(`/brainstorm?session=${s.id}`)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2.5 rounded-xl transition-all duration-200 text-start hover:bg-memento-blue/5 group ${
|
||||
(s as any)._owned === false ? 'border-s-2 border-memento-blue/30 dark:border-memento-blue/70' : ''
|
||||
className={`w-full flex items-center gap-3 px-4 py-2.5 rounded-xl transition-all duration-200 text-start hover:bg-brand-accent/5 group ${
|
||||
(s as any)._owned === false ? 'border-s-2 border-brand-accent/30 dark:border-brand-accent/70' : ''
|
||||
}`}
|
||||
>
|
||||
<div className={`w-7 h-7 rounded-full flex items-center justify-center shrink-0 ${
|
||||
(s as any)._owned === false
|
||||
? 'border border-memento-blue/20 dark:border-memento-blue/80 bg-memento-blue/5 dark:bg-memento-blue/20'
|
||||
: 'border border-orange-200 dark:border-orange-800/40 bg-orange-50 dark:bg-orange-900/20'
|
||||
? 'border border-brand-accent/20 dark:border-brand-accent/80 bg-brand-accent/5 dark:bg-brand-accent/20'
|
||||
: 'border border-brand-accent/20 dark:border-brand-accent/40 bg-brand-accent/5 dark:bg-brand-accent/20'
|
||||
}`}>
|
||||
<Sparkles size={12} className={(s as any)._owned === false ? 'text-memento-blue' : 'text-orange-500'} />
|
||||
<Sparkles size={12} className="text-brand-accent" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[12px] font-medium truncate">
|
||||
{s.seedIdea}
|
||||
{(s as any)._owned === false && <span className="text-[9px] ms-1.5 text-memento-blue/70 font-normal">· partagé</span>}
|
||||
{(s as any)._owned === false && (
|
||||
<span className="text-[9px] ms-1.5 text-brand-accent/70 font-normal">{t('sidebar.sharedNotebookBadge')}</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{s.activeIdeas} {t('brainstorm.ideas')} · {new Date(s.createdAt).toLocaleDateString()}
|
||||
@@ -177,6 +181,7 @@ function SidebarCarnetItem({
|
||||
level,
|
||||
isExpanded,
|
||||
toggleExpand,
|
||||
hasChildNotebooks,
|
||||
}: {
|
||||
carnet: { id: string; name: string; initial: string; isPrivate?: boolean }
|
||||
isActive: boolean
|
||||
@@ -195,10 +200,11 @@ function SidebarCarnetItem({
|
||||
level: number
|
||||
isExpanded: boolean
|
||||
toggleExpand: () => void
|
||||
hasChildNotebooks?: boolean
|
||||
}) {
|
||||
const { t, language } = useLanguage()
|
||||
const isRtl = language === 'fa' || language === 'ar'
|
||||
const hasChildren = React.Children.count(children) > 0
|
||||
const hasChildren = hasChildNotebooks || React.Children.count(children) > 0
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null)
|
||||
|
||||
// Close context menu on outside click
|
||||
@@ -213,7 +219,7 @@ function SidebarCarnetItem({
|
||||
<div className={cn('transition-opacity', isDragging && 'opacity-40')}>
|
||||
<div
|
||||
className="flex items-center group relative h-10"
|
||||
style={{ paddingInlineStart: `${level * 16}px` }}
|
||||
style={{ paddingInlineStart: `${level * 24 + 8}px` }}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
@@ -260,14 +266,14 @@ function SidebarCarnetItem({
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="active-indicator"
|
||||
className="absolute -start-1 w-1 h-4 bg-blueprint rounded-full"
|
||||
className="absolute -start-1 w-1 h-4 bg-brand-accent rounded-full"
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<div className={cn(
|
||||
'w-6 h-6 rounded-md flex items-center justify-center text-[10px] font-bold border shrink-0 transition-all',
|
||||
isActive
|
||||
? 'bg-blueprint text-white border-blueprint'
|
||||
? 'bg-brand-accent text-white border-brand-accent'
|
||||
: 'bg-white/60 dark:bg-white/5 text-ink dark:text-foreground border-border'
|
||||
)}>
|
||||
{carnet.initial}
|
||||
@@ -285,7 +291,7 @@ function SidebarCarnetItem({
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover/item:opacity-100 transition-opacity shrink-0">
|
||||
{isPinned && (
|
||||
<span className="text-blueprint" title={t('notebook.pinnedFrozenTooltip')}>
|
||||
<span className="text-brand-accent" title={t('notebook.pinnedFrozenTooltip')}>
|
||||
<Pin size={9} className="opacity-70" />
|
||||
</span>
|
||||
)}
|
||||
@@ -338,8 +344,8 @@ function SidebarCarnetItem({
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-[12px] text-ink hover:bg-foreground/5 transition-colors"
|
||||
>
|
||||
{isPinned
|
||||
? <><PinOff size={13} className="text-blueprint" /><span>{t('sidebar.unfreezePinnedNotebook')}</span></>
|
||||
: <><Pin size={13} className="text-blueprint" /><span>{t('sidebar.freezePinnedNotebook')}</span></>
|
||||
? <><PinOff size={13} className="text-brand-accent" /><span>{t('sidebar.unfreezePinnedNotebook')}</span></>
|
||||
: <><Pin size={13} className="text-brand-accent" /><span>{t('sidebar.freezePinnedNotebook')}</span></>
|
||||
}
|
||||
</button>
|
||||
<div className="mx-3 my-1 border-t border-border/50" />
|
||||
@@ -411,7 +417,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
const router = useRouter()
|
||||
const { t, language } = useLanguage()
|
||||
const isRtl = language === 'fa' || language === 'ar'
|
||||
const { notebooks, trashNotebook, updateNotebookOrderOptimistic } = useNotebooks()
|
||||
const { notebooks, trashNotebook, updateNotebookOrderOptimistic, moveNotebookToParent } = useNotebooks()
|
||||
const { refreshKey } = useNoteRefresh()
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||
const [createParentId, setCreateParentId] = useState<string | null>(null)
|
||||
@@ -470,9 +476,9 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
!searchParams.get('trashed')
|
||||
|
||||
useEffect(() => {
|
||||
setActiveView(
|
||||
pathname.startsWith('/agents') || pathname.startsWith('/lab') ? 'agents' : 'notebooks'
|
||||
)
|
||||
if (pathname.startsWith('/brainstorm')) setActiveView('brainstorms')
|
||||
else if (pathname.startsWith('/agents') || pathname.startsWith('/lab')) setActiveView('agents')
|
||||
else setActiveView('notebooks')
|
||||
}, [pathname])
|
||||
|
||||
const displayName = user?.name || user?.email || ''
|
||||
@@ -481,6 +487,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
// Sorted list for the sort dropdown (not used directly when dragging)
|
||||
const sortedNotebooks = useMemo(() => {
|
||||
const arr = [...notebooks]
|
||||
if (sortOrder === 'manual') return arr.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||
if (sortOrder === 'alpha') return arr.sort((a, b) => a.name.localeCompare(b.name))
|
||||
if (sortOrder === 'newest') return arr.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
if (sortOrder === 'oldest') return arr.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||
@@ -542,60 +549,60 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
router.push(`/?${params.toString()}`)
|
||||
}
|
||||
|
||||
// ── Drag handlers ──
|
||||
// ── Drag state ──
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null)
|
||||
const [dropAction, setDropAction] = useState<'into' | null>(null)
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, notebookId: string) => {
|
||||
setDraggedId(notebookId)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, notebookId: string) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
if (dragOverId.current === notebookId) return
|
||||
dragOverId.current = notebookId
|
||||
|
||||
if (!draggedId || draggedId === notebookId) return
|
||||
setOrderedNotebooks(prev => {
|
||||
const fromIdx = prev.findIndex(n => n.id === draggedId)
|
||||
const toIdx = prev.findIndex(n => n.id === notebookId)
|
||||
if (fromIdx === -1 || toIdx === -1) return prev
|
||||
const next = [...prev]
|
||||
const [item] = next.splice(fromIdx, 1)
|
||||
next.splice(toIdx, 0, item)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
if (!draggedId) return
|
||||
const savedOrder = [...orderedNotebooks]
|
||||
setDraggedId(null)
|
||||
dragOverId.current = null
|
||||
// Block the sync effect so the server reload doesn't overwrite local order
|
||||
isSavingRef.current = true
|
||||
try {
|
||||
await updateNotebookOrderOptimistic(savedOrder.map(n => n.id))
|
||||
// Keep local order — server will return them in the right order next load
|
||||
setOrderedNotebooks(savedOrder)
|
||||
} catch {
|
||||
// On failure, revert to original server order
|
||||
isSavingRef.current = false
|
||||
setOrderedNotebooks(sortedNotebooks)
|
||||
} finally {
|
||||
// Allow sync again after 2 s (time for the server reload to settle)
|
||||
setTimeout(() => {
|
||||
isSavingRef.current = false
|
||||
}, 2000)
|
||||
}
|
||||
e.dataTransfer.setData('text/plain', notebookId)
|
||||
}
|
||||
|
||||
const handleDragEnd = () => {
|
||||
if (draggedId) {
|
||||
// Drag cancelled without drop — restore
|
||||
setDraggedId(null)
|
||||
dragOverId.current = null
|
||||
isSavingRef.current = false
|
||||
setDropTarget(null)
|
||||
setDropAction(null)
|
||||
setOrderedNotebooks(sortedNotebooks)
|
||||
}
|
||||
|
||||
const handleDropOnNotebook = async (e: React.DragEvent, targetId: string) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDropTarget(null)
|
||||
setDropAction(null)
|
||||
const dragId = draggedId || e.dataTransfer.getData('text/plain')
|
||||
if (!dragId || dragId === targetId) {
|
||||
setDraggedId(null)
|
||||
dragOverId.current = null
|
||||
isSavingRef.current = false
|
||||
return
|
||||
}
|
||||
setDraggedId(null)
|
||||
dragOverId.current = null
|
||||
try {
|
||||
await moveNotebookToParent(dragId, targetId)
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('sidebar.moveFailed') || 'Failed to move notebook')
|
||||
setOrderedNotebooks(sortedNotebooks)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDropToRoot = async (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDropTarget(null)
|
||||
setDropAction(null)
|
||||
const dragId = draggedId || e.dataTransfer.getData('text/plain')
|
||||
if (!dragId) {
|
||||
setDraggedId(null)
|
||||
return
|
||||
}
|
||||
setDraggedId(null)
|
||||
dragOverId.current = null
|
||||
try {
|
||||
await moveNotebookToParent(dragId, null)
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('sidebar.moveFailed') || 'Failed to move notebook')
|
||||
setOrderedNotebooks(sortedNotebooks)
|
||||
}
|
||||
}
|
||||
@@ -604,6 +611,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
newest: t('sidebar.sortNewest'),
|
||||
oldest: t('sidebar.sortOldest'),
|
||||
alpha: t('sidebar.sortAlpha'),
|
||||
manual: t('sidebar.sortManual'),
|
||||
}
|
||||
|
||||
const toggleExpand = useCallback((id: string) => {
|
||||
@@ -623,6 +631,22 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
// Auto-expand all parent notebooks on first load
|
||||
useEffect(() => {
|
||||
if (orderedNotebooks.length === 0) return
|
||||
const parentIds = new Set<string>()
|
||||
for (const nb of orderedNotebooks) {
|
||||
if (nb.parentId) parentIds.add(nb.parentId)
|
||||
}
|
||||
if (parentIds.size > 0) {
|
||||
setExpandedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
for (const id of parentIds) next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}, [orderedNotebooks])
|
||||
|
||||
const togglePin = useCallback((id: string) => {
|
||||
setPinnedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
@@ -701,21 +725,49 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
const notes = notebookNotes[notebook.id] || []
|
||||
const isDragging = draggedId === notebook.id
|
||||
const children = childNotebooks.get(notebook.id) || []
|
||||
const hasActiveDescendant = children.some(c =>
|
||||
currentNotebookId === c.id || (childNotebooks.get(c.id) || []).some(gc => currentNotebookId === gc.id)
|
||||
)
|
||||
const hasDescendant = (nid: string): boolean => {
|
||||
const desc = childNotebooks.get(nid) || []
|
||||
return desc.some(c => currentNotebookId === c.id || hasDescendant(c.id))
|
||||
}
|
||||
const hasActiveDescendant = hasDescendant(notebook.id)
|
||||
// A notebook stays expanded if: manually expanded, has active descendant, OR is pinned
|
||||
const isExpanded = expandedIds.has(notebook.id) || hasActiveDescendant || pinnedIds.has(notebook.id)
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={notebook.id}
|
||||
>
|
||||
<motion.div key={notebook.id}>
|
||||
<div
|
||||
draggable={level === 0}
|
||||
onDragStart={level === 0 ? (e) => handleDragStart(e, notebook.id) : undefined}
|
||||
onDragOver={level === 0 ? (e) => handleDragOver(e, notebook.id) : undefined}
|
||||
onDragEnd={level === 0 ? handleDragEnd : undefined}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDragStart(e, notebook.id)
|
||||
}}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!draggedId || draggedId === notebook.id) return
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
setDropTarget(notebook.id)
|
||||
setDropAction('into')
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (e.currentTarget.contains(e.relatedTarget as Node)) return
|
||||
if (dropTarget === notebook.id) {
|
||||
setDropTarget(null)
|
||||
setDropAction(null)
|
||||
}
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleDropOnNotebook(e, notebook.id)
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-lg transition-colors',
|
||||
dropTarget === notebook.id && dropAction === 'into' && draggedId && draggedId !== notebook.id
|
||||
&& 'bg-brand-accent/10 ring-1 ring-brand-accent/30',
|
||||
isDragging && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<SidebarCarnetItem
|
||||
carnet={{
|
||||
@@ -749,117 +801,122 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
level={level}
|
||||
isExpanded={isExpanded}
|
||||
toggleExpand={() => toggleExpand(notebook.id)}
|
||||
>
|
||||
{renderCarnetTree(notebook.id, level + 1)}
|
||||
</SidebarCarnetItem>
|
||||
hasChildNotebooks={children.length > 0}
|
||||
/>
|
||||
</div>
|
||||
{isExpanded && renderCarnetTree(notebook.id, level + 1)}
|
||||
</motion.div>
|
||||
)
|
||||
})
|
||||
}, [rootNotebooks, childNotebooks, currentNotebookId, currentNoteId, notebookNotes, draggedId, expandedIds, toggleExpand, handleCarnetClick, handleNoteClick, handleDragStart, handleDragOver, handleDragEnd, handleStartRename])
|
||||
}, [rootNotebooks, childNotebooks, currentNotebookId, currentNoteId, notebookNotes, draggedId, dropTarget, dropAction, expandedIds, toggleExpand, handleCarnetClick, handleNoteClick, handleDragStart, handleDragEnd, handleDropOnNotebook, handleStartRename])
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside
|
||||
className={cn(
|
||||
'hidden h-full min-h-0 w-72 shrink-0 flex-col lg:w-80 md:flex',
|
||||
'border-e border-border/40 bg-memento-sidebar backdrop-blur-md sidebar-shadow dark:border-white/6 dark:bg-[#252525] dark:backdrop-blur-none',
|
||||
'border-e border-border/40 bg-white/30 backdrop-blur-md sidebar-shadow dark:border-white/6 dark:bg-[#151515] dark:backdrop-blur-none',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* ── Top: Avatar + View Toggle ── */}
|
||||
<div className="p-6 flex items-center justify-between mb-4">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-full outline-none ring-offset-background transition-shadow hover:ring-2 hover:ring-primary/30 focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={t('sidebar.accountMenu')}
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-secondary border border-black/10 flex items-center justify-center text-foreground font-memento-serif text-lg shadow-sm">
|
||||
{user?.image ? (
|
||||
<Avatar className="size-10 ring-1 ring-border/60">
|
||||
<AvatarImage src={user.image} alt="" />
|
||||
<AvatarFallback className="bg-secondary text-sm font-semibold text-muted-ink">{initial}</AvatarFallback>
|
||||
</Avatar>
|
||||
) : (
|
||||
<span>{initial}</span>
|
||||
)}
|
||||
{/* ── Top: Logo + Icons + View Toggle ── */}
|
||||
<div className="p-6 mb-8 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="flex items-center gap-2 group/logo cursor-pointer">
|
||||
<div className="w-10 h-10 bg-brand-accent flex items-center justify-center rounded-xl shadow-lg shadow-brand-accent/10 rotate-3 group-hover/logo:rotate-0 transition-all duration-500">
|
||||
<span className="text-white font-serif text-xl font-bold">M</span>
|
||||
</div>
|
||||
<span className="text-lg font-serif font-bold tracking-tight text-ink dark:text-paper">Memento</span>
|
||||
</div>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-52 bg-popover border-border">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings/profile" className="flex items-center gap-2 cursor-pointer">
|
||||
<User className="h-4 w-4" />
|
||||
{t('sidebar.profile') || 'Profil'}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings" className="flex items-center gap-2 cursor-pointer">
|
||||
<Settings className="h-4 w-4" />
|
||||
{t('nav.settings') || 'Paramètres'}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{(user as { role?: string } | undefined)?.role === 'ADMIN' && (
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-52 bg-popover border-border">
|
||||
<DropdownMenuItem asChild>
|
||||
<a href="/admin" className="flex items-center gap-2 cursor-pointer">
|
||||
<Shield className="h-4 w-4" />
|
||||
{t('nav.adminDashboard') || 'Administration'}
|
||||
</a>
|
||||
<Link href="/settings/profile" className="flex items-center gap-2 cursor-pointer">
|
||||
<User className="h-4 w-4" />
|
||||
{t('sidebar.profile') || 'Profil'}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => signOut({ callbackUrl: '/login' })}
|
||||
>
|
||||
<LogOut className="h-4 w-4 me-2" />
|
||||
{t('sidebar.signOut') || 'Se déconnecter'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{(user as { role?: string } | undefined)?.role === 'ADMIN' && (
|
||||
<DropdownMenuItem asChild>
|
||||
<a href="/admin" className="flex items-center gap-2 cursor-pointer">
|
||||
<Shield className="h-4 w-4" />
|
||||
{t('nav.adminDashboard') || 'Administration'}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => signOut({ callbackUrl: '/login' })}
|
||||
>
|
||||
<LogOut className="h-4 w-4 me-2" />
|
||||
{t('sidebar.signOut') || 'Se déconnecter'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 text-muted-foreground hover:text-foreground transition-all bg-white/50 dark:bg-white/10 rounded-full border border-border dark:border-white/10"
|
||||
>
|
||||
{isDark ? <Sun size={14} /> : <Moon size={14} />}
|
||||
</button>
|
||||
|
||||
<NotificationPanel />
|
||||
<div className="flex bg-white/50 dark:bg-white/5 p-1 rounded-full border border-border dark:border-white/10 transition-all">
|
||||
<button
|
||||
onClick={() => { setActiveView('notebooks'); if (pathname !== '/') router.push('/') }}
|
||||
className={cn('p-1.5 rounded-full transition-all', activeView === 'notebooks' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink')}
|
||||
title={t('nav.notebooks')}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Link
|
||||
href="/settings"
|
||||
className={cn(
|
||||
'p-1.5 transition-all rounded-lg border flex items-center justify-center',
|
||||
pathname.startsWith('/settings')
|
||||
? 'bg-brand-accent text-white border-brand-accent shadow-lg shadow-brand-accent/20'
|
||||
: 'text-muted-ink hover:text-ink hover:bg-white/50 dark:hover:bg-white/10 border-transparent hover:border-border'
|
||||
)}
|
||||
title={t('nav.settings')}
|
||||
>
|
||||
<BookOpen size={14} />
|
||||
<Settings size={14} />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => { router.push('/') }}
|
||||
className="p-1.5 text-muted-ink hover:text-ink transition-all hover:bg-white/50 dark:hover:bg-white/10 rounded-lg border border-transparent hover:border-border"
|
||||
title={t('nav.home') || 'Accueil'}
|
||||
>
|
||||
<Home size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveView('reminders')}
|
||||
className={cn('p-1.5 rounded-full transition-all', activeView === 'reminders' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink')}
|
||||
title={t('sidebar.reminders')}
|
||||
onClick={toggleTheme}
|
||||
className="p-1.5 text-muted-ink hover:text-ink transition-all hover:bg-white/50 dark:hover:bg-white/10 rounded-lg border border-transparent hover:border-border"
|
||||
>
|
||||
<Clock size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveView('agents'); router.push('/agents') }}
|
||||
className={cn('p-1.5 rounded-full transition-all', activeView === 'agents' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink')}
|
||||
title={t('nav.agents')}
|
||||
>
|
||||
<Bot size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveView('brainstorms'); router.push('/brainstorm') }}
|
||||
className={cn('p-1.5 rounded-full transition-all', activeView === 'brainstorms' ? 'bg-orange-500 text-white shadow-sm' : 'text-muted-ink hover:text-ink')}
|
||||
title={t('brainstorm.sessions')}
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
{isDark ? <Sun size={14} /> : <Moon size={14} />}
|
||||
</button>
|
||||
<NotificationPanel />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex bg-white/50 dark:bg-white/10 p-1 rounded-xl border border-border dark:border-white/10">
|
||||
<button
|
||||
onClick={() => { setActiveView('notebooks'); if (pathname !== '/') router.push('/') }}
|
||||
className={cn('flex-1 flex items-center justify-center py-1.5 rounded-lg transition-all', activeView === 'notebooks' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-white/50')}
|
||||
title={t('nav.notebooks')}
|
||||
>
|
||||
<BookOpen size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveView('reminders')}
|
||||
className={cn('flex-1 flex items-center justify-center py-1.5 rounded-lg transition-all', activeView === 'reminders' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-white/50')}
|
||||
title={t('sidebar.reminders')}
|
||||
>
|
||||
<Clock size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveView('agents'); router.push('/agents') }}
|
||||
className={cn('flex-1 flex items-center justify-center py-1.5 rounded-lg transition-all', activeView === 'agents' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-white/50')}
|
||||
title={t('nav.agents')}
|
||||
>
|
||||
<Bot size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveView('brainstorms'); router.push('/brainstorm') }}
|
||||
className={cn('flex-1 flex items-center justify-center py-1.5 rounded-lg transition-all', activeView === 'brainstorms' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-white/50')}
|
||||
title={t('brainstorm.sessions')}
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Scrollable content ── */}
|
||||
@@ -903,7 +960,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
exit={{ opacity: 0, scale: 0.9, y: -4 }}
|
||||
className="absolute end-0 top-full mt-1 bg-card border border-border rounded-xl shadow-lg z-50 py-1 min-w-[140px]"
|
||||
>
|
||||
{(['newest', 'oldest', 'alpha'] as SortOrder[]).map(order => (
|
||||
{(['newest', 'oldest', 'alpha', 'manual'] as SortOrder[]).map(order => (
|
||||
<button
|
||||
key={order}
|
||||
onClick={() => { setSortOrder(order); setShowSortMenu(false) }}
|
||||
@@ -950,11 +1007,18 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
|
||||
{/* Notebooks list — draggable */}
|
||||
<div
|
||||
className="space-y-0.5"
|
||||
onDrop={handleDrop}
|
||||
className="space-y-0.5 min-h-[60px]"
|
||||
onDrop={handleDropToRoot}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
>
|
||||
{renderCarnetTree(undefined, 0)}
|
||||
{draggedId && (
|
||||
<div
|
||||
className="h-10 rounded-lg border-2 border-dashed border-brand-accent/20 flex items-center justify-center text-[11px] text-brand-accent/50"
|
||||
>
|
||||
{t('sidebar.dropToRoot') || 'Drop here to move to root'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : activeView === 'reminders' ? (
|
||||
@@ -1028,7 +1092,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push('/brainstorm')}
|
||||
className="p-1 text-muted-foreground hover:text-orange-500 transition-colors rounded"
|
||||
className="p-1 text-muted-foreground hover:text-brand-accent transition-colors rounded"
|
||||
title={t('brainstorm.newBrainstorm')}
|
||||
>
|
||||
<Plus size={12} />
|
||||
@@ -1041,24 +1105,25 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
</div>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<div className="pt-4 border-t border-border/40 mt-auto pb-4">
|
||||
<div className="pt-4 border-t border-border/40 mt-auto pb-4 space-y-4">
|
||||
<UsageMeter />
|
||||
<div className="px-2 space-y-0.5">
|
||||
<Link
|
||||
href="/?shared=1&forceList=1"
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl',
|
||||
searchParams.get('shared') === '1' && pathname === '/'
|
||||
? 'bg-blueprint/5 text-blueprint'
|
||||
: 'text-muted-ink hover:text-ink hover:bg-foreground/5'
|
||||
? 'bg-accent/5 text-accent'
|
||||
: 'text-muted-ink hover:text-ink hover:bg-black/5'
|
||||
)}
|
||||
>
|
||||
<Users size={14} className={searchParams.get('shared') === '1' && pathname === '/' ? 'text-blueprint' : 'text-muted-ink group-hover:text-ink'} />
|
||||
<Users size={14} className={searchParams.get('shared') === '1' && pathname === '/' ? 'text-accent' : 'text-muted-ink group-hover:text-ink'} />
|
||||
<span>{t('sidebar.sharedWithMe') || 'Shared'}</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/archive"
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-[12px] text-muted-ink hover:text-ink hover:bg-foreground/5 transition-all font-medium group rounded-xl"
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-[12px] text-muted-ink hover:text-ink hover:bg-black/5 transition-all font-medium group rounded-xl"
|
||||
>
|
||||
<Archive size={14} className="text-muted-ink group-hover:text-ink" />
|
||||
<span>{t('sidebar.archive')}</span>
|
||||
@@ -1081,19 +1146,6 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
</Link>
|
||||
|
||||
<div className="my-2 h-px bg-border/20 mx-2" />
|
||||
|
||||
<Link
|
||||
href="/settings"
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl',
|
||||
pathname.startsWith('/settings')
|
||||
? 'bg-ink text-paper shadow-sm'
|
||||
: 'text-muted-ink hover:text-ink hover:bg-foreground/5'
|
||||
)}
|
||||
>
|
||||
<Settings size={14} className={pathname.startsWith('/settings') ? 'text-paper' : 'text-muted-ink group-hover:text-ink'} />
|
||||
<span>{t('nav.settings')}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -7,9 +7,10 @@ interface ThemeInitializerProps {
|
||||
theme?: string
|
||||
fontSize?: string
|
||||
fontFamily?: string
|
||||
accentColor?: string
|
||||
}
|
||||
|
||||
export function ThemeInitializer({ theme, fontSize, fontFamily }: ThemeInitializerProps) {
|
||||
export function ThemeInitializer({ theme, fontSize, fontFamily, accentColor }: ThemeInitializerProps) {
|
||||
useEffect(() => {
|
||||
const applyFontSize = (s?: string) => {
|
||||
const size = s || 'medium'
|
||||
@@ -54,7 +55,14 @@ export function ThemeInitializer({ theme, fontSize, fontFamily }: ThemeInitializ
|
||||
if (!localFontFamily && fontFamily) {
|
||||
localStorage.setItem('font-family', fontFamily)
|
||||
}
|
||||
}, [theme, fontSize, fontFamily])
|
||||
|
||||
const localAccent = localStorage.getItem('accent-color')
|
||||
const effectiveAccent = localAccent || accentColor || '#A47148'
|
||||
document.documentElement.style.setProperty('--color-brand-accent', effectiveAccent)
|
||||
if (!localAccent && accentColor) {
|
||||
localStorage.setItem('accent-color', accentColor)
|
||||
}
|
||||
}, [theme, fontSize, fontFamily, accentColor])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useLanguage } from "@/lib/i18n"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
@@ -54,6 +55,7 @@ function DialogContent({
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
@@ -72,7 +74,7 @@ function DialogContent({
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 end-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
<span className="sr-only">{t("common.close")}</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
|
||||
203
memento-note/components/usage-meter.tsx
Normal file
203
memento-note/components/usage-meter.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Sparkles, X, Crown } from 'lucide-react';
|
||||
import { useLanguage } from '@/lib/i18n';
|
||||
|
||||
interface QuotaData {
|
||||
remaining: number;
|
||||
limit: number;
|
||||
used: number;
|
||||
}
|
||||
|
||||
interface UsageMeterProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function formatRemaining(value: number): string {
|
||||
if (!Number.isFinite(value)) return '∞';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function UsageMeter({ className }: UsageMeterProps) {
|
||||
const { t } = useLanguage();
|
||||
const router = useRouter();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['usage', 'current'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/usage/current');
|
||||
if (!res.ok) throw new Error('Failed to fetch quotas');
|
||||
const data = await res.json();
|
||||
return data.quotas as Record<string, QuotaData>;
|
||||
},
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<div className={cn('px-2 py-2', className)}>
|
||||
<div className="h-8 rounded-lg bg-paper/50 animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const features = [
|
||||
{ key: 'semantic_search', labelKey: 'usageMeter.featureSearch' as const },
|
||||
{ key: 'auto_tag', labelKey: 'usageMeter.featureTags' as const },
|
||||
{ key: 'auto_title', labelKey: 'usageMeter.featureTitles' as const },
|
||||
] as const;
|
||||
|
||||
const featureQuotas = features.map((f) => {
|
||||
const quota = data[f.key];
|
||||
return {
|
||||
...f,
|
||||
label: t(f.labelKey),
|
||||
used: quota?.used ?? 0,
|
||||
limit: quota?.limit ?? 0,
|
||||
remaining: quota?.remaining ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
const isUnlimited = featureQuotas.every((f) => !Number.isFinite(f.limit));
|
||||
|
||||
const totalRemaining = featureQuotas.reduce(
|
||||
(sum, f) => sum + (Number.isFinite(f.remaining) ? f.remaining : 0),
|
||||
0,
|
||||
);
|
||||
const totalLimit = featureQuotas.reduce(
|
||||
(sum, f) => sum + (Number.isFinite(f.limit) ? f.limit : 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const used = totalLimit - totalRemaining;
|
||||
const percentage = totalLimit > 0 ? Math.min((used / totalLimit) * 100, 100) : 0;
|
||||
const isExhausted = !isUnlimited && totalRemaining <= 0;
|
||||
const isLow = !isUnlimited && percentage >= 75 && !isExhausted;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="px-2 py-2 w-full">
|
||||
<div className="p-4 bg-slate-50 dark:bg-white/5 border border-border rounded-2xl space-y-3 group hover:shadow-lg transition-all duration-300">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles
|
||||
size={12}
|
||||
className={
|
||||
isExhausted
|
||||
? 'text-rose-400 fill-rose-400/20'
|
||||
: isUnlimited
|
||||
? 'text-emerald-400 fill-emerald-400/20'
|
||||
: 'text-brand-accent fill-brand-accent/20'
|
||||
}
|
||||
/>
|
||||
<span className="text-[11px] font-bold text-ink/70">{t('usageMeter.packName')}</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-medium',
|
||||
isExhausted
|
||||
? 'text-rose-500'
|
||||
: isUnlimited
|
||||
? 'text-emerald-500'
|
||||
: isLow
|
||||
? 'text-amber-500'
|
||||
: 'text-concrete',
|
||||
)}
|
||||
>
|
||||
{isUnlimited
|
||||
? t('usageMeter.unlimited')
|
||||
: t('usageMeter.remaining', { count: totalRemaining })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!isUnlimited && (
|
||||
<div className="h-1.5 w-full bg-slate-200 dark:bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-300',
|
||||
isExhausted
|
||||
? 'bg-rose-400'
|
||||
: isLow
|
||||
? 'bg-amber-400'
|
||||
: 'bg-brand-accent',
|
||||
)}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => router.push('/settings/billing')}
|
||||
className="w-full py-2 bg-brand-accent/10 hover:bg-brand-accent text-brand-accent hover:text-white text-[9px] font-bold uppercase tracking-widest rounded-xl transition-all duration-300 border border-brand-accent/20"
|
||||
>
|
||||
{t('usageMeter.upgradePricing') || 'Passer à Pro'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-paper border border-border rounded-2xl p-6 mx-4 max-w-sm w-full shadow-xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Crown size={18} className="text-amber-500" />
|
||||
<h3 className="text-sm font-semibold">{t('usageMeter.upgradeTitle')}</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
className="text-muted-ink hover:text-ink transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-[12px] text-muted-ink mb-4">
|
||||
{t('usageMeter.upgradeDescription')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="text-[11px] font-medium text-ink">{t('usageMeter.proIncludes')}</div>
|
||||
<ul className="text-[11px] text-muted-ink space-y-1">
|
||||
<li>• {t('usageMeter.proSearch')}</li>
|
||||
<li>• {t('usageMeter.proTags')}</li>
|
||||
<li>• {t('usageMeter.proTitles')}</li>
|
||||
<li>• {t('usageMeter.proReformulate')}</li>
|
||||
<li>• {t('usageMeter.proChat')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
className="flex-1 px-3 py-2 text-[12px] rounded-xl border border-border hover:bg-foreground/5 transition-colors"
|
||||
>
|
||||
{t('usageMeter.later')}
|
||||
</button>
|
||||
<a
|
||||
href="/settings/billing"
|
||||
className="flex-1 px-3 py-2 text-[12px] font-medium rounded-xl bg-brand-accent text-white text-center hover:opacity-90 transition-colors"
|
||||
>
|
||||
{t('usageMeter.upgradePricing')}
|
||||
</a>
|
||||
</div>
|
||||
<a
|
||||
href="/settings/ai#byok"
|
||||
onClick={() => setShowModal(false)}
|
||||
className="w-full px-3 py-2 text-[12px] font-medium rounded-xl border border-brand-accent/40 text-brand-accent dark:text-brand-accent text-center hover:bg-brand-accent/10 transition-colors"
|
||||
>
|
||||
{t('usageMeter.addApiKey')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export interface UpdateNotebookInput {
|
||||
name?: string
|
||||
icon?: string
|
||||
color?: string
|
||||
parentId?: string | null
|
||||
}
|
||||
|
||||
export interface CreateLabelInput {
|
||||
@@ -56,6 +57,7 @@ export interface NotebooksContextValue {
|
||||
// Actions: Notebooks
|
||||
createNotebookOptimistic: (data: CreateNotebookInput) => Promise<void>
|
||||
updateNotebook: (notebookId: string, data: UpdateNotebookInput) => Promise<void>
|
||||
moveNotebookToParent: (notebookId: string, parentId: string | null) => Promise<void>
|
||||
deleteNotebook: (notebookId: string) => Promise<void>
|
||||
trashNotebook: (notebookId: string) => Promise<void>
|
||||
restoreNotebook: (notebookId: string) => Promise<void>
|
||||
@@ -204,6 +206,23 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
||||
triggerRefresh()
|
||||
}, [queryClient, triggerNotebooksRefresh, triggerRefresh])
|
||||
|
||||
const moveNotebookToParent = useCallback(async (notebookId: string, parentId: string | null) => {
|
||||
const response = await fetch(`/api/notebooks/${notebookId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ parentId }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
throw new Error(data.error || 'Failed to move notebook')
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
|
||||
triggerNotebooksRefresh()
|
||||
triggerRefresh()
|
||||
}, [queryClient, triggerNotebooksRefresh, triggerRefresh])
|
||||
|
||||
const deleteNotebook = useCallback(async (notebookId: string) => {
|
||||
const response = await fetch(`/api/notebooks/${notebookId}`, {
|
||||
method: 'DELETE',
|
||||
@@ -426,6 +445,7 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
||||
getLabelColor,
|
||||
createNotebookOptimistic,
|
||||
updateNotebook,
|
||||
moveNotebookToParent,
|
||||
deleteNotebook,
|
||||
trashNotebook,
|
||||
restoreNotebook,
|
||||
@@ -454,6 +474,7 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
||||
getLabelColor,
|
||||
createNotebookOptimistic,
|
||||
updateNotebook,
|
||||
moveNotebookToParent,
|
||||
deleteNotebook,
|
||||
trashNotebook,
|
||||
restoreNotebook,
|
||||
|
||||
@@ -2,8 +2,26 @@
|
||||
|
||||
import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { queryKeys } from '@/lib/query-keys'
|
||||
import {
|
||||
BrainstormQuotaError,
|
||||
type BrainstormQuotaPayload,
|
||||
} from '@/lib/brainstorm-quota-client'
|
||||
import type { BrainstormSession, BrainstormSessionListItem } from '@/types/brainstorm'
|
||||
|
||||
function parseBrainstormQuota(res: Response, data: BrainstormQuotaPayload & Record<string, unknown>) {
|
||||
if (res.status === 402 && data?.error === 'QUOTA_EXCEEDED') {
|
||||
throw new BrainstormQuotaError(data, 'QUOTA_EXCEEDED')
|
||||
}
|
||||
}
|
||||
|
||||
/** i18n key for host-pays quota toasts (Story 3.4). */
|
||||
export function brainstormQuotaMessageKey(err: unknown): string | null {
|
||||
if (err instanceof BrainstormQuotaError) {
|
||||
return err.isGuestActor ? 'brainstorm.quotaGuest' : 'brainstorm.quotaHost'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export interface CreateBrainstormResult {
|
||||
session: BrainstormSession
|
||||
contextSummary?: {
|
||||
@@ -79,6 +97,7 @@ export function useCreateBrainstorm() {
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
const data = await res.json()
|
||||
parseBrainstormQuota(res, data)
|
||||
if (!data.success) throw new Error(data.error || 'Failed to create brainstorm')
|
||||
return { session: data.data, contextSummary: data.contextSummary }
|
||||
},
|
||||
@@ -100,6 +119,7 @@ export function useExpandIdea(sessionId: string) {
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
const data = await res.json()
|
||||
parseBrainstormQuota(res, data)
|
||||
if (!data.success) throw new Error(data.error || 'Failed to expand idea')
|
||||
return data.data
|
||||
},
|
||||
@@ -258,6 +278,7 @@ export function useAddManualIdea(sessionId: string) {
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
const data = await res.json()
|
||||
parseBrainstormQuota(res, data)
|
||||
if (!data.success) throw new Error(data.error || 'Failed to add idea')
|
||||
return data.data
|
||||
},
|
||||
|
||||
@@ -4,8 +4,9 @@ import { CustomOpenAIProvider } from './providers/custom-openai';
|
||||
import { AnthropicProvider } from './providers/anthropic';
|
||||
import { GoogleProvider } from './providers/google';
|
||||
import { AIProvider } from './types';
|
||||
import { resolveAiRoute } from './router';
|
||||
|
||||
type ProviderType =
|
||||
export type ProviderType =
|
||||
| 'ollama'
|
||||
| 'openai'
|
||||
| 'google'
|
||||
@@ -195,7 +196,8 @@ function createGLMProvider(config: Record<string, string>, modelName: string, em
|
||||
return new CustomOpenAIProvider(apiKey, defaults.baseUrl, modelName || defaults.model, embeddingModelName || defaults.embeddingModel);
|
||||
}
|
||||
|
||||
function getProviderInstance(providerType: ProviderType, config: Record<string, string>, modelName: string, embeddingModelName: string, ollamaBaseUrl?: string): AIProvider {
|
||||
/** Exported for tests and Story 3.3+ orchestration; prefer `get*Provider` in production call sites. */
|
||||
export function getProviderInstance(providerType: ProviderType, config: Record<string, string>, modelName: string, embeddingModelName: string, ollamaBaseUrl?: string): AIProvider {
|
||||
switch (providerType) {
|
||||
case 'ollama':
|
||||
return createOllamaProvider(config, modelName, embeddingModelName, ollamaBaseUrl);
|
||||
@@ -250,61 +252,15 @@ function getProviderConfigKeys(providerType: string): { apiKeyConfigKey: string;
|
||||
}
|
||||
|
||||
export function getTagsProvider(config?: Record<string, string>): AIProvider {
|
||||
const providerType = (
|
||||
config?.AI_PROVIDER_TAGS ||
|
||||
config?.AI_PROVIDER_EMBEDDING ||
|
||||
config?.AI_PROVIDER ||
|
||||
process.env.AI_PROVIDER_TAGS ||
|
||||
process.env.AI_PROVIDER_EMBEDDING ||
|
||||
process.env.AI_PROVIDER
|
||||
);
|
||||
|
||||
if (!providerType) {
|
||||
console.error('[getTagsProvider] FATAL: No provider configured. Config received:', config);
|
||||
throw new Error(
|
||||
'AI_PROVIDER_TAGS is not configured. Please set it in the admin settings or environment variables. ' +
|
||||
'Options: ollama, openai, anthropic, anthropic_custom, deepseek, openrouter, mistral, zai, lmstudio, custom'
|
||||
);
|
||||
}
|
||||
|
||||
const provider = providerType.toLowerCase() as ProviderType;
|
||||
const modelName = config?.AI_MODEL_TAGS || process.env.AI_MODEL_TAGS || 'granite4:latest';
|
||||
const embeddingModelName = config?.AI_MODEL_EMBEDDING || process.env.AI_MODEL_EMBEDDING || 'embeddinggemma:latest';
|
||||
const ollamaBaseUrl = config?.OLLAMA_BASE_URL_TAGS || config?.OLLAMA_BASE_URL;
|
||||
|
||||
return getProviderInstance(provider, config || {}, modelName, embeddingModelName, ollamaBaseUrl);
|
||||
const cfg = config || {};
|
||||
const route = resolveAiRoute('tags', cfg);
|
||||
return getProviderInstance(route.providerType as ProviderType, cfg, route.modelName, route.embeddingModelName, route.ollamaBaseUrl);
|
||||
}
|
||||
|
||||
export function getEmbeddingsProvider(config?: Record<string, string>): AIProvider {
|
||||
const providerType = (
|
||||
config?.AI_PROVIDER_EMBEDDING ||
|
||||
config?.AI_PROVIDER_TAGS ||
|
||||
config?.AI_PROVIDER ||
|
||||
process.env.AI_PROVIDER_EMBEDDING ||
|
||||
process.env.AI_PROVIDER_TAGS ||
|
||||
process.env.AI_PROVIDER
|
||||
);
|
||||
|
||||
if (!providerType) {
|
||||
console.error('[getEmbeddingsProvider] FATAL: No provider configured. Config received:', config);
|
||||
throw new Error(
|
||||
'AI_PROVIDER_EMBEDDING is not configured. Please set it in the admin settings or environment variables. ' +
|
||||
'Options: ollama, openai, deepseek, openrouter, mistral, zai, lmstudio, custom'
|
||||
);
|
||||
}
|
||||
|
||||
const provider = providerType.toLowerCase() as ProviderType;
|
||||
|
||||
if (provider === 'anthropic' || provider === 'anthropic_custom') {
|
||||
throw new Error(
|
||||
'AI_PROVIDER_EMBEDDING cannot use "anthropic" or "anthropic_custom": these gateways use the Anthropic Messages API only (no embeddings in Memento). Use ollama, openai, or "custom" with MiniMax OpenAI URL https://api.minimax.io/v1 for embeddings.'
|
||||
);
|
||||
}
|
||||
const modelName = config?.AI_MODEL_TAGS || process.env.AI_MODEL_TAGS || 'granite4:latest';
|
||||
const embeddingModelName = config?.AI_MODEL_EMBEDDING || process.env.AI_MODEL_EMBEDDING || 'embeddinggemma:latest';
|
||||
const ollamaBaseUrl = config?.OLLAMA_BASE_URL_EMBEDDING || config?.OLLAMA_BASE_URL;
|
||||
|
||||
return getProviderInstance(provider, config || {}, modelName, embeddingModelName, ollamaBaseUrl);
|
||||
const cfg = config || {};
|
||||
const route = resolveAiRoute('embedding', cfg);
|
||||
return getProviderInstance(route.providerType as ProviderType, cfg, route.modelName, route.embeddingModelName, route.ollamaBaseUrl);
|
||||
}
|
||||
|
||||
export function getAIProvider(config?: Record<string, string>): AIProvider {
|
||||
@@ -312,35 +268,9 @@ export function getAIProvider(config?: Record<string, string>): AIProvider {
|
||||
}
|
||||
|
||||
export function getChatProvider(config?: Record<string, string>): AIProvider {
|
||||
const providerType = (
|
||||
config?.AI_PROVIDER_CHAT ||
|
||||
config?.AI_PROVIDER_TAGS ||
|
||||
config?.AI_PROVIDER_EMBEDDING ||
|
||||
config?.AI_PROVIDER ||
|
||||
process.env.AI_PROVIDER_CHAT ||
|
||||
process.env.AI_PROVIDER_TAGS ||
|
||||
process.env.AI_PROVIDER_EMBEDDING ||
|
||||
process.env.AI_PROVIDER
|
||||
);
|
||||
|
||||
if (!providerType) {
|
||||
console.error('[getChatProvider] FATAL: No provider configured. Config received:', config);
|
||||
throw new Error(
|
||||
'AI_PROVIDER_CHAT is not configured. Please set it in the admin settings or environment variables. ' +
|
||||
'Options: ollama, openai, anthropic, anthropic_custom, deepseek, openrouter, mistral, zai, lmstudio, custom'
|
||||
);
|
||||
}
|
||||
|
||||
const provider = providerType.toLowerCase() as ProviderType;
|
||||
const modelName = (
|
||||
config?.AI_MODEL_CHAT ||
|
||||
process.env.AI_MODEL_CHAT ||
|
||||
'granite4:latest'
|
||||
);
|
||||
const embeddingModelName = config?.AI_MODEL_EMBEDDING || process.env.AI_MODEL_EMBEDDING || 'embeddinggemma:latest';
|
||||
const ollamaBaseUrl = config?.OLLAMA_BASE_URL_CHAT || config?.OLLAMA_BASE_URL_TAGS || config?.OLLAMA_BASE_URL_EMBEDDING || config?.OLLAMA_BASE_URL;
|
||||
|
||||
return getProviderInstance(provider, config || {}, modelName, embeddingModelName, ollamaBaseUrl);
|
||||
const cfg = config || {};
|
||||
const route = resolveAiRoute('chat', cfg);
|
||||
return getProviderInstance(route.providerType as ProviderType, cfg, route.modelName, route.embeddingModelName, route.ollamaBaseUrl);
|
||||
}
|
||||
|
||||
// Export for use by admin settings form and deploy scripts
|
||||
|
||||
255
memento-note/lib/ai/fallback.ts
Normal file
255
memento-note/lib/ai/fallback.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Provider failover on retriable upstream errors (Story 3.3 — FR18 / NFR-R1).
|
||||
*
|
||||
* Story 3.5 BYOK: when user BYOK is active, call with skipSystemFallback: true.
|
||||
*/
|
||||
|
||||
import { APICallError } from 'ai'
|
||||
import { QuotaExceededError } from '@/lib/entitlements'
|
||||
import { getProviderInstance, type ProviderType } from './factory'
|
||||
import {
|
||||
resolveAiRoute,
|
||||
VALID_PROVIDERS,
|
||||
type AiFeatureLane,
|
||||
type AiGatewayProvider,
|
||||
type ResolvedAiRoute,
|
||||
} from './router'
|
||||
import type { AIProvider } from './types'
|
||||
|
||||
export const FALLBACK_BUDGET_MS = 1500
|
||||
|
||||
const VALID_PROVIDER_LIST = [...VALID_PROVIDERS].join(', ')
|
||||
|
||||
const LANE_FALLBACK_KEYS: Record<
|
||||
AiFeatureLane,
|
||||
{ provider: string; model: string; ollamaUrl?: string }
|
||||
> = {
|
||||
chat: {
|
||||
provider: 'AI_PROVIDER_CHAT_FALLBACK',
|
||||
model: 'AI_MODEL_CHAT_FALLBACK',
|
||||
ollamaUrl: 'OLLAMA_BASE_URL_CHAT',
|
||||
},
|
||||
tags: {
|
||||
provider: 'AI_PROVIDER_TAGS_FALLBACK',
|
||||
model: 'AI_MODEL_TAGS_FALLBACK',
|
||||
ollamaUrl: 'OLLAMA_BASE_URL_TAGS',
|
||||
},
|
||||
embedding: {
|
||||
provider: 'AI_PROVIDER_EMBEDDING_FALLBACK',
|
||||
model: 'AI_MODEL_EMBEDDING_FALLBACK',
|
||||
ollamaUrl: 'OLLAMA_BASE_URL_EMBEDDING',
|
||||
},
|
||||
}
|
||||
|
||||
function pick(config: Record<string, string>, key: string): string | undefined {
|
||||
const v = config[key]
|
||||
if (v != null && v !== '') return v
|
||||
const e = process.env[key]
|
||||
return e != null && e !== '' ? e : undefined
|
||||
}
|
||||
|
||||
function cfgOnly(config: Record<string, string>, key: string): string | undefined {
|
||||
const v = config[key]
|
||||
return v != null && v !== '' ? v : undefined
|
||||
}
|
||||
|
||||
function extractProviderErrorStatusDepth(err: unknown, depth: number): number | undefined {
|
||||
if (depth > 5) return undefined
|
||||
if (err instanceof QuotaExceededError) return 402
|
||||
if (APICallError.isInstance(err)) {
|
||||
return err.statusCode ?? undefined
|
||||
}
|
||||
if (err && typeof err === 'object') {
|
||||
const o = err as Record<string, unknown>
|
||||
if (typeof o.statusCode === 'number') return o.statusCode
|
||||
if (typeof o.status === 'number') return o.status
|
||||
if (o.cause) return extractProviderErrorStatusDepth(o.cause, depth + 1)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function extractProviderErrorStatus(err: unknown): number | undefined {
|
||||
return extractProviderErrorStatusDepth(err, 0)
|
||||
}
|
||||
|
||||
/** True for HTTP 429 and 5xx provider failures; false for quota and other 4xx. */
|
||||
export function isRetriableProviderError(err: unknown): boolean {
|
||||
if (err instanceof QuotaExceededError) return false
|
||||
if (err && typeof err === 'object') {
|
||||
const code = (err as { code?: string }).code
|
||||
if (code === 'QUOTA_EXCEEDED') return false
|
||||
}
|
||||
const status = extractProviderErrorStatus(err)
|
||||
if (status === undefined) return false
|
||||
if (status === 429) return true
|
||||
if (status >= 500 && status < 600) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve secondary route from *_FALLBACK keys only (primary keys untouched).
|
||||
*/
|
||||
export function resolveAiFallbackRoute(
|
||||
lane: AiFeatureLane,
|
||||
config: Record<string, string>
|
||||
): ResolvedAiRoute | null {
|
||||
const keys = LANE_FALLBACK_KEYS[lane]
|
||||
const providerRaw = pick(config, keys.provider)?.trim()
|
||||
if (!providerRaw) return null
|
||||
|
||||
const providerType = providerRaw.toLowerCase()
|
||||
|
||||
if (!VALID_PROVIDERS.has(providerType)) {
|
||||
throw new Error(
|
||||
`Unknown fallback provider '${providerRaw}'. Valid options: ${VALID_PROVIDER_LIST}`
|
||||
)
|
||||
}
|
||||
|
||||
if (lane === 'embedding' && (providerType === 'anthropic' || providerType === 'anthropic_custom')) {
|
||||
throw new Error(
|
||||
'AI_PROVIDER_EMBEDDING_FALLBACK cannot use "anthropic" or "anthropic_custom": no embeddings on this gateway.'
|
||||
)
|
||||
}
|
||||
|
||||
const primary = resolveAiRoute(lane, config)
|
||||
|
||||
if (providerType === primary.providerType) return null
|
||||
|
||||
const modelName =
|
||||
pick(config, keys.model) ??
|
||||
(lane === 'chat'
|
||||
? pick(config, 'AI_MODEL_CHAT')
|
||||
: lane === 'tags'
|
||||
? pick(config, 'AI_MODEL_TAGS')
|
||||
: pick(config, 'AI_MODEL_EMBEDDING')) ??
|
||||
primary.modelName
|
||||
|
||||
const embeddingModelName =
|
||||
pick(config, 'AI_MODEL_EMBEDDING_FALLBACK') ??
|
||||
pick(config, 'AI_MODEL_EMBEDDING') ??
|
||||
primary.embeddingModelName
|
||||
|
||||
const ollamaBaseUrl =
|
||||
cfgOnly(config, keys.ollamaUrl!) ||
|
||||
cfgOnly(config, 'OLLAMA_BASE_URL')
|
||||
|
||||
return {
|
||||
lane,
|
||||
providerType: providerType as AiGatewayProvider,
|
||||
modelName,
|
||||
embeddingModelName,
|
||||
ollamaBaseUrl,
|
||||
meta: {},
|
||||
}
|
||||
}
|
||||
|
||||
function getProviderForRoute(config: Record<string, string>, route: ResolvedAiRoute): AIProvider {
|
||||
return getProviderInstance(
|
||||
route.providerType as ProviderType,
|
||||
config,
|
||||
route.modelName,
|
||||
route.embeddingModelName,
|
||||
route.ollamaBaseUrl
|
||||
)
|
||||
}
|
||||
|
||||
function getPrimaryProvider(lane: AiFeatureLane, config: Record<string, string>): {
|
||||
provider: AIProvider
|
||||
route: ResolvedAiRoute
|
||||
} {
|
||||
const route = resolveAiRoute(lane, config)
|
||||
return { route, provider: getProviderForRoute(config, route) }
|
||||
}
|
||||
|
||||
function getSecondaryProvider(lane: AiFeatureLane, config: Record<string, string>): {
|
||||
provider: AIProvider
|
||||
route: ResolvedAiRoute
|
||||
} | null {
|
||||
try {
|
||||
const fbRoute = resolveAiFallbackRoute(lane, config)
|
||||
if (!fbRoute) return null
|
||||
return { route: fbRoute, provider: getProviderForRoute(config, fbRoute) }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldLogAiFallback(): boolean {
|
||||
return process.env.NODE_ENV !== 'production' || process.env.MEMENTO_AI_ROUTE_DEBUG === '1'
|
||||
}
|
||||
|
||||
export function formatAiFallbackDebug(meta: {
|
||||
lane: AiFeatureLane
|
||||
primaryProvider: string
|
||||
secondaryProvider: string
|
||||
primaryStatus?: number
|
||||
fallbackMs: number
|
||||
}): string {
|
||||
return JSON.stringify(meta)
|
||||
}
|
||||
|
||||
function logFallbackSuccess(meta: {
|
||||
lane: AiFeatureLane
|
||||
primaryProvider: string
|
||||
secondaryProvider: string
|
||||
primaryStatus?: number
|
||||
fallbackMs: number
|
||||
}): void {
|
||||
if (!shouldLogAiFallback()) return
|
||||
console.debug('[ai-fallback]', formatAiFallbackDebug(meta))
|
||||
if (meta.fallbackMs > FALLBACK_BUDGET_MS) {
|
||||
console.warn(
|
||||
`[ai-fallback] NFR-R1 budget exceeded: ${meta.fallbackMs.toFixed(1)}ms > ${FALLBACK_BUDGET_MS}ms`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export interface WithAiProviderFallbackOptions {
|
||||
/** Story 3.5: skip system secondary when user BYOK is active */
|
||||
skipSystemFallback?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an AI operation on the primary provider; on retriable failure, try secondary once.
|
||||
*/
|
||||
export async function withAiProviderFallback<T>(
|
||||
lane: AiFeatureLane,
|
||||
config: Record<string, string>,
|
||||
run: (provider: AIProvider) => Promise<T>,
|
||||
options?: WithAiProviderFallbackOptions
|
||||
): Promise<T> {
|
||||
if (options?.skipSystemFallback) {
|
||||
const primary = getPrimaryProvider(lane, config)
|
||||
return run(primary.provider)
|
||||
}
|
||||
|
||||
const primary = getPrimaryProvider(lane, config)
|
||||
try {
|
||||
return await run(primary.provider)
|
||||
} catch (err) {
|
||||
if (!isRetriableProviderError(err)) throw err
|
||||
|
||||
const fallbackStart = performance.now()
|
||||
const secondary = getSecondaryProvider(lane, config)
|
||||
if (!secondary) throw err
|
||||
|
||||
const primaryStatus = extractProviderErrorStatus(err)
|
||||
try {
|
||||
const result = await run(secondary.provider)
|
||||
logFallbackSuccess({
|
||||
lane,
|
||||
primaryProvider: primary.route.providerType,
|
||||
secondaryProvider: secondary.route.providerType,
|
||||
primaryStatus,
|
||||
fallbackMs: performance.now() - fallbackStart,
|
||||
})
|
||||
return result
|
||||
} catch (secondaryErr) {
|
||||
console.error(
|
||||
`[ai-fallback] secondary also failed for lane '${lane}':`,
|
||||
secondaryErr instanceof Error ? secondaryErr.message : secondaryErr
|
||||
)
|
||||
throw secondaryErr
|
||||
}
|
||||
}
|
||||
}
|
||||
108
memento-note/lib/ai/provider-for-user.ts
Normal file
108
memento-note/lib/ai/provider-for-user.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
getProviderInstance,
|
||||
type ProviderType,
|
||||
} from '@/lib/ai/factory';
|
||||
import { applyByokToConfig } from '@/lib/byok';
|
||||
import {
|
||||
resolveAiRoute,
|
||||
type AiFeatureLane,
|
||||
type ResolvedAiRoute,
|
||||
} from '@/lib/ai/router';
|
||||
import { withAiProviderFallback } from '@/lib/ai/fallback';
|
||||
import type { AIProvider } from '@/lib/ai/types';
|
||||
|
||||
export interface ProviderForUserResult {
|
||||
provider: AIProvider;
|
||||
usedByok: boolean;
|
||||
route: ResolvedAiRoute;
|
||||
}
|
||||
|
||||
async function resolveProviderForLane(
|
||||
lane: AiFeatureLane,
|
||||
config: Record<string, string>,
|
||||
billingUserId?: string,
|
||||
): Promise<ProviderForUserResult> {
|
||||
const cfg = { ...config };
|
||||
const route = resolveAiRoute(lane, cfg);
|
||||
let usedByok = false;
|
||||
|
||||
if (billingUserId) {
|
||||
const overlay = await applyByokToConfig(
|
||||
billingUserId,
|
||||
route.providerType,
|
||||
cfg,
|
||||
);
|
||||
Object.assign(cfg, overlay.config);
|
||||
usedByok = overlay.usedByok;
|
||||
}
|
||||
|
||||
const provider = getProviderInstance(
|
||||
route.providerType as ProviderType,
|
||||
cfg,
|
||||
route.modelName,
|
||||
route.embeddingModelName,
|
||||
route.ollamaBaseUrl,
|
||||
);
|
||||
|
||||
return { provider, usedByok, route };
|
||||
}
|
||||
|
||||
export async function getChatProviderForBillingUser(
|
||||
config: Record<string, string>,
|
||||
billingUserId?: string,
|
||||
): Promise<ProviderForUserResult> {
|
||||
return resolveProviderForLane('chat', config, billingUserId);
|
||||
}
|
||||
|
||||
export async function getTagsProviderForBillingUser(
|
||||
config: Record<string, string>,
|
||||
billingUserId?: string,
|
||||
): Promise<ProviderForUserResult> {
|
||||
return resolveProviderForLane('tags', config, billingUserId);
|
||||
}
|
||||
|
||||
export async function getEmbeddingsProviderForBillingUser(
|
||||
config: Record<string, string>,
|
||||
billingUserId?: string,
|
||||
): Promise<ProviderForUserResult> {
|
||||
return resolveProviderForLane('embedding', config, billingUserId);
|
||||
}
|
||||
|
||||
/** Run a lane with BYOK overlay; skips system fallback when user key is active. */
|
||||
export async function willUseByokForLane(
|
||||
lane: AiFeatureLane,
|
||||
config: Record<string, string>,
|
||||
billingUserId?: string,
|
||||
): Promise<{ providerType: string; usedByok: boolean }> {
|
||||
if (!billingUserId) {
|
||||
const route = resolveAiRoute(lane, config)
|
||||
return { providerType: route.providerType, usedByok: false }
|
||||
}
|
||||
const route = resolveAiRoute(lane, config)
|
||||
const overlay = await applyByokToConfig(billingUserId, route.providerType, config)
|
||||
return { providerType: route.providerType, usedByok: overlay.usedByok }
|
||||
}
|
||||
|
||||
export async function runLaneWithBillingUser<T>(
|
||||
lane: AiFeatureLane,
|
||||
config: Record<string, string>,
|
||||
billingUserId: string | undefined,
|
||||
run: (provider: AIProvider) => Promise<T>,
|
||||
): Promise<{ result: T; usedByok: boolean }> {
|
||||
if (billingUserId) {
|
||||
const resolved =
|
||||
lane === 'chat'
|
||||
? await getChatProviderForBillingUser(config, billingUserId)
|
||||
: lane === 'tags'
|
||||
? await getTagsProviderForBillingUser(config, billingUserId)
|
||||
: await getEmbeddingsProviderForBillingUser(config, billingUserId);
|
||||
|
||||
if (resolved.usedByok) {
|
||||
const result = await run(resolved.provider);
|
||||
return { result, usedByok: true };
|
||||
}
|
||||
}
|
||||
|
||||
const result = await withAiProviderFallback(lane, config, run);
|
||||
return { result, usedByok: false };
|
||||
}
|
||||
172
memento-note/lib/ai/router.ts
Normal file
172
memento-note/lib/ai/router.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Central synchronous AI gateway routing (Story 3.2 — FR17 / NFR-P3).
|
||||
*
|
||||
* Future (Story 3.5 BYOK): plug user-scoped API keys into resolveAiRoute output / factory instantiation.
|
||||
*
|
||||
* Non-goals here (by design):
|
||||
* - Multi-provider HTTP fallback on 429/500 → Story 3.3
|
||||
* - BYOK / UserAPIKey decryption → Story 3.5 (extension seam: same resolve output + key source later)
|
||||
*/
|
||||
|
||||
export type AiFeatureLane = 'chat' | 'tags' | 'embedding'
|
||||
|
||||
export type AiGatewayProvider =
|
||||
| 'ollama'
|
||||
| 'openai'
|
||||
| 'google'
|
||||
| 'minimax'
|
||||
| 'glm'
|
||||
| 'custom'
|
||||
| 'deepseek'
|
||||
| 'openrouter'
|
||||
| 'mistral'
|
||||
| 'zai'
|
||||
| 'lmstudio'
|
||||
| 'anthropic'
|
||||
| 'anthropic_custom'
|
||||
|
||||
export interface ResolvedAiRoute {
|
||||
lane: AiFeatureLane
|
||||
providerType: AiGatewayProvider
|
||||
modelName: string
|
||||
embeddingModelName: string
|
||||
ollamaBaseUrl?: string
|
||||
meta: {
|
||||
resolveMs?: number
|
||||
}
|
||||
}
|
||||
|
||||
export const VALID_PROVIDERS = new Set<string>([
|
||||
'ollama', 'openai', 'google', 'minimax', 'glm', 'custom',
|
||||
'deepseek', 'openrouter', 'mistral', 'zai', 'lmstudio',
|
||||
'anthropic', 'anthropic_custom',
|
||||
])
|
||||
|
||||
const PROVIDER_MODEL_DEFAULTS: Record<string, { model: string; embeddingModel: string }> = {
|
||||
ollama: { model: 'granite4:latest', embeddingModel: 'embeddinggemma:latest' },
|
||||
openai: { model: 'gpt-4o-mini', embeddingModel: 'text-embedding-3-small' },
|
||||
anthropic: { model: 'claude-sonnet-4-6-20250514', embeddingModel: '' },
|
||||
anthropic_custom: { model: 'claude-sonnet-4-6-20250514', embeddingModel: '' },
|
||||
deepseek: { model: 'deepseek-chat', embeddingModel: '' },
|
||||
openrouter: { model: 'openai/gpt-4o-mini', embeddingModel: 'openai/text-embedding-3-small' },
|
||||
google: { model: 'gemini-1.5-flash', embeddingModel: 'text-embedding-004' },
|
||||
mistral: { model: 'mistral-small-latest', embeddingModel: 'mistral-embed' },
|
||||
zai: { model: 'gpt-4o-mini', embeddingModel: 'text-embedding-3-small' },
|
||||
minimax: { model: 'abab6.5-chat', embeddingModel: '' },
|
||||
glm: { model: 'glm-4', embeddingModel: 'embedding-2' },
|
||||
lmstudio: { model: '', embeddingModel: '' },
|
||||
custom: { model: '', embeddingModel: '' },
|
||||
}
|
||||
|
||||
function pick(config: Record<string, string>, key: string): string | undefined {
|
||||
const v = config[key]
|
||||
if (v != null && v !== '') return v
|
||||
const e = process.env[key]
|
||||
return e != null && e !== '' ? e : undefined
|
||||
}
|
||||
|
||||
function cfgOnly(config: Record<string, string>, key: string): string | undefined {
|
||||
const v = config[key]
|
||||
return v != null && v !== '' ? v : undefined
|
||||
}
|
||||
|
||||
const VALID_PROVIDER_LIST = [...VALID_PROVIDERS].join(', ')
|
||||
|
||||
export function resolveAiRoute(lane: AiFeatureLane, config: Record<string, string>): ResolvedAiRoute {
|
||||
let providerRaw: string | undefined
|
||||
let modelKey: string
|
||||
let ollamaBaseUrl: string | undefined
|
||||
|
||||
if (lane === 'tags') {
|
||||
providerRaw =
|
||||
pick(config, 'AI_PROVIDER_TAGS') ||
|
||||
pick(config, 'AI_PROVIDER_EMBEDDING') ||
|
||||
pick(config, 'AI_PROVIDER')
|
||||
modelKey = 'AI_MODEL_TAGS'
|
||||
ollamaBaseUrl = cfgOnly(config, 'OLLAMA_BASE_URL_TAGS') || cfgOnly(config, 'OLLAMA_BASE_URL')
|
||||
if (!providerRaw) {
|
||||
throw new Error(
|
||||
'AI_PROVIDER_TAGS is not configured. Please set it in the admin settings or environment variables. ' +
|
||||
'Options: ' + VALID_PROVIDER_LIST
|
||||
)
|
||||
}
|
||||
} else if (lane === 'embedding') {
|
||||
providerRaw =
|
||||
pick(config, 'AI_PROVIDER_EMBEDDING') ||
|
||||
pick(config, 'AI_PROVIDER_TAGS') ||
|
||||
pick(config, 'AI_PROVIDER')
|
||||
modelKey = 'AI_MODEL_EMBEDDING'
|
||||
ollamaBaseUrl = cfgOnly(config, 'OLLAMA_BASE_URL_EMBEDDING') || cfgOnly(config, 'OLLAMA_BASE_URL')
|
||||
if (!providerRaw) {
|
||||
throw new Error(
|
||||
'AI_PROVIDER_EMBEDDING is not configured. Please set it in the admin settings or environment variables. ' +
|
||||
'Options: ' + VALID_PROVIDER_LIST
|
||||
)
|
||||
}
|
||||
} else {
|
||||
providerRaw =
|
||||
pick(config, 'AI_PROVIDER_CHAT') ||
|
||||
pick(config, 'AI_PROVIDER_TAGS') ||
|
||||
pick(config, 'AI_PROVIDER_EMBEDDING') ||
|
||||
pick(config, 'AI_PROVIDER')
|
||||
modelKey = 'AI_MODEL_CHAT'
|
||||
ollamaBaseUrl =
|
||||
cfgOnly(config, 'OLLAMA_BASE_URL_CHAT') ||
|
||||
cfgOnly(config, 'OLLAMA_BASE_URL_TAGS') ||
|
||||
cfgOnly(config, 'OLLAMA_BASE_URL_EMBEDDING') ||
|
||||
cfgOnly(config, 'OLLAMA_BASE_URL')
|
||||
if (!providerRaw) {
|
||||
throw new Error(
|
||||
'AI_PROVIDER_CHAT is not configured. Please set it in the admin settings or environment variables. ' +
|
||||
'Options: ' + VALID_PROVIDER_LIST
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const providerType = providerRaw.toLowerCase()
|
||||
|
||||
if (!VALID_PROVIDERS.has(providerType)) {
|
||||
throw new Error(
|
||||
`Unknown AI provider '${providerRaw}'. Valid options: ${VALID_PROVIDER_LIST}`
|
||||
)
|
||||
}
|
||||
|
||||
if (lane === 'embedding' && (providerType === 'anthropic' || providerType === 'anthropic_custom')) {
|
||||
throw new Error(
|
||||
'AI_PROVIDER_EMBEDDING cannot use "anthropic" or "anthropic_custom": these gateways use the Anthropic Messages API only (no embeddings in Memento). Use ollama, openai, or "custom" with MiniMax OpenAI URL https://api.minimax.io/v1 for embeddings.'
|
||||
)
|
||||
}
|
||||
|
||||
const defaults = PROVIDER_MODEL_DEFAULTS[providerType] || { model: '', embeddingModel: '' }
|
||||
const modelName = pick(config, modelKey) || defaults.model
|
||||
const embeddingModelName = pick(config, 'AI_MODEL_EMBEDDING') || defaults.embeddingModel
|
||||
|
||||
return {
|
||||
lane,
|
||||
providerType: providerType as AiGatewayProvider,
|
||||
modelName,
|
||||
embeddingModelName,
|
||||
ollamaBaseUrl,
|
||||
meta: {},
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAiRouteWithTiming(lane: AiFeatureLane, config: Record<string, string>): ResolvedAiRoute {
|
||||
const t0 = performance.now()
|
||||
const route = resolveAiRoute(lane, config)
|
||||
const resolveMs = performance.now() - t0
|
||||
return {
|
||||
...route,
|
||||
meta: { ...route.meta, resolveMs },
|
||||
}
|
||||
}
|
||||
|
||||
export function formatAiRouteDebug(route: ResolvedAiRoute): string {
|
||||
return JSON.stringify({
|
||||
lane: route.lane,
|
||||
providerType: route.providerType,
|
||||
modelId: route.modelName,
|
||||
embeddingModelId: route.embeddingModelName,
|
||||
resolveMs: route.meta.resolveMs,
|
||||
})
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* Stores embeddings as native pgvector in PostgreSQL.
|
||||
*/
|
||||
|
||||
import { getAIProvider } from '../factory'
|
||||
import { withAiProviderFallback } from '../fallback'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
export interface EmbeddingResult {
|
||||
@@ -23,8 +23,9 @@ export class EmbeddingService {
|
||||
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getAIProvider(config)
|
||||
const embedding = await provider.getEmbeddings(text)
|
||||
const embedding = await withAiProviderFallback('embedding', config, (provider) =>
|
||||
provider.getEmbeddings(text)
|
||||
)
|
||||
|
||||
return {
|
||||
embedding,
|
||||
@@ -45,9 +46,8 @@ export class EmbeddingService {
|
||||
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getAIProvider(config)
|
||||
const embeddings = await Promise.all(
|
||||
validTexts.map(text => provider.getEmbeddings(text))
|
||||
const embeddings = await withAiProviderFallback('embedding', config, (provider) =>
|
||||
Promise.all(validTexts.map((text) => provider.getEmbeddings(text)))
|
||||
)
|
||||
|
||||
return embeddings.map(embedding => ({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { tool } from 'ai'
|
||||
import { z } from 'zod'
|
||||
import { toolRegistry } from './registry'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getTagsProvider } from '@/lib/ai/factory'
|
||||
import { withAiProviderFallback } from '@/lib/ai/fallback'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
toolRegistry.register({
|
||||
@@ -44,7 +44,6 @@ toolRegistry.register({
|
||||
const lang = locale === 'fr' ? 'français' : locale === 'es' ? 'espagnol' : locale === 'de' ? 'allemand' : locale === 'it' ? 'italien' : locale === 'pt' ? 'portugais' : locale === 'nl' ? 'néerlandais' : locale === 'ru' ? 'russe' : locale === 'zh' ? 'chinois' : locale === 'ja' ? 'japonais' : locale === 'ar' ? 'arabe' : locale === 'fa' ? 'persan' : locale === 'hi' ? 'hindi' : 'English'
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const provider = getTagsProvider(config)
|
||||
|
||||
const prompt = `You are a task extraction specialist. Analyze the following notes and extract ALL action items, tasks, and TODOs.
|
||||
|
||||
@@ -72,7 +71,9 @@ Format each task as:
|
||||
- **Deadline**: ...
|
||||
- **Status**: ...`
|
||||
|
||||
const result = await provider.generateText(prompt)
|
||||
const result = await withAiProviderFallback('tags', config, (provider) =>
|
||||
provider.generateText(prompt)
|
||||
)
|
||||
|
||||
const summaryTitle = locale === 'fr'
|
||||
? `Action Items — ${new Date().toLocaleDateString('fr-FR')}`
|
||||
|
||||
35
memento-note/lib/billing/stripe-prices.ts
Normal file
35
memento-note/lib/billing/stripe-prices.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { SubscriptionTier } from '@/lib/entitlements';
|
||||
|
||||
export type BillingTier = 'PRO' | 'BUSINESS';
|
||||
export type BillingInterval = 'month' | 'year';
|
||||
|
||||
export function resolvePriceId(tier: BillingTier, interval: BillingInterval): string {
|
||||
const map: Record<BillingTier, Record<BillingInterval, string>> = {
|
||||
PRO: {
|
||||
month: process.env.STRIPE_PRICE_PRO_MONTHLY!,
|
||||
year: process.env.STRIPE_PRICE_PRO_ANNUAL!,
|
||||
},
|
||||
BUSINESS: {
|
||||
month: process.env.STRIPE_PRICE_BUSINESS_MONTHLY!,
|
||||
year: process.env.STRIPE_PRICE_BUSINESS_ANNUAL!,
|
||||
},
|
||||
};
|
||||
const priceId = map[tier][interval];
|
||||
if (!priceId) {
|
||||
throw new Error(`No Stripe price ID configured for ${tier}/${interval}`);
|
||||
}
|
||||
return priceId;
|
||||
}
|
||||
|
||||
export function priceIdToTier(priceId: string): SubscriptionTier | null {
|
||||
const entries: Array<[string | undefined, SubscriptionTier]> = [
|
||||
[process.env.STRIPE_PRICE_PRO_MONTHLY, 'PRO'],
|
||||
[process.env.STRIPE_PRICE_PRO_ANNUAL, 'PRO'],
|
||||
[process.env.STRIPE_PRICE_BUSINESS_MONTHLY, 'BUSINESS'],
|
||||
[process.env.STRIPE_PRICE_BUSINESS_ANNUAL, 'BUSINESS'],
|
||||
];
|
||||
for (const [envPriceId, tier] of entries) {
|
||||
if (envPriceId && envPriceId === priceId) return tier;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
113
memento-note/lib/billing/sync-subscription-from-stripe.ts
Normal file
113
memento-note/lib/billing/sync-subscription-from-stripe.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type Stripe from 'stripe';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { priceIdToTier } from '@/lib/billing/stripe-prices';
|
||||
import { SubscriptionStatus, SubscriptionTier } from '@prisma/client';
|
||||
|
||||
function mapStripeStatus(stripeStatus: Stripe.Subscription.Status): SubscriptionStatus {
|
||||
switch (stripeStatus) {
|
||||
case 'active':
|
||||
return SubscriptionStatus.ACTIVE;
|
||||
case 'trialing':
|
||||
return SubscriptionStatus.TRIALING;
|
||||
case 'past_due':
|
||||
return SubscriptionStatus.PAST_DUE;
|
||||
case 'canceled':
|
||||
case 'unpaid':
|
||||
return SubscriptionStatus.CANCELED;
|
||||
case 'incomplete':
|
||||
case 'incomplete_expired':
|
||||
return SubscriptionStatus.INACTIVE;
|
||||
default:
|
||||
return SubscriptionStatus.INACTIVE;
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncSubscriptionFromStripe(
|
||||
subscription: Stripe.Subscription,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
const priceId = subscription.items.data[0]?.price?.id ?? null;
|
||||
const resolvedTier = priceId ? priceIdToTier(priceId) : null;
|
||||
|
||||
const tierFromMetadata =
|
||||
(subscription.metadata?.tier as string | undefined) ??
|
||||
(subscription.metadata?.tier as string | undefined);
|
||||
|
||||
let tier: SubscriptionTier;
|
||||
if (resolvedTier) {
|
||||
tier = resolvedTier as SubscriptionTier;
|
||||
} else if (tierFromMetadata === 'PRO' || tierFromMetadata === 'BUSINESS' || tierFromMetadata === 'ENTERPRISE') {
|
||||
tier = tierFromMetadata as SubscriptionTier;
|
||||
} else {
|
||||
tier = SubscriptionTier.BASIC;
|
||||
}
|
||||
|
||||
const status = mapStripeStatus(subscription.status);
|
||||
|
||||
const currentPeriodStart = new Date(((subscription as any).current_period_start as number) * 1000);
|
||||
const currentPeriodEnd = new Date(((subscription as any).current_period_end as number) * 1000);
|
||||
|
||||
await prisma.subscription.upsert({
|
||||
where: { stripeSubscriptionId: subscription.id },
|
||||
update: {
|
||||
tier,
|
||||
status,
|
||||
stripeCustomerId: typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id,
|
||||
stripePriceId: priceId,
|
||||
currentPeriodStart,
|
||||
currentPeriodEnd,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
canceledAt: subscription.canceled_at ? new Date(subscription.canceled_at * 1000) : null,
|
||||
trialEndsAt: subscription.trial_end ? new Date(subscription.trial_end * 1000) : null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
tier,
|
||||
status,
|
||||
stripeCustomerId: typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id,
|
||||
stripeSubscriptionId: subscription.id,
|
||||
stripePriceId: priceId,
|
||||
currentPeriodStart,
|
||||
currentPeriodEnd,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
canceledAt: subscription.canceled_at ? new Date(subscription.canceled_at * 1000) : null,
|
||||
trialEndsAt: subscription.trial_end ? new Date(subscription.trial_end * 1000) : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleSubscriptionDeleted(subscription: Stripe.Subscription): Promise<void> {
|
||||
const existing = await prisma.subscription.findUnique({
|
||||
where: { stripeSubscriptionId: subscription.id },
|
||||
});
|
||||
if (!existing) return;
|
||||
|
||||
await prisma.subscription.update({
|
||||
where: { stripeSubscriptionId: subscription.id },
|
||||
data: {
|
||||
status: SubscriptionStatus.CANCELED,
|
||||
cancelAtPeriodEnd: false,
|
||||
canceledAt: subscription.canceled_at ? new Date(subscription.canceled_at * 1000) : new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveUserIdFromStripeEvent(
|
||||
subscription: Stripe.Subscription,
|
||||
): Promise<string | null> {
|
||||
const metaUserId = subscription.metadata?.userId as string | undefined;
|
||||
if (metaUserId) return metaUserId;
|
||||
|
||||
const customerId = typeof subscription.customer === 'string'
|
||||
? subscription.customer
|
||||
: subscription.customer.id;
|
||||
|
||||
const existing = await prisma.subscription.findFirst({
|
||||
where: { stripeCustomerId: customerId },
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
return existing?.userId ?? null;
|
||||
}
|
||||
@@ -1,5 +1,41 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
|
||||
export type BillingOwnerContext = {
|
||||
billingOwnerId: string
|
||||
isGuestActor: boolean
|
||||
}
|
||||
|
||||
/** Resolve quota billing owner from an already-loaded session row (no extra query). */
|
||||
export function billingOwnerFromSession(
|
||||
sessionUserId: string,
|
||||
requestingUserId: string,
|
||||
): BillingOwnerContext {
|
||||
if (!sessionUserId) {
|
||||
throw new Error('Session has no owner — cannot resolve billing owner')
|
||||
}
|
||||
return {
|
||||
billingOwnerId: sessionUserId,
|
||||
isGuestActor: sessionUserId !== requestingUserId,
|
||||
}
|
||||
}
|
||||
|
||||
/** Host-pays: all collaborative AI usage is billed to BrainstormSession.userId (Story 3.4). */
|
||||
export async function getBillingOwner(
|
||||
sessionId: string,
|
||||
requestingUserId: string,
|
||||
): Promise<BillingOwnerContext> {
|
||||
const session = await prisma.brainstormSession.findUnique({
|
||||
where: { id: sessionId },
|
||||
select: { userId: true },
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
throw new Error('Session not found')
|
||||
}
|
||||
|
||||
return billingOwnerFromSession(session.userId, requestingUserId)
|
||||
}
|
||||
|
||||
export async function verifyParticipant(
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
|
||||
35
memento-note/lib/brainstorm-quota-client.ts
Normal file
35
memento-note/lib/brainstorm-quota-client.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/** Client-side parsing for brainstorm host-pays quota responses (Story 3.4). */
|
||||
|
||||
export type BrainstormQuotaPayload = {
|
||||
error: string
|
||||
feature?: string
|
||||
upgradeTier?: string
|
||||
byokConfigured?: boolean
|
||||
billingOwnerId?: string
|
||||
triggeredByUserId?: string
|
||||
isGuestActor?: boolean
|
||||
}
|
||||
|
||||
export class BrainstormQuotaError extends Error {
|
||||
readonly code = 'QUOTA_EXCEEDED'
|
||||
readonly isGuestActor: boolean
|
||||
readonly payload: BrainstormQuotaPayload
|
||||
|
||||
constructor(payload: BrainstormQuotaPayload, message: string) {
|
||||
super(message)
|
||||
this.name = 'BrainstormQuotaError'
|
||||
this.isGuestActor = Boolean(payload.isGuestActor)
|
||||
this.payload = payload
|
||||
}
|
||||
}
|
||||
|
||||
export function throwIfBrainstormQuota(
|
||||
res: Response,
|
||||
data: BrainstormQuotaPayload & { success?: boolean; error?: string },
|
||||
guestMessage: string,
|
||||
hostMessage: string,
|
||||
): void {
|
||||
if (res.status !== 402 || data?.error !== 'QUOTA_EXCEEDED') return
|
||||
const message = data.isGuestActor ? guestMessage : hostMessage
|
||||
throw new BrainstormQuotaError(data, message)
|
||||
}
|
||||
137
memento-note/lib/byok.ts
Normal file
137
memento-note/lib/byok.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { decryptApiKey, encryptApiKey, hashApiKey } from '@/lib/crypto';
|
||||
import {
|
||||
VALID_PROVIDERS,
|
||||
type AiGatewayProvider,
|
||||
} from '@/lib/ai/router';
|
||||
import { getProviderConfigKeys } from '@/lib/ai/factory';
|
||||
import type { SubscriptionTier } from '@/lib/entitlements';
|
||||
|
||||
const PRO_BYOK_PROVIDERS: readonly AiGatewayProvider[] = [
|
||||
'openai',
|
||||
'anthropic',
|
||||
'deepseek',
|
||||
'openrouter',
|
||||
'minimax',
|
||||
'zai',
|
||||
];
|
||||
|
||||
const BUSINESS_BYOK_PROVIDERS: readonly AiGatewayProvider[] = [
|
||||
...VALID_PROVIDERS,
|
||||
].filter((p) => p !== 'ollama' && p !== 'lmstudio') as AiGatewayProvider[];
|
||||
|
||||
export function getAllowedByokProviders(
|
||||
tier: SubscriptionTier,
|
||||
): readonly AiGatewayProvider[] {
|
||||
if (tier === 'BASIC') return [];
|
||||
if (tier === 'PRO') return PRO_BYOK_PROVIDERS;
|
||||
return BUSINESS_BYOK_PROVIDERS;
|
||||
}
|
||||
|
||||
export function isByokProviderAllowed(
|
||||
tier: SubscriptionTier,
|
||||
provider: string,
|
||||
): boolean {
|
||||
return getAllowedByokProviders(tier).includes(provider as AiGatewayProvider);
|
||||
}
|
||||
|
||||
export async function hasAnyActiveByok(userId: string): Promise<boolean> {
|
||||
const count = await prisma.userAPIKey.count({
|
||||
where: { userId, isActive: true },
|
||||
});
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
export async function getActiveByokKey(userId: string, provider: string) {
|
||||
return prisma.userAPIKey.findFirst({
|
||||
where: { userId, provider, isActive: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveByokApiKey(
|
||||
userId: string,
|
||||
providerType: string,
|
||||
): Promise<{ plaintext: string; provider: string } | null> {
|
||||
const row = await getActiveByokKey(userId, providerType);
|
||||
if (!row) return null;
|
||||
try {
|
||||
const plaintext = await decryptApiKey(row.encryptedKey);
|
||||
return { plaintext, provider: row.provider };
|
||||
} catch (err) {
|
||||
console.error('[byok] Failed to decrypt key for provider', providerType, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyByokToConfig(
|
||||
billingUserId: string,
|
||||
providerType: string,
|
||||
config: Record<string, string>,
|
||||
): Promise<{ config: Record<string, string>; usedByok: boolean }> {
|
||||
const byok = await resolveByokApiKey(billingUserId, providerType);
|
||||
if (!byok) return { config, usedByok: false };
|
||||
|
||||
const { apiKeyConfigKey } = getProviderConfigKeys(providerType);
|
||||
if (!apiKeyConfigKey) return { config, usedByok: false };
|
||||
|
||||
return {
|
||||
config: { ...config, [apiKeyConfigKey]: byok.plaintext },
|
||||
usedByok: true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function upsertUserApiKey(params: {
|
||||
userId: string;
|
||||
provider: AiGatewayProvider;
|
||||
plaintext: string;
|
||||
alias?: string;
|
||||
model?: string;
|
||||
}) {
|
||||
const encryptedKey = await encryptApiKey(params.plaintext);
|
||||
const keyHash = hashApiKey(params.plaintext);
|
||||
|
||||
return prisma.userAPIKey.upsert({
|
||||
where: {
|
||||
userId_provider: {
|
||||
userId: params.userId,
|
||||
provider: params.provider,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId: params.userId,
|
||||
provider: params.provider,
|
||||
alias: params.alias ?? '',
|
||||
encryptedKey,
|
||||
keyHash,
|
||||
model: params.model ?? null,
|
||||
isActive: true,
|
||||
},
|
||||
update: {
|
||||
alias: params.alias ?? '',
|
||||
encryptedKey,
|
||||
keyHash,
|
||||
model: params.model ?? null,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function toPublicApiKey(row: {
|
||||
provider: string;
|
||||
alias: string;
|
||||
model: string | null;
|
||||
isActive: boolean;
|
||||
lastUsedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
provider: row.provider,
|
||||
alias: row.alias,
|
||||
model: row.model,
|
||||
isActive: row.isActive,
|
||||
lastUsedAt: row.lastUsedAt,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
106
memento-note/lib/byok/validate-key.ts
Normal file
106
memento-note/lib/byok/validate-key.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { AiGatewayProvider } from '@/lib/ai/router';
|
||||
|
||||
const OPENAI_COMPAT = new Set<string>([
|
||||
'openai',
|
||||
'deepseek',
|
||||
'openrouter',
|
||||
'mistral',
|
||||
'zai',
|
||||
'minimax',
|
||||
'glm',
|
||||
'custom',
|
||||
'lmstudio',
|
||||
]);
|
||||
|
||||
async function validateOpenAiCompatible(
|
||||
apiKey: string,
|
||||
baseUrl: string,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/models`, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Provider rejected the API key (${res.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateAnthropic(apiKey: string): Promise<void> {
|
||||
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'claude-3-5-haiku-20241022',
|
||||
max_tokens: 1,
|
||||
messages: [{ role: 'user', content: 'ping' }],
|
||||
}),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
throw new Error('Provider rejected the API key');
|
||||
}
|
||||
if (res.status >= 500) {
|
||||
throw new Error('Provider temporarily unavailable; try again');
|
||||
}
|
||||
}
|
||||
|
||||
async function validateGoogle(apiKey: string): Promise<void> {
|
||||
const res = await fetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(apiKey)}`,
|
||||
{ signal: AbortSignal.timeout(10_000) },
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Provider rejected the API key (${res.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
const BASE_URLS: Partial<Record<AiGatewayProvider, string>> = {
|
||||
deepseek: 'https://api.deepseek.com/v1',
|
||||
openrouter: 'https://openrouter.ai/api/v1',
|
||||
mistral: 'https://api.mistral.ai/v1',
|
||||
zai: 'https://api.zukijourney.com/v1',
|
||||
minimax: 'https://api.minimax.io/v1',
|
||||
glm: 'https://open.bigmodel.ai/api/paas/v4',
|
||||
openai: 'https://api.openai.com/v1',
|
||||
};
|
||||
|
||||
export async function validateProviderApiKey(
|
||||
provider: AiGatewayProvider,
|
||||
apiKey: string,
|
||||
baseUrl?: string,
|
||||
): Promise<void> {
|
||||
if (!apiKey.trim()) {
|
||||
throw new Error('API key is required');
|
||||
}
|
||||
|
||||
if (provider === 'anthropic' || provider === 'anthropic_custom') {
|
||||
await validateAnthropic(apiKey);
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === 'google') {
|
||||
await validateGoogle(apiKey);
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === 'ollama' || provider === 'lmstudio') {
|
||||
throw new Error('Local providers are not supported for BYOK');
|
||||
}
|
||||
|
||||
if (OPENAI_COMPAT.has(provider)) {
|
||||
const url = provider === 'custom'
|
||||
? baseUrl
|
||||
: BASE_URLS[provider as AiGatewayProvider];
|
||||
if (!url) {
|
||||
throw new Error('Base URL is required for this provider');
|
||||
}
|
||||
await validateOpenAiCompatible(apiKey, url);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported provider: ${provider}`);
|
||||
}
|
||||
@@ -9,6 +9,12 @@ const ENV_FALLBACKS: Record<string, string> = {
|
||||
AI_MODEL_EMBEDDING: process.env.AI_MODEL_EMBEDDING || '',
|
||||
AI_PROVIDER_CHAT: process.env.AI_PROVIDER_CHAT || '',
|
||||
AI_MODEL_CHAT: process.env.AI_MODEL_CHAT || '',
|
||||
AI_PROVIDER_CHAT_FALLBACK: process.env.AI_PROVIDER_CHAT_FALLBACK || '',
|
||||
AI_MODEL_CHAT_FALLBACK: process.env.AI_MODEL_CHAT_FALLBACK || '',
|
||||
AI_PROVIDER_TAGS_FALLBACK: process.env.AI_PROVIDER_TAGS_FALLBACK || '',
|
||||
AI_MODEL_TAGS_FALLBACK: process.env.AI_MODEL_TAGS_FALLBACK || '',
|
||||
AI_PROVIDER_EMBEDDING_FALLBACK: process.env.AI_PROVIDER_EMBEDDING_FALLBACK || '',
|
||||
AI_MODEL_EMBEDDING_FALLBACK: process.env.AI_MODEL_EMBEDDING_FALLBACK || '',
|
||||
// Ollama
|
||||
OLLAMA_BASE_URL: process.env.OLLAMA_BASE_URL || '',
|
||||
// OpenAI
|
||||
|
||||
65
memento-note/lib/crypto.ts
Normal file
65
memento-note/lib/crypto.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
randomBytes,
|
||||
scrypt,
|
||||
} from 'crypto';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const KEY_LEN = 32;
|
||||
const IV_LEN = 16;
|
||||
const SALT_LEN = 16;
|
||||
const TAG_LEN = 16;
|
||||
|
||||
function getMasterPassphrase(): string {
|
||||
const master = process.env.MASTER_ENCRYPTION_KEY;
|
||||
if (!master || master.length < 32) {
|
||||
throw new Error('MASTER_ENCRYPTION_KEY must be set (minimum 32 characters)');
|
||||
}
|
||||
return master;
|
||||
}
|
||||
|
||||
async function deriveKey(salt: Buffer): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
scrypt(getMasterPassphrase(), salt, KEY_LEN, (err, key) => {
|
||||
if (err) reject(err);
|
||||
else resolve(key);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function encryptApiKey(plaintext: string): Promise<string> {
|
||||
const salt = randomBytes(SALT_LEN);
|
||||
const iv = randomBytes(IV_LEN);
|
||||
const key = await deriveKey(salt);
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(plaintext, 'utf8'),
|
||||
cipher.final(),
|
||||
]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return Buffer.concat([salt, iv, tag, encrypted]).toString('base64');
|
||||
}
|
||||
|
||||
export async function decryptApiKey(payload: string): Promise<string> {
|
||||
const buf = Buffer.from(payload, 'base64');
|
||||
if (buf.length < SALT_LEN + IV_LEN + TAG_LEN + 1) {
|
||||
throw new Error('Invalid encrypted key payload');
|
||||
}
|
||||
const salt = buf.subarray(0, SALT_LEN);
|
||||
const iv = buf.subarray(SALT_LEN, SALT_LEN + IV_LEN);
|
||||
const tag = buf.subarray(SALT_LEN + IV_LEN, SALT_LEN + IV_LEN + TAG_LEN);
|
||||
const ciphertext = buf.subarray(SALT_LEN + IV_LEN + TAG_LEN);
|
||||
const key = await deriveKey(salt);
|
||||
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([
|
||||
decipher.update(ciphertext),
|
||||
decipher.final(),
|
||||
]).toString('utf8');
|
||||
}
|
||||
|
||||
export function hashApiKey(plaintext: string): string {
|
||||
return createHash('sha256').update(plaintext, 'utf8').digest('hex');
|
||||
}
|
||||
375
memento-note/lib/entitlements.ts
Normal file
375
memento-note/lib/entitlements.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
import { redis } from './redis';
|
||||
import { prisma } from './prisma';
|
||||
import { hasAnyActiveByok } from './byok';
|
||||
import {
|
||||
getCurrentPeriodKey,
|
||||
getRedisKey,
|
||||
parseRedisInt,
|
||||
isValidFeature,
|
||||
} from './quota-utils';
|
||||
|
||||
type SubscriptionTier = 'BASIC' | 'PRO' | 'BUSINESS' | 'ENTERPRISE';
|
||||
|
||||
export interface EntitlementResult {
|
||||
allowed: boolean;
|
||||
remaining: number;
|
||||
limit: number;
|
||||
tier: SubscriptionTier;
|
||||
reason?: 'QUOTA_EXCEEDED' | 'TIER_LIMITED' | 'FEATURE_NOT_AVAILABLE';
|
||||
message?: string;
|
||||
upgradeTier?: 'PRO' | 'BUSINESS';
|
||||
byokConfigured?: boolean;
|
||||
}
|
||||
|
||||
export class QuotaExceededError extends Error {
|
||||
code = 'QUOTA_EXCEEDED';
|
||||
upgradeTier: 'PRO' | 'BUSINESS';
|
||||
feature: string;
|
||||
currentQuota: number;
|
||||
usedQuota: number;
|
||||
byokConfigured: boolean;
|
||||
billingOwnerId?: string;
|
||||
triggeredByUserId?: string;
|
||||
isGuestActor?: boolean;
|
||||
|
||||
constructor(
|
||||
upgradeTier: 'PRO' | 'BUSINESS',
|
||||
feature: string,
|
||||
currentQuota: number,
|
||||
usedQuota: number,
|
||||
byokConfigured: boolean = false,
|
||||
sessionMeta?: {
|
||||
billingOwnerId?: string;
|
||||
triggeredByUserId?: string;
|
||||
isGuestActor?: boolean;
|
||||
},
|
||||
) {
|
||||
super(`Quota exceeded for ${feature}`);
|
||||
this.upgradeTier = upgradeTier;
|
||||
this.feature = feature;
|
||||
this.currentQuota = currentQuota;
|
||||
this.usedQuota = usedQuota;
|
||||
this.byokConfigured = byokConfigured;
|
||||
this.billingOwnerId = sessionMeta?.billingOwnerId;
|
||||
this.triggeredByUserId = sessionMeta?.triggeredByUserId;
|
||||
this.isGuestActor = sessionMeta?.isGuestActor;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
error: this.code,
|
||||
feature: this.feature,
|
||||
upgradeTier: this.upgradeTier,
|
||||
byokConfigured: this.byokConfigured,
|
||||
isGuestActor: this.isGuestActor ?? false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const TIER_LIMITS: Record<SubscriptionTier, Record<string, number | 'unlimited'>> = {
|
||||
BASIC: {
|
||||
semantic_search: 30,
|
||||
auto_tag: 20,
|
||||
auto_title: 10,
|
||||
brainstorm_create: 1,
|
||||
brainstorm_expand: 10,
|
||||
brainstorm_enrich: 20,
|
||||
},
|
||||
PRO: {
|
||||
semantic_search: 100,
|
||||
auto_tag: 200,
|
||||
auto_title: 200,
|
||||
reformulate: 50,
|
||||
chat: 100,
|
||||
brainstorm_create: 30,
|
||||
brainstorm_expand: 100,
|
||||
brainstorm_enrich: 200,
|
||||
},
|
||||
BUSINESS: {
|
||||
semantic_search: 1000,
|
||||
auto_tag: 1000,
|
||||
auto_title: 1000,
|
||||
reformulate: 500,
|
||||
chat: 1000,
|
||||
brainstorm_create: 200,
|
||||
brainstorm_expand: 500,
|
||||
brainstorm_enrich: 1000,
|
||||
},
|
||||
ENTERPRISE: {
|
||||
semantic_search: 'unlimited',
|
||||
auto_tag: 'unlimited',
|
||||
auto_title: 'unlimited',
|
||||
reformulate: 'unlimited',
|
||||
chat: 'unlimited',
|
||||
brainstorm_create: 'unlimited',
|
||||
brainstorm_expand: 'unlimited',
|
||||
brainstorm_enrich: 'unlimited',
|
||||
},
|
||||
};
|
||||
|
||||
const TTL_SECONDS = 90 * 24 * 60 * 60;
|
||||
|
||||
const INCREMENT_LUA = `
|
||||
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
|
||||
local ttl = tonumber(ARGV[1])
|
||||
redis.call('INCRBY', KEYS[1], 1)
|
||||
local ttlResult = redis.call('TTL', KEYS[1])
|
||||
if ttlResult == -1 then
|
||||
redis.call('EXPIRE', KEYS[1], ttl)
|
||||
end
|
||||
local newCount = tonumber(redis.call('GET', KEYS[1]))
|
||||
return newCount
|
||||
`;
|
||||
|
||||
const RESERVE_LUA = `
|
||||
local limit = tonumber(ARGV[1])
|
||||
local ttl = tonumber(ARGV[2])
|
||||
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
|
||||
if current >= limit then
|
||||
return -1
|
||||
end
|
||||
redis.call('INCRBY', KEYS[1], 1)
|
||||
local ttlResult = redis.call('TTL', KEYS[1])
|
||||
if ttlResult == -1 then
|
||||
redis.call('EXPIRE', KEYS[1], ttl)
|
||||
end
|
||||
local newCount = tonumber(redis.call('GET', KEYS[1]))
|
||||
return newCount
|
||||
`;
|
||||
|
||||
function getLimit(tier: SubscriptionTier, feature: string): number | undefined {
|
||||
const tierLimits = TIER_LIMITS[tier];
|
||||
const limit = tierLimits?.[feature];
|
||||
if (limit === 'unlimited') return Infinity;
|
||||
if (limit === undefined) return undefined;
|
||||
return limit;
|
||||
}
|
||||
|
||||
export async function getUserInfo(
|
||||
userId: string,
|
||||
): Promise<{ tier: SubscriptionTier; status: string; currentPeriodEnd?: Date }> {
|
||||
const subscription = await prisma.subscription.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
if (!subscription) return { tier: 'BASIC', status: 'INACTIVE' };
|
||||
return {
|
||||
tier: subscription.tier as SubscriptionTier,
|
||||
status: subscription.status,
|
||||
currentPeriodEnd: subscription.currentPeriodEnd,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getEffectiveTier(
|
||||
userId: string,
|
||||
): Promise<SubscriptionTier> {
|
||||
const { tier, status, currentPeriodEnd } = await getUserInfo(userId);
|
||||
|
||||
if (status === 'ACTIVE' || status === 'TRIALING') return tier;
|
||||
|
||||
if ((status === 'PAST_DUE' || status === 'CANCELED') && currentPeriodEnd) {
|
||||
if (new Date() < new Date(currentPeriodEnd)) return tier;
|
||||
}
|
||||
|
||||
return 'BASIC';
|
||||
}
|
||||
|
||||
export async function canUseFeature(
|
||||
userId: string,
|
||||
feature: string,
|
||||
): Promise<EntitlementResult> {
|
||||
if (!isValidFeature(feature)) {
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
limit: 0,
|
||||
tier: 'BASIC',
|
||||
reason: 'TIER_LIMITED',
|
||||
message: `Unknown feature: ${feature}`,
|
||||
};
|
||||
}
|
||||
|
||||
const tier = await getEffectiveTier(userId);
|
||||
const limit = getLimit(tier, feature);
|
||||
|
||||
if (limit === undefined) {
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
limit: 0,
|
||||
tier,
|
||||
reason: 'FEATURE_NOT_AVAILABLE',
|
||||
message: `Feature "${feature}" is not available on the ${tier} plan. Upgrade to unlock it.`,
|
||||
upgradeTier: tier === 'BASIC' ? 'PRO' : 'BUSINESS',
|
||||
};
|
||||
}
|
||||
|
||||
if (limit === Infinity) {
|
||||
return { allowed: true, remaining: Infinity, limit: Infinity, tier };
|
||||
}
|
||||
|
||||
try {
|
||||
const key = getRedisKey(userId, feature);
|
||||
const currentStr = await redis.get(key);
|
||||
const current = parseRedisInt(currentStr);
|
||||
const allowed = current < limit;
|
||||
|
||||
if (!allowed) {
|
||||
const byokConfigured = await hasAnyActiveByok(userId);
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
limit,
|
||||
tier,
|
||||
reason: 'QUOTA_EXCEEDED',
|
||||
upgradeTier: tier === 'BASIC' ? 'PRO' : 'BUSINESS',
|
||||
byokConfigured,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: Math.max(0, limit - current),
|
||||
limit,
|
||||
tier,
|
||||
byokConfigured: await hasAnyActiveByok(userId),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[entitlements] Redis unavailable, allowing request (fail-open):', err);
|
||||
return { allowed: true, remaining: limit, limit, tier };
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkEntitlementOrThrow(
|
||||
userId: string,
|
||||
feature: string,
|
||||
): Promise<void> {
|
||||
const result = await canUseFeature(userId, feature);
|
||||
|
||||
if (!result.allowed) {
|
||||
throw new QuotaExceededError(
|
||||
result.upgradeTier ?? (result.tier === 'BASIC' ? 'PRO' : 'BUSINESS'),
|
||||
feature,
|
||||
result.limit,
|
||||
result.limit > 0 ? result.limit - result.remaining : 0,
|
||||
result.byokConfigured ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function reserveUsageOrThrow(
|
||||
userId: string,
|
||||
feature: string,
|
||||
): Promise<void> {
|
||||
if (!isValidFeature(feature)) {
|
||||
throw new QuotaExceededError('PRO', feature, 0, 0, false);
|
||||
}
|
||||
|
||||
const tier = await getEffectiveTier(userId);
|
||||
const limit = getLimit(tier, feature);
|
||||
|
||||
if (limit === undefined) {
|
||||
throw new QuotaExceededError(
|
||||
tier === 'BASIC' ? 'PRO' : 'BUSINESS',
|
||||
feature,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
if (limit === Infinity) return;
|
||||
|
||||
try {
|
||||
const key = getRedisKey(userId, feature);
|
||||
const newCount = await redis.eval(
|
||||
RESERVE_LUA,
|
||||
1,
|
||||
key,
|
||||
String(limit),
|
||||
String(TTL_SECONDS),
|
||||
) as number;
|
||||
|
||||
if (newCount === -1) {
|
||||
const byokConfigured = await hasAnyActiveByok(userId);
|
||||
throw new QuotaExceededError(
|
||||
tier === 'BASIC' ? 'PRO' : 'BUSINESS',
|
||||
feature,
|
||||
limit,
|
||||
limit,
|
||||
byokConfigured,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaExceededError) throw err;
|
||||
console.error('[entitlements] Redis unavailable, allowing request (fail-open):', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Host-pays: bill session owner, attach actor metadata for 402 responses (Story 3.4). */
|
||||
export async function checkSessionEntitlementOrThrow(
|
||||
billingOwnerId: string,
|
||||
triggeredByUserId: string,
|
||||
isGuestActor: boolean,
|
||||
feature: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await reserveUsageOrThrow(billingOwnerId, feature);
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaExceededError) {
|
||||
err.billingOwnerId = billingOwnerId;
|
||||
err.triggeredByUserId = triggeredByUserId;
|
||||
err.isGuestActor = isGuestActor;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function incrementUsageAsync(userId: string, feature: string): Promise<void> {
|
||||
if (!isValidFeature(feature)) return Promise.resolve();
|
||||
|
||||
const key = getRedisKey(userId, feature);
|
||||
return redis.eval(INCREMENT_LUA, 1, key, String(TTL_SECONDS)).then(() => {}).catch((err) => {
|
||||
console.error('[entitlements] Async increment failed:', err);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getUserQuotas(
|
||||
userId: string,
|
||||
): Promise<Record<string, { remaining: number; limit: number; used: number }>> {
|
||||
const tier = await getEffectiveTier(userId);
|
||||
const period = getCurrentPeriodKey();
|
||||
const features = Object.keys(TIER_LIMITS[tier]);
|
||||
|
||||
if (features.length === 0) return {};
|
||||
|
||||
const keys = features.map((f) => `usage:${userId}:${f}:${period}`);
|
||||
|
||||
try {
|
||||
const values = await redis.mget(...keys);
|
||||
|
||||
const result: Record<string, { remaining: number; limit: number; used: number }> = {};
|
||||
for (let i = 0; i < features.length; i++) {
|
||||
const feature = features[i];
|
||||
const limit = getLimit(tier, feature) ?? 0;
|
||||
const current = parseRedisInt(values[i]);
|
||||
result[feature] = {
|
||||
remaining: limit === Infinity ? Infinity : Math.max(0, limit - current),
|
||||
limit,
|
||||
used: current,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('[entitlements] getUserQuotas Redis error:', err);
|
||||
const result: Record<string, { remaining: number; limit: number; used: number }> = {};
|
||||
for (const feature of features) {
|
||||
const limit = getLimit(tier, feature) ?? 0;
|
||||
result[feature] = { remaining: limit, limit, used: 0 };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export { TIER_LIMITS, getLimit };
|
||||
export type { SubscriptionTier };
|
||||
@@ -919,6 +919,53 @@ export interface Translations {
|
||||
expand: string
|
||||
collapse: string
|
||||
}
|
||||
billing: {
|
||||
title: string
|
||||
currentPlan: string
|
||||
upgradePlan: string
|
||||
manageBilling: string
|
||||
manageDescription: string
|
||||
openPortal: string
|
||||
renewsOn: string
|
||||
expiresOn: string
|
||||
canceledAt: string
|
||||
freePlan: string
|
||||
proPlan: string
|
||||
businessPlan: string
|
||||
enterprisePlan: string
|
||||
perMonth: string
|
||||
perYear: string
|
||||
monthly: string
|
||||
annual: string
|
||||
save: string
|
||||
upgradeTitle: string
|
||||
proPrice: string
|
||||
businessPrice: string
|
||||
proAnnualPrice: string
|
||||
businessAnnualPrice: string
|
||||
proFeature1: string
|
||||
proFeature2: string
|
||||
proFeature3: string
|
||||
proFeature4: string
|
||||
businessFeature1: string
|
||||
businessFeature2: string
|
||||
businessFeature3: string
|
||||
businessFeature4: string
|
||||
enterpriseTitle: string
|
||||
enterpriseDescription: string
|
||||
contactSales: string
|
||||
startCheckout: string
|
||||
checkoutLoading: string
|
||||
checkoutSuccess: string
|
||||
checkoutCanceled: string
|
||||
active: string
|
||||
trialing: string
|
||||
pastDue: string
|
||||
canceled: string
|
||||
inactive: string
|
||||
billingEnabled: string
|
||||
billingDisabled: string
|
||||
}
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
|
||||
31
memento-note/lib/quota-utils.ts
Normal file
31
memento-note/lib/quota-utils.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export const VALID_FEATURES = [
|
||||
'semantic_search',
|
||||
'auto_tag',
|
||||
'auto_title',
|
||||
'reformulate',
|
||||
'chat',
|
||||
'brainstorm_create',
|
||||
'brainstorm_expand',
|
||||
'brainstorm_enrich',
|
||||
] as const;
|
||||
|
||||
export type FeatureName = (typeof VALID_FEATURES)[number];
|
||||
|
||||
export function getCurrentPeriodKey(): string {
|
||||
return new Date().toISOString().slice(0, 7);
|
||||
}
|
||||
|
||||
export function getRedisKey(userId: string, feature: string): string {
|
||||
const period = getCurrentPeriodKey();
|
||||
return `usage:${userId}:${feature}:${period}`;
|
||||
}
|
||||
|
||||
export function parseRedisInt(value: string | number | null | undefined): number {
|
||||
if (value == null) return 0;
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? Math.round(n) : 1;
|
||||
}
|
||||
|
||||
export function isValidFeature(feature: string): feature is FeatureName {
|
||||
return (VALID_FEATURES as readonly string[]).includes(feature);
|
||||
}
|
||||
32
memento-note/lib/redis.ts
Normal file
32
memento-note/lib/redis.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import Redis from 'ioredis';
|
||||
|
||||
const globalForRedis = globalThis as unknown as { redis?: Redis };
|
||||
|
||||
const redis =
|
||||
globalForRedis.redis ??
|
||||
new Redis({
|
||||
host: process.env.REDIS_HOST ?? 'localhost',
|
||||
port: parseInt(process.env.REDIS_PORT ?? '6379'),
|
||||
password: process.env.REDIS_PASSWORD,
|
||||
lazyConnect: true,
|
||||
retryStrategy(times) {
|
||||
if (times > 10) {
|
||||
console.error('[redis] Max retries exceeded, will retry in 30s');
|
||||
return 30000;
|
||||
}
|
||||
return Math.min(times * 200, 2000);
|
||||
},
|
||||
maxRetriesPerRequest: 3,
|
||||
});
|
||||
|
||||
redis.on('error', (err) => {
|
||||
console.error('[redis] Connection error:', err.message);
|
||||
});
|
||||
|
||||
redis.on('connect', () => {
|
||||
console.log('[redis] Connected');
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForRedis.redis = redis;
|
||||
|
||||
export { redis };
|
||||
18
memento-note/lib/stripe.ts
Normal file
18
memento-note/lib/stripe.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import Stripe from 'stripe';
|
||||
|
||||
if (!process.env.STRIPE_SECRET_KEY) {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error('STRIPE_SECRET_KEY is required in production');
|
||||
}
|
||||
}
|
||||
|
||||
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? 'sk_test_placeholder', {
|
||||
apiVersion: '2026-04-22.dahlia',
|
||||
typescript: true,
|
||||
});
|
||||
|
||||
export function getPublishableKey(): string {
|
||||
const key = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
|
||||
if (!key) throw new Error('NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is not set');
|
||||
return key;
|
||||
}
|
||||
@@ -27,6 +27,8 @@ export function getThemeScript(serverTheme: string = 'light') {
|
||||
root.setAttribute('data-theme', theme);
|
||||
if (theme === 'midnight') root.classList.add('dark');
|
||||
}
|
||||
var accentStored = localStorage.getItem('accent-color');
|
||||
if (accentStored) root.style.setProperty('--color-brand-accent', accentStored);
|
||||
} catch (e) {
|
||||
console.error('Theme script error', e);
|
||||
}
|
||||
|
||||
45
memento-note/lib/usage-tracker.ts
Normal file
45
memento-note/lib/usage-tracker.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { redis } from './redis';
|
||||
import { getRedisKey, parseRedisInt } from './quota-utils';
|
||||
|
||||
const TTL_SECONDS = 90 * 24 * 60 * 60;
|
||||
|
||||
export function trackFeatureUsage(
|
||||
userId: string,
|
||||
feature: string,
|
||||
tokensUsed: number,
|
||||
): void {
|
||||
if (tokensUsed < 0) {
|
||||
console.warn('[usage-tracker] Negative tokensUsed ignored:', tokensUsed);
|
||||
return;
|
||||
}
|
||||
|
||||
if (tokensUsed === 0) return;
|
||||
|
||||
const key = getRedisKey(userId, feature);
|
||||
|
||||
redis
|
||||
.pipeline()
|
||||
.incrbyfloat(`${key}:tokens`, tokensUsed)
|
||||
.expire(`${key}:tokens`, TTL_SECONDS)
|
||||
.exec()
|
||||
.catch((err) => {
|
||||
console.error('[usage-tracker] Failed to track token usage:', err);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getUsageCounter(
|
||||
userId: string,
|
||||
feature: string,
|
||||
): Promise<{ requests: number; tokens: number }> {
|
||||
const key = getRedisKey(userId, feature);
|
||||
|
||||
const [requests, tokens] = await Promise.all([
|
||||
redis.get(key),
|
||||
redis.get(`${key}:tokens`),
|
||||
]);
|
||||
|
||||
return {
|
||||
requests: parseRedisInt(requests),
|
||||
tokens: parseRedisInt(tokens),
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user