fix: unify theme system - fix theme switching persistence

- Unified localStorage key to 'theme-preference' across all components
- Fixed header.tsx using wrong localStorage key ('theme' instead of 'theme-preference')
- Added localStorage hybrid persistence for instant theme changes
- Removed router.refresh() which was causing stale data revert
- Replaced Blue theme with Sepia
- Consolidated auth() calls to prevent race conditions
- Updated UserSettingsData types to include all themes
This commit is contained in:
2026-01-18 22:33:41 +01:00
parent ef60dafd73
commit ddb67ba9e5
306 changed files with 59580 additions and 6063 deletions

View File

@@ -14,20 +14,29 @@ export type UserAISettingsData = {
preferredLanguage?: 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl'
demoMode?: boolean
showRecentNotes?: boolean
emailNotifications?: boolean
desktopNotifications?: boolean
anonymousAnalytics?: boolean
theme?: 'light' | 'dark' | 'auto'
fontSize?: 'small' | 'medium' | 'large'
}
/**
* Update AI settings for the current user
*/
export async function updateAISettings(settings: UserAISettingsData) {
console.log('[updateAISettings] Started with:', JSON.stringify(settings, null, 2))
const session = await auth()
console.log('[updateAISettings] Session User ID:', session?.user?.id)
if (!session?.user?.id) {
console.error('[updateAISettings] Unauthorized: No session or user ID')
throw new Error('Unauthorized')
}
try {
// Upsert settings (create if not exists, update if exists)
await prisma.userAISettings.upsert({
const result = await prisma.userAISettings.upsert({
where: { userId: session.user.id },
create: {
userId: session.user.id,
@@ -35,6 +44,7 @@ export async function updateAISettings(settings: UserAISettingsData) {
},
update: settings
})
console.log('[updateAISettings] Database upsert successful:', result)
revalidatePath('/settings/ai')
revalidatePath('/')
@@ -49,11 +59,16 @@ export async function updateAISettings(settings: UserAISettingsData) {
/**
* Get AI settings for the current user
*/
export async function getAISettings() {
const session = await auth()
export async function getAISettings(userId?: string) {
let id = userId
if (!id) {
const session = await auth()
id = session?.user?.id
}
// Return defaults for non-logged-in users
if (!session?.user?.id) {
if (!id) {
return {
titleSuggestions: true,
semanticSearch: true,
@@ -63,33 +78,21 @@ export async function getAISettings() {
aiProvider: 'auto' as const,
preferredLanguage: 'auto' as const,
demoMode: false,
showRecentNotes: false
showRecentNotes: false,
emailNotifications: false,
desktopNotifications: false,
anonymousAnalytics: false,
theme: 'light' as const,
fontSize: 'medium' as const
}
}
try {
// Use raw SQL query to get showRecentNotes until Prisma client is regenerated
const settingsRaw = await prisma.$queryRaw<Array<{
titleSuggestions: number
semanticSearch: number
paragraphRefactor: number
memoryEcho: number
memoryEchoFrequency: string
aiProvider: string
preferredLanguage: string
fontSize: string
demoMode: number
showRecentNotes: number
}>>`
SELECT titleSuggestions, semanticSearch, paragraphRefactor, memoryEcho,
memoryEchoFrequency, aiProvider, preferredLanguage, fontSize,
demoMode, showRecentNotes
FROM UserAISettings
WHERE userId = ${session.user.id}
`
const settings = await prisma.userAISettings.findUnique({
where: { userId: id }
})
// Return settings or defaults if not found
if (!settingsRaw || settingsRaw.length === 0) {
if (!settings) {
return {
titleSuggestions: true,
semanticSearch: true,
@@ -99,28 +102,30 @@ export async function getAISettings() {
aiProvider: 'auto' as const,
preferredLanguage: 'auto' as const,
demoMode: false,
showRecentNotes: false
showRecentNotes: false,
emailNotifications: false,
desktopNotifications: false,
anonymousAnalytics: false,
theme: 'light' as const,
fontSize: 'medium' as const
}
}
const settings = settingsRaw[0]
// Type-cast database values to proper union types
// Handle NULL values - SQLite can return NULL for showRecentNotes if column was added later
const showRecentNotesValue = settings.showRecentNotes !== null && settings.showRecentNotes !== undefined
? settings.showRecentNotes === 1
: false
return {
titleSuggestions: settings.titleSuggestions === 1,
semanticSearch: settings.semanticSearch === 1,
paragraphRefactor: settings.paragraphRefactor === 1,
memoryEcho: settings.memoryEcho === 1,
titleSuggestions: settings.titleSuggestions,
semanticSearch: settings.semanticSearch,
paragraphRefactor: settings.paragraphRefactor,
memoryEcho: settings.memoryEcho,
memoryEchoFrequency: (settings.memoryEchoFrequency || 'daily') as 'daily' | 'weekly' | 'custom',
aiProvider: (settings.aiProvider || 'auto') as 'auto' | 'openai' | 'ollama',
preferredLanguage: (settings.preferredLanguage || 'auto') as 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl',
demoMode: settings.demoMode === 1,
showRecentNotes: showRecentNotesValue
demoMode: settings.demoMode,
showRecentNotes: settings.showRecentNotes,
emailNotifications: settings.emailNotifications,
desktopNotifications: settings.desktopNotifications,
anonymousAnalytics: settings.anonymousAnalytics,
// theme: 'light' as const, // REMOVED: Should not be handled here or hardcoded
fontSize: (settings.fontSize || 'medium') as 'small' | 'medium' | 'large'
}
} catch (error) {
console.error('Error getting AI settings:', error)
@@ -134,7 +139,12 @@ export async function getAISettings() {
aiProvider: 'auto' as const,
preferredLanguage: 'auto' as const,
demoMode: false,
showRecentNotes: false
showRecentNotes: false,
emailNotifications: false,
desktopNotifications: false,
anonymousAnalytics: false,
theme: 'light' as const,
fontSize: 'medium' as const
}
}
}

View File

@@ -6,7 +6,8 @@ import { Note, CheckItem } from '@/lib/types'
import { auth } from '@/auth'
import { getAIProvider } from '@/lib/ai/factory'
import { cosineSimilarity, validateEmbedding, calculateRRFK, detectQueryType, getSearchWeights } from '@/lib/utils'
import { getSystemConfig, getConfigNumber, SEARCH_DEFAULTS } from '@/lib/config'
import { getSystemConfig, getConfigNumber, getConfigBoolean, SEARCH_DEFAULTS } from '@/lib/config'
import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service'
// Helper function to parse JSON strings from database
function parseNote(dbNote: any): Note {
@@ -337,6 +338,41 @@ export async function createNote(data: {
console.error('Embedding generation failed:', e);
}
// AUTO-LABELING: If no labels provided and auto-labeling is enabled, suggest labels
let labelsToUse = data.labels || null;
if ((!labelsToUse || labelsToUse.length === 0) && data.notebookId) {
try {
const autoLabelingEnabled = await getConfigBoolean('AUTO_LABELING_ENABLED', true);
const autoLabelingConfidence = await getConfigNumber('AUTO_LABELING_CONFIDENCE_THRESHOLD', 70);
if (autoLabelingEnabled) {
console.log('[AUTO-LABELING] Generating suggestions for new note in notebook:', data.notebookId);
const suggestions = await contextualAutoTagService.suggestLabels(
data.content,
data.notebookId,
session.user.id
);
// Apply suggestions with confidence >= threshold
const appliedLabels = suggestions
.filter(s => s.confidence >= autoLabelingConfidence)
.map(s => s.label);
if (appliedLabels.length > 0) {
labelsToUse = appliedLabels;
console.log(`[AUTO-LABELING] Applied ${appliedLabels.length} labels:`, appliedLabels);
} else {
console.log('[AUTO-LABELING] No suggestions met confidence threshold');
}
} else {
console.log('[AUTO-LABELING] Disabled in config');
}
} catch (error) {
console.error('[AUTO-LABELING] Failed to suggest labels:', error);
// Continue without auto-labeling on error
}
}
const note = await prisma.note.create({
data: {
userId: session.user.id,
@@ -345,7 +381,7 @@ export async function createNote(data: {
color: data.color || 'default',
type: data.type || 'text',
checkItems: data.checkItems ? JSON.stringify(data.checkItems) : null,
labels: data.labels ? JSON.stringify(data.labels) : null,
labels: labelsToUse ? JSON.stringify(labelsToUse) : null,
images: data.images ? JSON.stringify(data.images) : null,
links: data.links ? JSON.stringify(data.links) : null,
isArchived: data.isArchived || false,
@@ -360,8 +396,8 @@ export async function createNote(data: {
})
// Sync labels to ensure Label records exist
if (data.labels && data.labels.length > 0) {
await syncLabels(session.user.id, data.labels)
if (labelsToUse && labelsToUse.length > 0) {
await syncLabels(session.user.id, labelsToUse)
}
// Revalidate main page (handles both inbox and notebook views via query params)

View File

@@ -0,0 +1,232 @@
'use server'
import { revalidatePath } from 'next/cache'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
const ProfileSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email().optional(), // Email change might require verification logic, keeping it simple for now or read-only
})
const PasswordSchema = z.object({
currentPassword: z.string().min(1, "Current password is required"),
newPassword: z.string().min(6, "New password must be at least 6 characters"),
confirmPassword: z.string().min(6, "Confirm password must be at least 6 characters"),
}).refine((data) => data.newPassword === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
})
export async function updateProfile(data: { name: string }) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const validated = ProfileSchema.safeParse(data)
if (!validated.success) {
return { error: validated.error.flatten().fieldErrors }
}
try {
await prisma.user.update({
where: { id: session.user.id },
data: { name: validated.data.name },
})
revalidatePath('/settings/profile')
return { success: true }
} catch (error) {
return { error: { _form: ['Failed to update profile'] } }
}
}
export async function changePassword(formData: FormData) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const rawData = {
currentPassword: formData.get('currentPassword'),
newPassword: formData.get('newPassword'),
confirmPassword: formData.get('confirmPassword'),
}
const validated = PasswordSchema.safeParse(rawData)
if (!validated.success) {
return { error: validated.error.flatten().fieldErrors }
}
const { currentPassword, newPassword } = validated.data
const user = await prisma.user.findUnique({
where: { id: session.user.id },
})
if (!user || !user.password) {
return { error: { _form: ['User not found'] } }
}
const passwordsMatch = await bcrypt.compare(currentPassword, user.password)
if (!passwordsMatch) {
return { error: { currentPassword: ['Incorrect current password'] } }
}
const hashedPassword = await bcrypt.hash(newPassword, 10)
try {
await prisma.user.update({
where: { id: session.user.id },
data: { password: hashedPassword },
})
return { success: true }
} catch (error) {
return { error: { _form: ['Failed to change password'] } }
}
}
export async function updateTheme(theme: string) {
const session = await auth()
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
await prisma.user.update({
where: { id: session.user.id },
data: { theme },
})
revalidatePath('/')
revalidatePath('/settings/profile')
return { success: true }
} catch (error) {
return { error: 'Failed to update theme' }
}
}
export async function updateLanguage(language: string) {
const session = await auth()
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
// Update or create UserAISettings with the preferred language
await prisma.userAISettings.upsert({
where: { userId: session.user.id },
create: {
userId: session.user.id,
preferredLanguage: language,
},
update: {
preferredLanguage: language,
},
})
// Note: The language will be applied on next page load
// The client component should handle updating localStorage and reloading
revalidatePath('/')
revalidatePath('/settings/profile')
return { success: true, language }
} catch (error) {
console.error('Failed to update language:', error)
return { error: 'Failed to update language' }
}
}
export async function updateFontSize(fontSize: string) {
const session = await auth()
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
// Check if UserAISettings exists
const existing = await prisma.userAISettings.findUnique({
where: { userId: session.user.id }
})
let result
if (existing) {
// Update existing - only update fontSize field
result = await prisma.userAISettings.update({
where: { userId: session.user.id },
data: { fontSize: fontSize }
})
} else {
// Create new with all required fields
result = await prisma.userAISettings.create({
data: {
userId: session.user.id,
fontSize: fontSize,
// Set default values for required fields
titleSuggestions: true,
semanticSearch: true,
paragraphRefactor: true,
memoryEcho: true,
memoryEchoFrequency: 'daily',
aiProvider: 'auto',
preferredLanguage: 'auto',
showRecentNotes: false
}
})
}
revalidatePath('/')
revalidatePath('/settings/profile')
return { success: true, fontSize }
} catch (error) {
console.error('[updateFontSize] Failed to update font size:', error)
return { error: 'Failed to update font size' }
}
}
export async function updateShowRecentNotes(showRecentNotes: boolean) {
const session = await auth()
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
// Use EXACT same pattern as updateFontSize which works
const existing = await prisma.userAISettings.findUnique({
where: { userId: session.user.id }
})
if (existing) {
// Try Prisma client first, fallback to raw SQL if field doesn't exist in client
try {
await prisma.userAISettings.update({
where: { userId: session.user.id },
data: { showRecentNotes: showRecentNotes } as any
})
} catch (prismaError: any) {
// If Prisma client doesn't know about showRecentNotes, use raw SQL
if (prismaError?.message?.includes('Unknown argument') || prismaError?.code === 'P2009') {
const value = showRecentNotes ? 1 : 0
await prisma.$executeRaw`
UPDATE UserAISettings
SET showRecentNotes = ${value}
WHERE userId = ${session.user.id}
`
} else {
throw prismaError
}
}
} else {
// Create new - same as updateFontSize
await prisma.userAISettings.create({
data: {
userId: session.user.id,
titleSuggestions: true,
semanticSearch: true,
paragraphRefactor: true,
memoryEcho: true,
memoryEchoFrequency: 'daily',
aiProvider: 'auto',
preferredLanguage: 'auto',
fontSize: 'medium',
showRecentNotes: showRecentNotes
} as any
})
}
revalidatePath('/')
revalidatePath('/settings/profile')
return { success: true, showRecentNotes }
} catch (error) {
console.error('[updateShowRecentNotes] Failed:', error)
return { error: 'Failed to update show recent notes setting' }
}
}

View File

@@ -0,0 +1,232 @@
'use server'
import { revalidatePath } from 'next/cache'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
const ProfileSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email().optional(), // Email change might require verification logic, keeping it simple for now or read-only
})
const PasswordSchema = z.object({
currentPassword: z.string().min(1, "Current password is required"),
newPassword: z.string().min(6, "New password must be at least 6 characters"),
confirmPassword: z.string().min(6, "Confirm password must be at least 6 characters"),
}).refine((data) => data.newPassword === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
})
export async function updateProfile(data: { name: string }) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const validated = ProfileSchema.safeParse(data)
if (!validated.success) {
return { error: validated.error.flatten().fieldErrors }
}
try {
await prisma.user.update({
where: { id: session.user.id },
data: { name: validated.data.name },
})
revalidatePath('/settings/profile')
return { success: true }
} catch (error) {
return { error: { _form: ['Failed to update profile'] } }
}
}
export async function changePassword(formData: FormData) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const rawData = {
currentPassword: formData.get('currentPassword'),
newPassword: formData.get('newPassword'),
confirmPassword: formData.get('confirmPassword'),
}
const validated = PasswordSchema.safeParse(rawData)
if (!validated.success) {
return { error: validated.error.flatten().fieldErrors }
}
const { currentPassword, newPassword } = validated.data
const user = await prisma.user.findUnique({
where: { id: session.user.id },
})
if (!user || !user.password) {
return { error: { _form: ['User not found'] } }
}
const passwordsMatch = await bcrypt.compare(currentPassword, user.password)
if (!passwordsMatch) {
return { error: { currentPassword: ['Incorrect current password'] } }
}
const hashedPassword = await bcrypt.hash(newPassword, 10)
try {
await prisma.user.update({
where: { id: session.user.id },
data: { password: hashedPassword },
})
return { success: true }
} catch (error) {
return { error: { _form: ['Failed to change password'] } }
}
}
export async function updateTheme(theme: string) {
const session = await auth()
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
await prisma.user.update({
where: { id: session.user.id },
data: { theme },
})
revalidatePath('/')
revalidatePath('/settings/profile')
return { success: true }
} catch (error) {
return { error: 'Failed to update theme' }
}
}
export async function updateLanguage(language: string) {
const session = await auth()
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
// Update or create UserAISettings with the preferred language
await prisma.userAISettings.upsert({
where: { userId: session.user.id },
create: {
userId: session.user.id,
preferredLanguage: language,
},
update: {
preferredLanguage: language,
},
})
// Note: The language will be applied on next page load
// The client component should handle updating localStorage and reloading
revalidatePath('/')
revalidatePath('/settings/profile')
return { success: true, language }
} catch (error) {
console.error('Failed to update language:', error)
return { error: 'Failed to update language' }
}
}
export async function updateFontSize(fontSize: string) {
const session = await auth()
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
// Check if UserAISettings exists
const existing = await prisma.userAISettings.findUnique({
where: { userId: session.user.id }
})
let result
if (existing) {
// Update existing - only update fontSize field
result = await prisma.userAISettings.update({
where: { userId: session.user.id },
data: { fontSize: fontSize }
})
} else {
// Create new with all required fields
result = await prisma.userAISettings.create({
data: {
userId: session.user.id,
fontSize: fontSize,
// Set default values for required fields
titleSuggestions: true,
semanticSearch: true,
paragraphRefactor: true,
memoryEcho: true,
memoryEchoFrequency: 'daily',
aiProvider: 'auto',
preferredLanguage: 'auto',
showRecentNotes: false
}
})
}
revalidatePath('/')
revalidatePath('/settings/profile')
return { success: true, fontSize }
} catch (error) {
console.error('[updateFontSize] Failed to update font size:', error)
return { error: 'Failed to update font size' }
}
}
export async function updateShowRecentNotes(showRecentNotes: boolean) {
const session = await auth()
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
// Use EXACT same pattern as updateFontSize which works
const existing = await prisma.userAISettings.findUnique({
where: { userId: session.user.id }
})
if (existing) {
// Try Prisma client first, fallback to raw SQL if field doesn't exist in client
try {
await prisma.userAISettings.update({
where: { userId: session.user.id },
data: { showRecentNotes: showRecentNotes } as any
})
} catch (prismaError: any) {
// If Prisma client doesn't know about showRecentNotes, use raw SQL
if (prismaError?.message?.includes('Unknown argument') || prismaError?.code === 'P2009') {
const value = showRecentNotes ? 1 : 0
await prisma.$executeRaw`
UPDATE UserAISettings
SET showRecentNotes = ${value}
WHERE userId = ${session.user.id}
`
} else {
throw prismaError
}
}
} else {
// Create new - same as updateFontSize
await prisma.userAISettings.create({
data: {
userId: session.user.id,
titleSuggestions: true,
semanticSearch: true,
paragraphRefactor: true,
memoryEcho: true,
memoryEchoFrequency: 'daily',
aiProvider: 'auto',
preferredLanguage: 'auto',
fontSize: 'medium',
showRecentNotes: showRecentNotes
} as any
})
}
revalidatePath('/')
revalidatePath('/settings/profile')
return { success: true, showRecentNotes }
} catch (error) {
console.error('[updateShowRecentNotes] Failed:', error)
return { error: 'Failed to update show recent notes setting' }
}
}

View File

@@ -146,7 +146,7 @@ export async function updateFontSize(fontSize: string) {
where: { userId: session.user.id },
data: { fontSize: fontSize }
})
} else {
} else {
// Create new with all required fields
result = await prisma.userAISettings.create({
data: {
@@ -163,7 +163,7 @@ export async function updateFontSize(fontSize: string) {
showRecentNotes: false
}
})
}
}
revalidatePath('/')
revalidatePath('/settings/profile')
@@ -179,49 +179,18 @@ export async function updateShowRecentNotes(showRecentNotes: boolean) {
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
// Use EXACT same pattern as updateFontSize which works
const existing = await prisma.userAISettings.findUnique({
where: { userId: session.user.id }
await prisma.userAISettings.upsert({
where: { userId: session.user.id },
create: {
userId: session.user.id,
showRecentNotes,
// Defaults will be used for other fields
},
update: {
showRecentNotes,
},
})
if (existing) {
// Try Prisma client first, fallback to raw SQL if field doesn't exist in client
try {
await prisma.userAISettings.update({
where: { userId: session.user.id },
data: { showRecentNotes: showRecentNotes } as any
})
} catch (prismaError: any) {
// If Prisma client doesn't know about showRecentNotes, use raw SQL
if (prismaError?.message?.includes('Unknown argument') || prismaError?.code === 'P2009') {
const value = showRecentNotes ? 1 : 0
await prisma.$executeRaw`
UPDATE UserAISettings
SET showRecentNotes = ${value}
WHERE userId = ${session.user.id}
`
} else {
throw prismaError
}
}
} else {
// Create new - same as updateFontSize
await prisma.userAISettings.create({
data: {
userId: session.user.id,
titleSuggestions: true,
semanticSearch: true,
paragraphRefactor: true,
memoryEcho: true,
memoryEchoFrequency: 'daily',
aiProvider: 'auto',
preferredLanguage: 'auto',
fontSize: 'medium',
showRecentNotes: showRecentNotes
} as any
})
}
revalidatePath('/')
revalidatePath('/settings/profile')
return { success: true, showRecentNotes }

View File

@@ -0,0 +1,71 @@
'use server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
export type UserSettingsData = {
theme?: 'light' | 'dark' | 'auto' | 'sepia' | 'midnight'
}
/**
* Update user settings (theme, etc.)
*/
export async function updateUserSettings(settings: UserSettingsData) {
console.log('[updateUserSettings] Started with:', settings)
const session = await auth()
if (!session?.user?.id) {
console.error('[updateUserSettings] Unauthorized')
throw new Error('Unauthorized')
}
try {
const result = await prisma.user.update({
where: { id: session.user.id },
data: settings
})
console.log('[updateUserSettings] Success:', result)
revalidatePath('/', 'layout')
return { success: true }
} catch (error) {
console.error('Error updating user settings:', error)
throw new Error('Failed to update user settings')
}
}
/**
* Get user settings for current user
*/
export async function getUserSettings(userId?: string) {
let id = userId
if (!id) {
const session = await auth()
id = session?.user?.id
}
if (!id) {
return {
theme: 'light' as const
}
}
try {
const user = await prisma.user.findUnique({
where: { id },
select: { theme: true }
})
return {
theme: (user?.theme || 'light') as 'light' | 'dark' | 'auto'
}
} catch (error) {
console.error('Error getting user settings:', error)
return {
theme: 'light' as const
}
}
}