fix: replace raw ai_consent_required code with translatable message and consent prompt
- Centralize AI consent 403 responses in server-consent helpers - Return human-readable message + code + i18n errorKey from all AI APIs - Update dashboard and AI clients to detect code/errorKey and translate - Add consent.ai.requiredToast i18n keys (fr/en)
This commit is contained in:
@@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { autoLabelCreationService } from '@/lib/ai/services'
|
import { autoLabelCreationService } from '@/lib/ai/services'
|
||||||
import { getAISettings } from '@/app/actions/ai-settings'
|
import { getAISettings } from '@/app/actions/ai-settings'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenJson } from '@/lib/consent/server-consent'
|
||||||
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,10 +23,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// GDPR AI Consent check
|
// GDPR AI Consent check
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json(
|
return aiConsentForbiddenJson()
|
||||||
{ success: false, error: 'ai_consent_required' },
|
|
||||||
{ status: 403 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Respect user's autoLabeling toggle
|
// Respect user's autoLabeling toggle
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { batchOrganizationService } from '@/lib/ai/services'
|
import { batchOrganizationService } from '@/lib/ai/services'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenJson } from '@/lib/consent/server-consent'
|
||||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,10 +20,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// GDPR AI Consent check
|
// GDPR AI Consent check
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json(
|
return aiConsentForbiddenJson()
|
||||||
{ success: false, error: 'ai_consent_required' },
|
|
||||||
{ status: 403 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get language from request headers or body
|
// Get language from request headers or body
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { getAISettings } from '@/app/actions/ai-settings'
|
import { getAISettings } from '@/app/actions/ai-settings'
|
||||||
import { describeImages } from '@/lib/ai/services/image-description.service'
|
import { describeImages } from '@/lib/ai/services/image-description.service'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
@@ -13,7 +13,7 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
return aiConsentForbiddenResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
const userSettings = await getAISettings(session.user.id)
|
const userSettings = await getAISettings(session.user.id)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { memoryEchoService } from '@/lib/ai/services/memory-echo.service'
|
import { memoryEchoService } from '@/lib/ai/services/memory-echo.service'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/ai/echo/connections?noteId={id}&page={page}&limit={limit}
|
* GET /api/ai/echo/connections?noteId={id}&page={page}&limit={limit}
|
||||||
@@ -19,10 +19,7 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json(
|
return aiConsentForbiddenResponse()
|
||||||
{ error: 'ai_consent_required' },
|
|
||||||
{ status: 403 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get query parameters
|
// Get query parameters
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { auth } from '@/auth'
|
|||||||
import { getChatProvider } from '@/lib/ai/factory'
|
import { getChatProvider } from '@/lib/ai/factory'
|
||||||
import { getSystemConfig } from '@/lib/config'
|
import { getSystemConfig } from '@/lib/config'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,7 +22,7 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
return aiConsentForbiddenResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await req.json()
|
const body = await req.json()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { memoryEchoService } from '@/lib/ai/services/memory-echo.service'
|
import { memoryEchoService } from '@/lib/ai/services/memory-echo.service'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,10 +21,7 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
// GDPR AI Consent check
|
// GDPR AI Consent check
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json(
|
return aiConsentForbiddenResponse()
|
||||||
{ error: 'ai_consent_required' },
|
|
||||||
{ status: 403 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get next insight (respects frequency limits)
|
// Get next insight (respects frequency limits)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { getSystemConfig } from '@/lib/config'
|
import { getSystemConfig } from '@/lib/config'
|
||||||
import { getTagsProvider } from '@/lib/ai/factory'
|
import { getTagsProvider } from '@/lib/ai/factory'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -13,7 +13,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
return aiConsentForbiddenResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
const { existingContent, resourceText, mode, language, format } = await request.json()
|
const { existingContent, resourceText, mode, language, format } = await request.json()
|
||||||
|
|||||||
62
memento-note/app/api/ai/notebook-slides/route.ts
Normal file
62
memento-note/app/api/ai/notebook-slides/route.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/auth'
|
||||||
|
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||||
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
|
import { generateSlideDeck } from '@/lib/ai/services/slide-deck-generator.service'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await hasUserAiConsent())) {
|
||||||
|
return aiConsentForbiddenResponse()
|
||||||
|
}
|
||||||
|
const userId = session.user.id
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const { notebookId, theme, purpose, audience, slideCount, language } = body
|
||||||
|
|
||||||
|
if (!notebookId || typeof notebookId !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'notebookId is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const intent =
|
||||||
|
purpose || audience || slideCount
|
||||||
|
? {
|
||||||
|
purpose: purpose || 'auto',
|
||||||
|
audience: audience || 'auto',
|
||||||
|
slideCount: typeof slideCount === 'number' ? slideCount : undefined,
|
||||||
|
template: 'auto' as const,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
|
||||||
|
const result = await withAiQuota(
|
||||||
|
userId,
|
||||||
|
'slide_generate',
|
||||||
|
() =>
|
||||||
|
generateSlideDeck({
|
||||||
|
userId,
|
||||||
|
sourceNotebookId: notebookId,
|
||||||
|
theme: theme && theme !== 'auto' ? theme : null,
|
||||||
|
lang: language === 'en' ? 'en' : 'fr',
|
||||||
|
intent,
|
||||||
|
}),
|
||||||
|
{ lane: 'chat' },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return NextResponse.json({ error: result.error || 'Failed to generate slides' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(result)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const quotaResp = handleQuotaHttpError(error)
|
||||||
|
if (quotaResp) return quotaResp
|
||||||
|
console.error('[NotebookSlides] Error:', error)
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to generate slides'
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { rateLimit } from '@/lib/rate-limit'
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { notebookSummaryService } from '@/lib/ai/services'
|
import { notebookSummaryService } from '@/lib/ai/services'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenJson } from '@/lib/consent/server-consent'
|
||||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,10 +20,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json(
|
return aiConsentForbiddenJson()
|
||||||
{ success: false, error: 'ai_consent_required' },
|
|
||||||
{ status: 403 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { auth } from '@/auth'
|
|||||||
import { getSystemConfig } from '@/lib/config'
|
import { getSystemConfig } from '@/lib/config'
|
||||||
import { getTagsProvider } from '@/lib/ai/factory'
|
import { getTagsProvider } from '@/lib/ai/factory'
|
||||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
|
|
||||||
export type PersonaId = 'engineer' | 'financial' | 'customer' | 'skeptic' | 'optimist'
|
export type PersonaId = 'engineer' | 'financial' | 'customer' | 'skeptic' | 'optimist'
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
return aiConsentForbiddenResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
const { content, title, personaId } = await request.json()
|
const { content, title, personaId } = await request.json()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { auth } from '@/auth'
|
|||||||
import { paragraphRefactorService } from '@/lib/ai/services/paragraph-refactor.service'
|
import { paragraphRefactorService } from '@/lib/ai/services/paragraph-refactor.service'
|
||||||
import { getAISettings } from '@/app/actions/ai-settings'
|
import { getAISettings } from '@/app/actions/ai-settings'
|
||||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -14,7 +14,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
return aiConsentForbiddenResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Respect user's paragraphRefactor toggle (Assistant IA)
|
// Respect user's paragraphRefactor toggle (Assistant IA)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { getSystemConfig } from '@/lib/config'
|
|||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { reserveUsageOrThrow, QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
|
import { reserveUsageOrThrow, QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
|
|
||||||
export const maxDuration = 30
|
export const maxDuration = 30
|
||||||
|
|
||||||
@@ -41,10 +41,7 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return new Response(JSON.stringify({ error: 'ai_consent_required' }), {
|
return aiConsentForbiddenResponse()
|
||||||
status: 403,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
const userId = session.user.id
|
const userId = session.user.id
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { notebookSuggestionService } from '@/lib/ai/services/notebook-suggestion.service'
|
import { notebookSuggestionService } from '@/lib/ai/services/notebook-suggestion.service'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
@@ -13,7 +13,7 @@ export async function POST(req: NextRequest) {
|
|||||||
const userId = session.user.id
|
const userId = session.user.id
|
||||||
|
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
return aiConsentForbiddenResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await req.json()
|
const body = await req.json()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-fo
|
|||||||
import { getSystemConfig } from '@/lib/config';
|
import { getSystemConfig } from '@/lib/config';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { checkEntitlementOrThrow, QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements';
|
import { checkEntitlementOrThrow, QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements';
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent';
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent';
|
||||||
|
|
||||||
import { getAISettings } from '@/app/actions/ai-settings';
|
import { getAISettings } from '@/app/actions/ai-settings';
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
// GDPR AI Consent check
|
// GDPR AI Consent check
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 });
|
return aiConsentForbiddenResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
const userSettings = await getAISettings(session.user.id);
|
const userSettings = await getAISettings(session.user.id);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { getAISettings } from '@/app/actions/ai-settings'
|
|||||||
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
|
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
|
||||||
import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
|
import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
|
|
||||||
const requestSchema = z.object({
|
const requestSchema = z.object({
|
||||||
content: z.string().min(1, "Le contenu ne peut pas être vide"),
|
content: z.string().min(1, "Le contenu ne peut pas être vide"),
|
||||||
@@ -37,7 +37,7 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
// GDPR AI Consent check
|
// GDPR AI Consent check
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
return aiConsentForbiddenResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = await getAISettings(session.user.id)
|
const settings = await getAISettings(session.user.id)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { getChatProvider } from '@/lib/ai/factory'
|
import { getChatProvider } from '@/lib/ai/factory'
|
||||||
import { getSystemConfig } from '@/lib/config'
|
import { getSystemConfig } from '@/lib/config'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -14,7 +14,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
return aiConsentForbiddenResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
const { text } = await request.json()
|
const { text } = await request.json()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { getTagsProvider } from '@/lib/ai/factory'
|
import { getTagsProvider } from '@/lib/ai/factory'
|
||||||
import { getSystemConfig } from '@/lib/config'
|
import { getSystemConfig } from '@/lib/config'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -13,7 +13,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
return aiConsentForbiddenResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
const { text, targetLanguage } = await request.json()
|
const { text, targetLanguage } = await request.json()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { getChatProvider } from '@/lib/ai/factory'
|
|||||||
import { semanticSearchService } from '@/lib/ai/services/semantic-search.service'
|
import { semanticSearchService } from '@/lib/ai/services/semantic-search.service'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
|
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
|
||||||
import { toolRegistry } from '@/lib/ai/tools'
|
import { toolRegistry } from '@/lib/ai/tools'
|
||||||
import { reserveAiUsageOrThrow, handleQuotaHttpError } from '@/lib/ai-quota'
|
import { reserveAiUsageOrThrow, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||||
@@ -65,10 +65,7 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
// GDPR AI Consent check
|
// GDPR AI Consent check
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return new Response(JSON.stringify({ error: 'ai_consent_required' }), {
|
return aiConsentForbiddenResponse()
|
||||||
status: 403,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1.5 Quota check (per-provider BYOK bypass — only when BYOK will be used for resolved provider)
|
// 1.5 Quota check (per-provider BYOK bypass — only when BYOK will be used for resolved provider)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { auth } from '@/auth'
|
|||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { getAISettings } from '@/app/actions/ai-settings'
|
import { getAISettings } from '@/app/actions/ai-settings'
|
||||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
import { generateFlashcardsFromNote, type FlashcardStyle } from '@/lib/flashcards/generate-flashcards'
|
import { generateFlashcardsFromNote, type FlashcardStyle } from '@/lib/flashcards/generate-flashcards'
|
||||||
import { stripHtmlToText } from '@/lib/flashcards/deck-utils'
|
import { stripHtmlToText } from '@/lib/flashcards/deck-utils'
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
return aiConsentForbiddenResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
const userSettings = await getAISettings(session.user.id)
|
const userSettings = await getAISettings(session.user.id)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import prisma from '@/lib/prisma'
|
|||||||
import { contentModerationService, type ModerationResult } from '@/lib/ai/services/content-moderation.service'
|
import { contentModerationService, type ModerationResult } from '@/lib/ai/services/content-moderation.service'
|
||||||
import { publishEnhanceService } from '@/lib/ai/services/publish-enhance.service'
|
import { publishEnhanceService } from '@/lib/ai/services/publish-enhance.service'
|
||||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
import { isPublishTemplateId } from '@/lib/publish/types'
|
import { isPublishTemplateId } from '@/lib/publish/types'
|
||||||
import { computePublishedSourceHash, renderPublishedTemplate, renderRewrittenTemplate } from '@/lib/publish/template-render'
|
import { computePublishedSourceHash, renderPublishedTemplate, renderRewrittenTemplate } from '@/lib/publish/template-render'
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
if (publishMode === 'ai') {
|
if (publishMode === 'ai') {
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
return aiConsentForbiddenResponse()
|
||||||
}
|
}
|
||||||
if (!template || !isPublishTemplateId(template)) {
|
if (!template || !isPublishTemplateId(template)) {
|
||||||
return NextResponse.json({ error: 'Invalid template' }, { status: 400 })
|
return NextResponse.json({ error: 'Invalid template' }, { status: 400 })
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
|
|||||||
if (pollRef.current) clearInterval(pollRef.current)
|
if (pollRef.current) clearInterval(pollRef.current)
|
||||||
pollRef.current = null
|
pollRef.current = null
|
||||||
setIsRunning(false)
|
setIsRunning(false)
|
||||||
toast.error(t('agents.toasts.runError', { error: data.error || t('agents.toasts.runFailed') }), {
|
toast.error(t('agents.toasts.runError', { error: data.errorKey ? t(data.errorKey) : (data.error || t('agents.toasts.runFailed')) }), {
|
||||||
id: toastId,
|
id: toastId,
|
||||||
description: '' // Clear the loading description
|
description: '' // Clear the loading description
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export function AutoLabelSuggestionDialog({
|
|||||||
onLabelsCreated()
|
onLabelsCreated()
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || t('ai.autoLabels.error'))
|
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.autoLabels.error')))
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create labels:', error)
|
console.error('Failed to create labels:', error)
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ export function BatchOrganizationDialog({
|
|||||||
onNotesMoved()
|
onNotesMoved()
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || t('ai.batchOrganization.applyFailed'))
|
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.batchOrganization.applyFailed')))
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to apply organization plan:', error)
|
console.error('Failed to apply organization plan:', error)
|
||||||
|
|||||||
@@ -345,7 +345,7 @@ export function ContextualAIChat({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
|
if (!res.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.genericError')))
|
||||||
const descs = data.descriptions || []
|
const descs = data.descriptions || []
|
||||||
let resultText = descs.map((d: any) =>
|
let resultText = descs.map((d: any) =>
|
||||||
noteImages.length > 1 ? `**Image ${d.index + 1}:** ${d.description}` : d.description
|
noteImages.length > 1 ? `**Image ${d.index + 1}:** ${d.description}` : d.description
|
||||||
@@ -381,7 +381,7 @@ export function ContextualAIChat({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
|
if (!res.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.genericError')))
|
||||||
const result = data[action.resultKey] || ''
|
const result = data[action.resultKey] || ''
|
||||||
setActionPreview({ label: t(action.i18nKey), text: result, asRichText: action.id === 'toRichText' })
|
setActionPreview({ label: t(action.i18nKey), text: result, asRichText: action.id === 'toRichText' })
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -457,7 +457,7 @@ export function ContextualAIChat({
|
|||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok || !data.success) {
|
if (!res.ok || !data.success) {
|
||||||
mToast.error(data.error || t('ai.errorShort'), { id: toastId })
|
mToast.error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.errorShort')), { id: toastId })
|
||||||
setGenerateLoading(null)
|
setGenerateLoading(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -577,7 +577,7 @@ export function ContextualAIChat({
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error || t('ai.resource.enrichError'))
|
if (!res.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.resource.enrichError')))
|
||||||
setResourcePreview({ text: data.enrichedContent, source: resourceMode })
|
setResourcePreview({ text: data.enrichedContent, source: resourceMode })
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
mToast.error(e.message || t('ai.resource.enrichError'))
|
mToast.error(e.message || t('ai.resource.enrichError'))
|
||||||
@@ -622,7 +622,7 @@ export function ContextualAIChat({
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
|
if (!res.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.genericError')))
|
||||||
setResourcePreview({ text: data.enrichedContent, source: mode })
|
setResourcePreview({ text: data.enrichedContent, source: mode })
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
mToast.error(e.message || t('ai.resource.enrichErrorShort'))
|
mToast.error(e.message || t('ai.resource.enrichErrorShort'))
|
||||||
|
|||||||
@@ -519,7 +519,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch('/api/ai/echo')
|
const res = await fetch('/api/ai/echo')
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
if (res.status === 403 && json.error === 'ai_consent_required') { await requestAiConsent(); return }
|
if (res.status === 403 && json.code === 'ai_consent_required') { await requestAiConsent(); return }
|
||||||
if (!res.ok) throw new Error(json.error)
|
if (!res.ok) throw new Error(json.error)
|
||||||
if (json.insight) {
|
if (json.insight) {
|
||||||
await reloadBriefingAndPaths()
|
await reloadBriefingAndPaths()
|
||||||
|
|||||||
@@ -64,11 +64,7 @@ export function FlashcardGenerateDialog({
|
|||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (data.errorKey) {
|
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('flashcards.generateFailed')))
|
||||||
toast.error(t(data.errorKey) || data.error)
|
|
||||||
} else {
|
|
||||||
toast.error(data.error || t('flashcards.generateFailed'))
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setCards(data.cards || [])
|
setCards(data.cards || [])
|
||||||
|
|||||||
@@ -353,7 +353,7 @@ export function RevisionView() {
|
|||||||
const res = await fetch(`/api/flashcards/decks/${deckId}`)
|
const res = await fetch(`/api/flashcards/decks/${deckId}`)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
toast.error(data.error || t('flashcards.loadDeckFailed'))
|
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('flashcards.loadDeckFailed')))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setActiveDeckId(deckId)
|
setActiveDeckId(deckId)
|
||||||
@@ -432,7 +432,7 @@ export function RevisionView() {
|
|||||||
const res = await fetch(`/api/flashcards/decks/${deckId}`)
|
const res = await fetch(`/api/flashcards/decks/${deckId}`)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
toast.error(data.error || t('flashcards.loadDeckFailed'))
|
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('flashcards.loadDeckFailed')))
|
||||||
setExpandedDeckId(null)
|
setExpandedDeckId(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function FusionModal({
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(data.error || 'Failed to generate fusion')
|
throw new Error(data.errorKey ? t(data.errorKey) : (data.error || 'Failed to generate fusion'))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.fusedNote) {
|
if (!data.fusedNote) {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
|
|
||||||
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
|
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, ChevronDown, Tag as TagIcon, X, Menu, LayoutGrid, List, Table, Columns3, CalendarDays, Wand2, Download, Upload, Globe } from 'lucide-react'
|
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, ChevronDown, Tag as TagIcon, X, Menu, LayoutGrid, List, Table, Columns3, CalendarDays, Wand2, Download, Upload, Globe, Presentation } from 'lucide-react'
|
||||||
import { emitNoteChange, NOTE_CHANGE_EVENT, type NoteChangeEvent } from '@/lib/note-change-sync'
|
import { emitNoteChange, NOTE_CHANGE_EVENT, type NoteChangeEvent } from '@/lib/note-change-sync'
|
||||||
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
||||||
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
|
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
|
||||||
@@ -67,6 +67,10 @@ const NotebookSiteDialog = dynamic(
|
|||||||
() => import('@/components/wizard/notebook-site-dialog').then(m => ({ default: m.NotebookSiteDialog })),
|
() => import('@/components/wizard/notebook-site-dialog').then(m => ({ default: m.NotebookSiteDialog })),
|
||||||
{ ssr: false }
|
{ ssr: false }
|
||||||
)
|
)
|
||||||
|
const NotebookSlidesDialog = dynamic(
|
||||||
|
() => import('@/components/wizard/notebook-slides-dialog').then(m => ({ default: m.NotebookSlidesDialog })),
|
||||||
|
{ ssr: false }
|
||||||
|
)
|
||||||
const StructuredViewsIntro = dynamic(
|
const StructuredViewsIntro = dynamic(
|
||||||
() => import('@/components/structured-views/structured-views-intro').then(m => ({ default: m.StructuredViewsIntro })),
|
() => import('@/components/structured-views/structured-views-intro').then(m => ({ default: m.StructuredViewsIntro })),
|
||||||
{ ssr: false }
|
{ ssr: false }
|
||||||
@@ -151,6 +155,7 @@ export function HomeClient({
|
|||||||
const [isEnablingStructured, setIsEnablingStructured] = useState(false)
|
const [isEnablingStructured, setIsEnablingStructured] = useState(false)
|
||||||
const [showStructuredWizard, setShowStructuredWizard] = useState(false)
|
const [showStructuredWizard, setShowStructuredWizard] = useState(false)
|
||||||
const [showNotebookSite, setShowNotebookSite] = useState(false)
|
const [showNotebookSite, setShowNotebookSite] = useState(false)
|
||||||
|
const [showNotebookSlides, setShowNotebookSlides] = useState(false)
|
||||||
const [aiMenuOpen, setAiMenuOpen] = useState(false)
|
const [aiMenuOpen, setAiMenuOpen] = useState(false)
|
||||||
const aiMenuRef = useRef<HTMLDivElement>(null)
|
const aiMenuRef = useRef<HTMLDivElement>(null)
|
||||||
const [showStudyPlanner, setShowStudyPlanner] = useState(false)
|
const [showStudyPlanner, setShowStudyPlanner] = useState(false)
|
||||||
@@ -179,7 +184,7 @@ export function HomeClient({
|
|||||||
toast.success(`${data.created} notes importées !`)
|
toast.success(`${data.created} notes importées !`)
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || 'Erreur')
|
toast.error(data.errorKey ? t(data.errorKey) : (data.error || 'Erreur'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
input.click()
|
input.click()
|
||||||
@@ -1136,6 +1141,11 @@ export function HomeClient({
|
|||||||
label: t('notebookSite.shortTitle') || 'Site web',
|
label: t('notebookSite.shortTitle') || 'Site web',
|
||||||
action: () => { setShowNotebookSite(true); setAiMenuOpen(false) },
|
action: () => { setShowNotebookSite(true); setAiMenuOpen(false) },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: <Presentation size={14} />,
|
||||||
|
label: t('notebook.slides') || 'Présentation',
|
||||||
|
action: () => { setShowNotebookSlides(true); setAiMenuOpen(false) },
|
||||||
|
},
|
||||||
].map((item, i) => (
|
].map((item, i) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
@@ -1452,6 +1462,14 @@ export function HomeClient({
|
|||||||
onClose={() => setShowNotebookSite(false)}
|
onClose={() => setShowNotebookSite(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showNotebookSlides && currentNotebook && (
|
||||||
|
<NotebookSlidesDialog
|
||||||
|
notebookId={currentNotebook.id}
|
||||||
|
notebookName={currentNotebook.name}
|
||||||
|
onClose={() => setShowNotebookSlides(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -426,7 +426,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const errorData = await response.json()
|
const errorData = await response.json()
|
||||||
throw new Error(errorData.error || t('ai.titleGenerationError'))
|
throw new Error(errorData.errorKey ? t(errorData.errorKey) : (errorData.error || t('ai.titleGenerationError')))
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
@@ -502,7 +502,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const errorData = await response.json()
|
const errorData = await response.json()
|
||||||
throw new Error(errorData.error || t('ai.reformulationError'))
|
throw new Error(errorData.errorKey ? t(errorData.errorKey) : (errorData.error || t('ai.reformulationError')))
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
@@ -542,7 +542,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
if (!response.ok) throw new Error(data.error || t('notes.clarifyFailed'))
|
if (!response.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('notes.clarifyFailed')))
|
||||||
setContentImmediate(data.reformulatedText || data.text)
|
setContentImmediate(data.reformulatedText || data.text)
|
||||||
toast.success(t('ai.reformulationApplied'))
|
toast.success(t('ai.reformulationApplied'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -575,7 +575,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
if (!response.ok) throw new Error(data.error || t('notes.shortenFailed'))
|
if (!response.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('notes.shortenFailed')))
|
||||||
setContentImmediate(data.reformulatedText || data.text)
|
setContentImmediate(data.reformulatedText || data.text)
|
||||||
toast.success(t('ai.reformulationApplied'))
|
toast.success(t('ai.reformulationApplied'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -608,7 +608,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
if (!response.ok) throw new Error(data.error || t('notes.improveFailed'))
|
if (!response.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('notes.improveFailed')))
|
||||||
setContentImmediate(data.reformulatedText || data.text)
|
setContentImmediate(data.reformulatedText || data.text)
|
||||||
toast.success(t('ai.reformulationApplied'))
|
toast.success(t('ai.reformulationApplied'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -642,7 +642,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
body: JSON.stringify({ text: content })
|
body: JSON.stringify({ text: content })
|
||||||
})
|
})
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
if (!response.ok) throw new Error(data.error || t('notes.transformFailed'))
|
if (!response.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('notes.transformFailed')))
|
||||||
|
|
||||||
setContentImmediate(data.transformedText)
|
setContentImmediate(data.transformedText)
|
||||||
setIsMarkdown(true)
|
setIsMarkdown(true)
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
|||||||
duration: 6000,
|
duration: 6000,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || t('general.error'))
|
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('general.error')))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('general.error'))
|
toast.error(t('general.error'))
|
||||||
@@ -408,7 +408,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
|||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
toast.dismiss(toastId)
|
toast.dismiss(toastId)
|
||||||
toast.error(data.error || t('general.error'))
|
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('general.error')))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.dismiss(toastId)
|
toast.dismiss(toastId)
|
||||||
@@ -437,7 +437,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
|||||||
setPublishOpen(false)
|
setPublishOpen(false)
|
||||||
} else {
|
} else {
|
||||||
const data = await res.json().catch(() => ({}))
|
const data = await res.json().catch(() => ({}))
|
||||||
toast.error(data.error || t('general.error'))
|
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('general.error')))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('general.error'))
|
toast.error(t('general.error'))
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function PublishDialog({ open, onClose, noteId, noteTitle, isPublic: init
|
|||||||
duration: 6000,
|
duration: 6000,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || 'Erreur')
|
toast.error(data.errorKey ? t(data.errorKey) : (data.error || 'Erreur'))
|
||||||
}
|
}
|
||||||
} catch { toast.error('Erreur') }
|
} catch { toast.error('Erreur') }
|
||||||
finally { setLoading(false) }
|
finally { setLoading(false) }
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function NotebookSummaryDialog({
|
|||||||
if (data.success && data.data) {
|
if (data.success && data.data) {
|
||||||
setSummary(data.data)
|
setSummary(data.data)
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || t('notebook.summaryError'))
|
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('notebook.summaryError')))
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export function PersonasPanel({ noteTitle, noteContent }: PersonasPanelProps) {
|
|||||||
body: JSON.stringify({ content: noteContent, title: noteTitle, personaId }),
|
body: JSON.stringify({ content: noteContent, title: noteTitle, personaId }),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error || t('common.error'))
|
if (!res.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('common.error')))
|
||||||
setResults(prev => new Map(prev).set(personaId, data))
|
setResults(prev => new Map(prev).set(personaId, data))
|
||||||
setExpanded(personaId)
|
setExpanded(personaId)
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -2005,7 +2005,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) { toast.error(data.error || 'Erreur'); return }
|
if (!res.ok) { toast.error(data.errorKey ? t(data.errorKey) : (data.error || 'Erreur')); return }
|
||||||
let html = data.reformulatedText || data.text || ''
|
let html = data.reformulatedText || data.text || ''
|
||||||
// Clean up excessive whitespace
|
// Clean up excessive whitespace
|
||||||
html = html
|
html = html
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void;
|
|||||||
} else if (data.errorKey === 'ai.quotaExceeded') {
|
} else if (data.errorKey === 'ai.quotaExceeded') {
|
||||||
toast.error(t('ai.quotaExceeded') || 'Quota IA dépassé')
|
toast.error(t('ai.quotaExceeded') || 'Quota IA dépassé')
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || 'Erreur')
|
toast.error(data.errorKey ? t(data.errorKey) : (data.error || 'Erreur'))
|
||||||
}
|
}
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export function NotebookSiteDialog({ notebookId, notebookName, onClose }: Notebo
|
|||||||
body: JSON.stringify({ selectedNoteIds: selected, template, description }),
|
body: JSON.stringify({ selectedNoteIds: selected, template, description }),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) { toast.error(data.error || 'Erreur'); setStep('selection'); return }
|
if (!res.ok) { toast.error(data.errorKey ? t(data.errorKey) : (data.error || 'Erreur')); setStep('selection'); return }
|
||||||
setExistingSlug(data.slug)
|
setExistingSlug(data.slug)
|
||||||
setSiteUrl(data.url)
|
setSiteUrl(data.url)
|
||||||
setStep('done')
|
setStep('done')
|
||||||
|
|||||||
237
memento-note/components/wizard/notebook-slides-dialog.tsx
Normal file
237
memento-note/components/wizard/notebook-slides-dialog.tsx
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { Presentation, X } from 'lucide-react'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
|
const THEMES = [
|
||||||
|
'auto',
|
||||||
|
'Architectural SaaS',
|
||||||
|
'Midnight Cathedral',
|
||||||
|
'Aurora Borealis',
|
||||||
|
'Tokyo Neon',
|
||||||
|
'Sunlit Gallery',
|
||||||
|
'Clinical Precision',
|
||||||
|
'Venture Pitch',
|
||||||
|
'Forest Floor',
|
||||||
|
'Steel & Glass',
|
||||||
|
'Cyberpunk Terminal',
|
||||||
|
'Editorial Ink',
|
||||||
|
'Coastal Morning',
|
||||||
|
'Paper Studio',
|
||||||
|
]
|
||||||
|
|
||||||
|
const PURPOSES = [
|
||||||
|
{ value: 'auto', key: 'ai.generate.purposeAuto' },
|
||||||
|
{ value: 'course', key: 'ai.generate.purposeCourse' },
|
||||||
|
{ value: 'board', key: 'ai.generate.purposeBoard' },
|
||||||
|
{ value: 'project', key: 'ai.generate.purposeProject' },
|
||||||
|
{ value: 'strategy', key: 'ai.generate.purposeStrategy' },
|
||||||
|
{ value: 'pitch', key: 'ai.generate.purposePitch' },
|
||||||
|
{ value: 'summary', key: 'ai.generate.purposeSummary' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const STATUS_KEYS = [
|
||||||
|
'notebook.slidesStatus1',
|
||||||
|
'notebook.slidesStatus2',
|
||||||
|
'notebook.slidesStatus3',
|
||||||
|
'notebook.slidesStatus4',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function NotebookSlidesDialog({
|
||||||
|
notebookId,
|
||||||
|
notebookName,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
notebookId: string
|
||||||
|
notebookName: string
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const { t, language } = useLanguage()
|
||||||
|
const router = useRouter()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [theme, setTheme] = useState('auto')
|
||||||
|
const [purpose, setPurpose] = useState('auto')
|
||||||
|
const [progress, setProgress] = useState(0)
|
||||||
|
const timerRef = useRef<ReturnType<typeof setInterval>>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setProgress(0)
|
||||||
|
|
||||||
|
let elapsed = 0
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
elapsed += 0.6
|
||||||
|
setProgress(() => {
|
||||||
|
const target = 85 * (1 - Math.exp(-elapsed / 15))
|
||||||
|
return Math.min(target, 85)
|
||||||
|
})
|
||||||
|
}, 600)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai/notebook-slides', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
notebookId,
|
||||||
|
theme,
|
||||||
|
purpose,
|
||||||
|
language: language === 'fr' ? 'fr' : 'en',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current)
|
||||||
|
setProgress(100)
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
toast.error(
|
||||||
|
data.errorKey === 'ai.featureLocked'
|
||||||
|
? (t('ai.featureLocked') || 'Plan requis')
|
||||||
|
: (data.error || 'Erreur'),
|
||||||
|
)
|
||||||
|
} else if (data.success && data.canvasId) {
|
||||||
|
window.dispatchEvent(new Event('ai-usage-changed'))
|
||||||
|
toast.success(t('notebook.slidesReady') || `Présentation générée (${data.slideCount} slides)`)
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push(`/lab?canvas=${data.canvasId}`)
|
||||||
|
onClose()
|
||||||
|
}, 400)
|
||||||
|
} else {
|
||||||
|
toast.error(data.errorKey ? t(data.errorKey) : (data.error || 'Erreur lors de la génération'))
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current)
|
||||||
|
toast.error(e.message || 'Erreur')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusIdx = Math.min(Math.floor(progress / 22), STATUS_KEYS.length - 1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[300] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
|
||||||
|
dir="auto"
|
||||||
|
onClick={loading ? undefined : onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-lg rounded-2xl border border-border bg-card shadow-2xl overflow-hidden flex flex-col"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border/50 bg-brand-accent/5 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Presentation className="h-5 w-5 text-brand-accent" />
|
||||||
|
<h2 className="text-base font-semibold">
|
||||||
|
{t('notebook.slides') || 'Présentation'}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={loading}
|
||||||
|
className="p-1 rounded-lg hover:bg-muted text-muted-foreground disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-5">
|
||||||
|
{!loading && (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('notebook.slidesDescription') || `Génère une présentation à partir des notes du carnet « ${notebookName} ».`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<span className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-foreground px-1">
|
||||||
|
{t('ai.generate.theme') || 'Thème'}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={theme}
|
||||||
|
onChange={(e) => setTheme(e.target.value)}
|
||||||
|
className="w-full bg-card/60 border border-border rounded-lg px-3 py-2 text-sm outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground"
|
||||||
|
>
|
||||||
|
{THEMES.map((th) => (
|
||||||
|
<option key={th} value={th}>
|
||||||
|
{th === 'auto' ? (t('ai.generate.themeAuto') || 'Auto') : th}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<span className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-foreground px-1">
|
||||||
|
{t('ai.generate.purpose') || 'Type de deck'}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={purpose}
|
||||||
|
onChange={(e) => setPurpose(e.target.value)}
|
||||||
|
className="w-full bg-card/60 border border-border rounded-lg px-3 py-2 text-sm outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground"
|
||||||
|
>
|
||||||
|
{PURPOSES.map((p) => (
|
||||||
|
<option key={p.value} value={p.value}>
|
||||||
|
{t(p.key) || p.value}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleGenerate}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-3 text-sm rounded-lg bg-brand-accent text-white hover:bg-brand-accent/90 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
<Presentation className="h-4 w-4" />
|
||||||
|
{t('notebook.slidesGenerate') || 'Générer la présentation'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="space-y-5 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="w-full h-2.5 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-brand-accent to-brand-accent/70 rounded-full transition-all duration-700 ease-out"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-muted-foreground animate-pulse">
|
||||||
|
{t(STATUS_KEYS[statusIdx])}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-mono font-bold text-brand-accent tabular-nums">
|
||||||
|
{Math.round(progress)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between px-2">
|
||||||
|
{STATUS_KEYS.map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`h-1 flex-1 mx-0.5 rounded-full transition-colors duration-300 ${
|
||||||
|
i <= statusIdx ? 'bg-brand-accent' : 'bg-muted'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[10px] text-muted-foreground/60 text-center">
|
||||||
|
{t('notebook.slidesGeneratingHint') || 'Outline + rédaction des slides'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -25,6 +25,19 @@ export async function hasUserAiConsent(): Promise<boolean> {
|
|||||||
return settings?.aiProcessingConsent ?? false
|
return settings?.aiProcessingConsent ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
export function aiConsentForbiddenResponse() {
|
const DEFAULT_AI_CONSENT_MESSAGE = 'AI processing consent is required to use this feature.'
|
||||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
const AI_CONSENT_ERROR_KEY = 'consent.ai.requiredToast'
|
||||||
|
|
||||||
|
export function aiConsentForbiddenResponse(message?: string) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: message ?? DEFAULT_AI_CONSENT_MESSAGE, code: 'ai_consent_required', errorKey: AI_CONSENT_ERROR_KEY },
|
||||||
|
{ status: 403 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aiConsentForbiddenJson(message?: string) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: message ?? DEFAULT_AI_CONSENT_MESSAGE, code: 'ai_consent_required', errorKey: AI_CONSENT_ERROR_KEY },
|
||||||
|
{ status: 403 }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3806,7 +3806,8 @@
|
|||||||
"revoked": "Consent not granted",
|
"revoked": "Consent not granted",
|
||||||
"revokedToast": "AI processing consent successfully revoked.",
|
"revokedToast": "AI processing consent successfully revoked.",
|
||||||
"complianceBadge": "GDPR compliance",
|
"complianceBadge": "GDPR compliance",
|
||||||
"auditFailed": "Could not record your consent. Please try again."
|
"auditFailed": "Could not record your consent. Please try again.",
|
||||||
|
"requiredToast": "Enable AI processing in Settings → AI to use this feature."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
|
|||||||
@@ -3812,7 +3812,8 @@
|
|||||||
"revoked": "Consentement non accordé",
|
"revoked": "Consentement non accordé",
|
||||||
"revokedToast": "Consentement IA révoqué avec succès.",
|
"revokedToast": "Consentement IA révoqué avec succès.",
|
||||||
"complianceBadge": "Conformité RGPD",
|
"complianceBadge": "Conformité RGPD",
|
||||||
"auditFailed": "Impossible d'enregistrer votre consentement. Réessayez."
|
"auditFailed": "Impossible d'enregistrer votre consentement. Réessayez.",
|
||||||
|
"requiredToast": "Activez le traitement IA dans Paramètres → IA pour utiliser cette fonctionnalité."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
|
|||||||
Reference in New Issue
Block a user