fix: improve note interactions and markdown LaTeX support

## Bug Fixes

### Note Card Actions
- Fix broken size change functionality (missing state declaration)
- Implement React 19 useOptimistic for instant UI feedback
- Add startTransition for non-blocking updates
- Ensure smooth animations without page refresh
- All note actions now work: pin, archive, color, size, checklist

### Markdown LaTeX Rendering
- Add remark-math and rehype-katex plugins
- Support inline equations with dollar sign syntax
- Support block equations with double dollar sign syntax
- Import KaTeX CSS for proper styling
- Equations now render correctly instead of showing raw LaTeX

## Technical Details

- Replace undefined currentNote references with optimistic state
- Add optimistic updates before server actions for instant feedback
- Use router.refresh() in transitions for smart cache invalidation
- Install remark-math, rehype-katex, and katex packages

## Testing

- Build passes successfully with no TypeScript errors
- Dev server hot-reloads changes correctly
This commit is contained in:
2026-01-09 22:13:49 +01:00
parent 3c4b9d6176
commit 640fcb26f7
218 changed files with 51363 additions and 902 deletions

View File

@@ -0,0 +1,77 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { forgotPassword } from '@/app/actions/auth-reset'
import { toast } from 'sonner'
import Link from 'next/link'
export default function ForgotPasswordPage() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [isDone, setIsSubmittingDone] = useState(false)
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setIsSubmitting(true)
const formData = new FormData(e.currentTarget)
const result = await forgotPassword(formData.get('email') as string)
setIsSubmitting(false)
if (result.error) {
toast.error(result.error)
} else {
setIsSubmittingDone(true)
}
}
if (isDone) {
return (
<main className="flex items-center justify-center md:h-screen p-4">
<Card className="w-full max-w-[400px]">
<CardHeader>
<CardTitle>Check your email</CardTitle>
<CardDescription>
We have sent a password reset link to your email address if it exists in our system.
</CardDescription>
</CardHeader>
<CardFooter>
<Link href="/login" className="w-full">
<Button variant="outline" className="w-full">Return to Login</Button>
</Link>
</CardFooter>
</Card>
</main>
)
}
return (
<main className="flex items-center justify-center md:h-screen p-4">
<Card className="w-full max-w-[400px]">
<CardHeader>
<CardTitle>Forgot Password</CardTitle>
<CardDescription>
Enter your email address and we'll send you a link to reset your password.
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium">Email</label>
<Input id="email" name="email" type="email" required placeholder="name@example.com" />
</div>
</CardContent>
<CardFooter className="flex flex-col gap-4">
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? 'Sending...' : 'Send Reset Link'}
</Button>
<Link href="/login" className="text-sm text-center underline">
Back to login
</Link>
</CardFooter>
</form>
</Card>
</main>
)
}

View File

@@ -0,0 +1,13 @@
export default function AuthLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-zinc-950">
<div className="w-full max-w-md p-4">
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { LoginForm } from '@/components/login-form';
import { getSystemConfig } from '@/lib/config';
export default async function LoginPage() {
const config = await getSystemConfig();
// Default to true unless explicitly disabled in DB or Env
const allowRegister = config.ALLOW_REGISTRATION !== 'false' && process.env.ALLOW_REGISTRATION !== 'false';
return (
<main className="flex items-center justify-center md:h-screen">
<div className="relative mx-auto flex w-full max-w-[400px] flex-col space-y-2.5 p-4 md:-mt-32">
<LoginForm allowRegister={allowRegister} />
</div>
</main>
);
}

View File

@@ -0,0 +1,20 @@
import { RegisterForm } from '@/components/register-form';
import { getSystemConfig } from '@/lib/config';
import { redirect } from 'next/navigation';
export default async function RegisterPage() {
const config = await getSystemConfig();
const allowRegister = config.ALLOW_REGISTRATION !== 'false' && process.env.ALLOW_REGISTRATION !== 'false';
if (!allowRegister) {
redirect('/login');
}
return (
<main className="flex items-center justify-center md:h-screen">
<div className="relative mx-auto flex w-full max-w-[400px] flex-col space-y-2.5 p-4 md:-mt-32">
<RegisterForm />
</div>
</main>
);
}

View File

@@ -0,0 +1,95 @@
'use client'
import { useState, Suspense } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { resetPassword } from '@/app/actions/auth-reset'
import { toast } from 'sonner'
import { useSearchParams, useRouter } from 'next/navigation'
import Link from 'next/link'
function ResetPasswordForm() {
const searchParams = useSearchParams()
const router = useRouter()
const [isSubmitting, setIsSubmitting] = useState(false)
const token = searchParams.get('token')
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (!token) return
const formData = new FormData(e.currentTarget)
const password = formData.get('password') as string
const confirm = formData.get('confirmPassword') as string
if (password !== confirm) {
toast.error("Passwords don't match")
return
}
setIsSubmitting(true)
const result = await resetPassword(token, password)
setIsSubmitting(false)
if (result.error) {
toast.error(result.error)
} else {
toast.success('Password reset successfully. You can now login.')
router.push('/login')
}
}
if (!token) {
return (
<Card className="w-full max-w-[400px]">
<CardHeader>
<CardTitle>Invalid Link</CardTitle>
<CardDescription>This password reset link is invalid or has expired.</CardDescription>
</CardHeader>
<CardFooter>
<Link href="/forgot-password" title="Try again" className="w-full">
<Button variant="outline" className="w-full">Request new link</Button>
</Link>
</CardFooter>
</Card>
)
}
return (
<Card className="w-full max-w-[400px]">
<CardHeader>
<CardTitle>Reset Password</CardTitle>
<CardDescription>Enter your new password below.</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
<div className="space-y-2">
<label htmlFor="password">New Password</label>
<Input id="password" name="password" type="password" required minLength={6} autoFocus />
</div>
<div className="space-y-2">
<label htmlFor="confirmPassword">Confirm New Password</label>
<Input id="confirmPassword" name="confirmPassword" type="password" required minLength={6} />
</div>
</CardContent>
<CardFooter>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? 'Resetting...' : 'Reset Password'}
</Button>
</CardFooter>
</form>
</Card>
)
}
export default function ResetPasswordPage() {
return (
<main className="flex items-center justify-center md:h-screen p-4">
<Suspense fallback={<div>Loading...</div>}>
<ResetPasswordForm />
</Suspense>
</main>
)
}

View File

@@ -0,0 +1,78 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Plus } from 'lucide-react'
import { createUser } from '@/app/actions/admin'
import { toast } from 'sonner'
export function CreateUserDialog() {
const [open, setOpen] = useState(false)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" /> Add User
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Create User</DialogTitle>
<DialogDescription>
Add a new user to the system. They will need to change their password upon first login if you implement that policy.
</DialogDescription>
</DialogHeader>
<form
action={async (formData) => {
const result = await createUser(formData)
if (result?.error) {
toast.error('Failed to create user')
} else {
toast.success('User created successfully')
setOpen(false)
}
}}
className="grid gap-4 py-4"
>
<div className="grid gap-2">
<label htmlFor="name">Name</label>
<Input id="name" name="name" required />
</div>
<div className="grid gap-2">
<label htmlFor="email">Email</label>
<Input id="email" name="email" type="email" required />
</div>
<div className="grid gap-2">
<label htmlFor="password">Password</label>
<Input id="password" name="password" type="password" required minLength={6} />
</div>
<div className="grid gap-2">
<label htmlFor="role">Role</label>
<select
id="role"
name="role"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="USER">User</option>
<option value="ADMIN">Admin</option>
</select>
</div>
<DialogFooter>
<Button type="submit">Create User</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,39 @@
import { getUsers } from '@/app/actions/admin'
import { UserList } from './user-list'
import { CreateUserDialog } from './create-user-dialog'
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Settings } from 'lucide-react'
export default async function AdminPage() {
const session = await auth()
if ((session?.user as any)?.role !== 'ADMIN') {
redirect('/')
}
const users = await getUsers()
return (
<div className="container mx-auto py-10 px-4">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">User Management</h1>
<div className="flex gap-2">
<Link href="/admin/settings">
<Button variant="outline">
<Settings className="mr-2 h-4 w-4" />
Settings
</Button>
</Link>
<CreateUserDialog />
</div>
</div>
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-800">
<UserList initialUsers={users} />
</div>
</div>
)
}

View File

@@ -0,0 +1,245 @@
'use client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { updateSystemConfig, testSMTP } from '@/app/actions/admin-settings'
import { toast } from 'sonner'
import { useState, useEffect } from 'react'
export function AdminSettingsForm({ config }: { config: Record<string, string> }) {
const [isSaving, setIsSaving] = useState(false)
const [isTesting, setIsTesting] = useState(false)
// Local state for Checkbox
const [allowRegister, setAllowRegister] = useState(config.ALLOW_REGISTRATION !== 'false')
const [smtpSecure, setSmtpSecure] = useState(config.SMTP_SECURE === 'true')
const [smtpIgnoreCert, setSmtpIgnoreCert] = useState(config.SMTP_IGNORE_CERT === 'true')
// Sync state with config when server revalidates
useEffect(() => {
setAllowRegister(config.ALLOW_REGISTRATION !== 'false')
setSmtpSecure(config.SMTP_SECURE === 'true')
setSmtpIgnoreCert(config.SMTP_IGNORE_CERT === 'true')
}, [config])
const handleSaveSecurity = async (formData: FormData) => {
setIsSaving(true)
// We override the formData get because the hidden input might be tricky
const data = {
ALLOW_REGISTRATION: allowRegister ? 'true' : 'false',
}
const result = await updateSystemConfig(data)
setIsSaving(false)
if (result.error) {
toast.error('Failed to update security settings')
} else {
toast.success('Security Settings updated')
}
}
const handleSaveAI = async (formData: FormData) => {
setIsSaving(true)
const data = {
AI_PROVIDER: formData.get('AI_PROVIDER') as string,
OLLAMA_BASE_URL: formData.get('OLLAMA_BASE_URL') as string,
AI_MODEL_EMBEDDING: formData.get('AI_MODEL_EMBEDDING') as string,
OPENAI_API_KEY: formData.get('OPENAI_API_KEY') as string,
}
const result = await updateSystemConfig(data)
setIsSaving(false)
if (result.error) {
toast.error('Failed to update AI settings')
} else {
toast.success('AI Settings updated')
}
}
const handleSaveSMTP = async (formData: FormData) => {
setIsSaving(true)
const data = {
SMTP_HOST: formData.get('SMTP_HOST') as string,
SMTP_PORT: formData.get('SMTP_PORT') as string,
SMTP_USER: formData.get('SMTP_USER') as string,
SMTP_PASS: formData.get('SMTP_PASS') as string,
SMTP_FROM: formData.get('SMTP_FROM') as string,
SMTP_IGNORE_CERT: smtpIgnoreCert ? 'true' : 'false',
SMTP_SECURE: smtpSecure ? 'true' : 'false',
}
const result = await updateSystemConfig(data)
setIsSaving(false)
if (result.error) {
toast.error('Failed to update SMTP settings')
} else {
toast.success('SMTP Settings updated')
}
}
const handleTestEmail = async () => {
setIsTesting(true)
try {
const result: any = await testSMTP()
if (result.success) {
toast.success('Test email sent successfully!')
} else {
toast.error(`Failed: ${result.error}`)
}
} catch (e: any) {
toast.error(`Error: ${e.message}`)
} finally {
setIsTesting(false)
}
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Security Settings</CardTitle>
<CardDescription>Manage access control and registration policies.</CardDescription>
</CardHeader>
<form action={handleSaveSecurity}>
<CardContent className="space-y-4">
<div className="flex items-center space-x-2">
<Checkbox
id="ALLOW_REGISTRATION"
checked={allowRegister}
onCheckedChange={(c) => setAllowRegister(!!c)}
/>
<label
htmlFor="ALLOW_REGISTRATION"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Allow Public Registration
</label>
</div>
<p className="text-xs text-muted-foreground">
If disabled, new users can only be added by an Administrator via the User Management page.
</p>
</CardContent>
<CardFooter>
<Button type="submit" disabled={isSaving}>Save Security Settings</Button>
</CardFooter>
</form>
</Card>
<Card>
<CardHeader>
<CardTitle>AI Configuration</CardTitle>
<CardDescription>Configure the AI provider for auto-tagging and semantic search.</CardDescription>
</CardHeader>
<form action={handleSaveAI}>
<CardContent className="space-y-4">
<div className="space-y-2">
<label htmlFor="AI_PROVIDER" className="text-sm font-medium">Provider</label>
<select
id="AI_PROVIDER"
name="AI_PROVIDER"
defaultValue={config.AI_PROVIDER || 'ollama'}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="ollama">Ollama (Local)</option>
<option value="openai">OpenAI</option>
</select>
</div>
<div className="space-y-2">
<label htmlFor="OLLAMA_BASE_URL" className="text-sm font-medium">Ollama Base URL</label>
<Input id="OLLAMA_BASE_URL" name="OLLAMA_BASE_URL" defaultValue={config.OLLAMA_BASE_URL || 'http://localhost:11434'} placeholder="http://localhost:11434" />
</div>
<div className="space-y-2">
<label htmlFor="AI_MODEL_EMBEDDING" className="text-sm font-medium">Embedding Model</label>
<Input id="AI_MODEL_EMBEDDING" name="AI_MODEL_EMBEDDING" defaultValue={config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest'} placeholder="embeddinggemma:latest" />
</div>
<div className="space-y-2">
<label htmlFor="OPENAI_API_KEY" className="text-sm font-medium">OpenAI API Key (if using OpenAI)</label>
<Input id="OPENAI_API_KEY" name="OPENAI_API_KEY" type="password" defaultValue={config.OPENAI_API_KEY || ''} placeholder="sk-..." />
</div>
</CardContent>
<CardFooter>
<Button type="submit" disabled={isSaving}>Save AI Settings</Button>
</CardFooter>
</form>
</Card>
<Card>
<CardHeader>
<CardTitle>SMTP Configuration</CardTitle>
<CardDescription>Configure email server for password resets.</CardDescription>
</CardHeader>
<form action={handleSaveSMTP}>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label htmlFor="SMTP_HOST" className="text-sm font-medium">Host</label>
<Input id="SMTP_HOST" name="SMTP_HOST" defaultValue={config.SMTP_HOST || ''} placeholder="smtp.example.com" />
</div>
<div className="space-y-2">
<label htmlFor="SMTP_PORT" className="text-sm font-medium">Port</label>
<Input id="SMTP_PORT" name="SMTP_PORT" defaultValue={config.SMTP_PORT || '587'} placeholder="587" />
</div>
</div>
<div className="space-y-2">
<label htmlFor="SMTP_USER" className="text-sm font-medium">Username</label>
<Input id="SMTP_USER" name="SMTP_USER" defaultValue={config.SMTP_USER || ''} />
</div>
<div className="space-y-2">
<label htmlFor="SMTP_PASS" className="text-sm font-medium">Password</label>
<Input id="SMTP_PASS" name="SMTP_PASS" type="password" defaultValue={config.SMTP_PASS || ''} />
</div>
<div className="space-y-2">
<label htmlFor="SMTP_FROM" className="text-sm font-medium">From Email</label>
<Input id="SMTP_FROM" name="SMTP_FROM" defaultValue={config.SMTP_FROM || 'noreply@memento.app'} />
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="SMTP_SECURE"
checked={smtpSecure}
onCheckedChange={(c) => setSmtpSecure(!!c)}
/>
<label
htmlFor="SMTP_SECURE"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Force SSL/TLS (usually for port 465)
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="SMTP_IGNORE_CERT"
checked={smtpIgnoreCert}
onCheckedChange={(c) => setSmtpIgnoreCert(!!c)}
/>
<label
htmlFor="SMTP_IGNORE_CERT"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-yellow-600"
>
Ignore Certificate Errors (Self-hosted/Dev only)
</label>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button type="submit" disabled={isSaving}>Save SMTP Settings</Button>
<Button type="button" variant="secondary" onClick={handleTestEmail} disabled={isTesting}>
{isTesting ? 'Sending...' : 'Test Email'}
</Button>
</CardFooter>
</form>
</Card>
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { getSystemConfig } from '@/app/actions/admin-settings'
import { AdminSettingsForm } from './admin-settings-form'
export default async function AdminSettingsPage() {
const session = await auth()
if ((session?.user as any)?.role !== 'ADMIN') {
redirect('/')
}
const config = await getSystemConfig()
return (
<div className="container max-w-4xl mx-auto py-10 px-4">
<h1 className="text-3xl font-bold mb-8">System Configuration</h1>
<AdminSettingsForm config={config} />
</div>
)
}

View File

@@ -0,0 +1,82 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { deleteUser, updateUserRole } from '@/app/actions/admin'
import { toast } from 'sonner'
import { Trash2, Shield, ShieldOff } from 'lucide-react'
import { format } from 'date-fns'
export function UserList({ initialUsers }: { initialUsers: any[] }) {
// Optimistic update could be implemented here, but standard is fine for admin
const handleDelete = async (id: string) => {
if (!confirm('Are you sure? This action cannot be undone.')) return
try {
await deleteUser(id)
toast.success('User deleted')
} catch (e) {
toast.error('Failed to delete')
}
}
const handleRoleToggle = async (user: any) => {
const newRole = user.role === 'ADMIN' ? 'USER' : 'ADMIN'
try {
await updateUserRole(user.id, newRole)
toast.success(`User role updated to ${newRole}`)
} catch (e) {
toast.error('Failed to update role')
}
}
return (
<div className="w-full overflow-auto">
<table className="w-full caption-bottom text-sm text-left">
<thead className="[&_tr]:border-b">
<tr className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">Name</th>
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">Email</th>
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">Role</th>
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">Created At</th>
<th className="h-12 px-4 align-middle font-medium text-muted-foreground text-right">Actions</th>
</tr>
</thead>
<tbody className="[&_tr:last-child]:border-0">
{initialUsers.map((user) => (
<tr key={user.id} className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td className="p-4 align-middle font-medium">{user.name || 'N/A'}</td>
<td className="p-4 align-middle">{user.email}</td>
<td className="p-4 align-middle">
<span className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 ${user.role === 'ADMIN' ? 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80' : 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80'}`}>
{user.role}
</span>
</td>
<td className="p-4 align-middle">{format(new Date(user.createdAt), 'PP')}</td>
<td className="p-4 align-middle text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleRoleToggle(user)}
title={user.role === 'ADMIN' ? "Demote to User" : "Promote to Admin"}
>
{user.role === 'ADMIN' ? <ShieldOff className="h-4 w-4" /> : <Shield className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(user.id)}
className="text-red-600 hover:text-red-900"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,23 @@
import { HeaderWrapper } from "@/components/header-wrapper";
import { Sidebar } from "@/components/sidebar";
import { auth } from "@/auth";
export default async function MainLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const session = await auth();
return (
<div className="flex min-h-screen flex-col">
<HeaderWrapper user={session?.user} />
<div className="flex flex-1">
<Sidebar className="shrink-0 border-r" user={session?.user} />
<main className="flex-1">
{children}
</main>
</div>
</div>
);
}

View File

@@ -3,18 +3,20 @@
import { useState, useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
import { Note } from '@/lib/types'
import { getNotes, searchNotes } from '@/app/actions/notes'
import { getAllNotes, searchNotes } from '@/app/actions/notes'
import { NoteInput } from '@/components/note-input'
import { MasonryGrid } from '@/components/masonry-grid'
import { useLabels } from '@/context/LabelContext'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
import { useReminderCheck } from '@/hooks/use-reminder-check'
export default function HomePage() {
const searchParams = useSearchParams()
const [notes, setNotes] = useState<Note[]>([])
const [isLoading, setIsLoading] = useState(true)
const { refreshKey } = useNoteRefresh()
const { labels } = useLabels()
// Enable reminder notifications
useReminderCheck(notes)
@@ -24,8 +26,8 @@ export default function HomePage() {
const search = searchParams.get('search')
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
const colorFilter = searchParams.get('color')
let allNotes = search ? await searchNotes(search) : await getNotes()
let allNotes = search ? await searchNotes(search) : await getAllNotes()
// Filter by selected labels
if (labelFilter.length > 0) {
@@ -35,11 +37,13 @@ export default function HomePage() {
}
// Filter by color (filter notes that have labels with this color)
// Note: We use a ref-like pattern to avoid including labels in dependencies
// This prevents dialog closing when adding new labels
if (colorFilter) {
const labelNamesWithColor = labels
.filter((label: any) => label.color === colorFilter)
.map((label: any) => label.name)
allNotes = allNotes.filter((note: any) =>
note.labels?.some((label: string) => labelNamesWithColor.includes(label))
)
@@ -50,8 +54,8 @@ export default function HomePage() {
}
loadNotes()
}, [searchParams, labels])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, refreshKey]) // Intentionally omit 'labels' to prevent reload when adding tags
return (
<main className="container mx-auto px-4 py-8 max-w-7xl">
<NoteInput />

View File

@@ -4,14 +4,30 @@ import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Loader2, CheckCircle, XCircle, RefreshCw, Trash2, Database } from 'lucide-react';
import { cleanupAllOrphans } from '@/app/actions/notes';
import { useToast } from '@/components/ui/toast';
import { Loader2, CheckCircle, XCircle, RefreshCw, Trash2, Database, BrainCircuit } from 'lucide-react';
import { cleanupAllOrphans, syncAllEmbeddings } from '@/app/actions/notes';
import { toast } from 'sonner';
export default function SettingsPage() {
const { addToast } = useToast();
const [loading, setLoading] = useState(false);
const [cleanupLoading, setCleanupLoading] = useState(false);
const [syncLoading, setSyncLoading] = useState(false);
const handleSync = async () => {
setSyncLoading(true);
try {
const result = await syncAllEmbeddings();
if (result.success) {
toast.success(`Indexing complete: ${result.count} notes processed`);
}
} catch (error) {
console.error(error);
toast.error("Error during indexing");
} finally {
setSyncLoading(false);
}
};
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [result, setResult] = useState<any>(null);
const [config, setConfig] = useState<any>(null);
@@ -23,7 +39,7 @@ export default function SettingsPage() {
try {
const res = await fetch('/api/ai/test');
const data = await res.json();
setConfig({
provider: data.provider,
status: res.ok ? 'connected' : 'disconnected'
@@ -50,11 +66,11 @@ export default function SettingsPage() {
try {
const result = await cleanupAllOrphans();
if (result.success) {
addToast(`Nettoyage terminé : ${result.count} tags supprimés`, 'success');
toast.success(result.message || `Cleanup complete: ${result.created} created, ${result.deleted} removed`);
}
} catch (error) {
console.error(error);
addToast("Erreur lors du nettoyage", "error");
toast.error("Error during cleanup");
} finally {
setCleanupLoading(false);
}
@@ -66,57 +82,57 @@ export default function SettingsPage() {
return (
<div className="container mx-auto py-10 px-4 max-w-4xl space-y-8">
<h1 className="text-3xl font-bold mb-8">Paramètres</h1>
<h1 className="text-3xl font-bold mb-8">Settings</h1>
<Card>
<CardHeader>
<div className="flex justify-between items-center">
<div>
<CardTitle className="flex items-center gap-2">
Diagnostic IA
AI Diagnostics
{status === 'success' && <CheckCircle className="text-green-500 w-5 h-5" />}
{status === 'error' && <XCircle className="text-red-500 w-5 h-5" />}
</CardTitle>
<CardDescription>Vérifiez la connexion avec votre fournisseur d'intelligence artificielle.</CardDescription>
<CardDescription>Check your AI provider connection status.</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={checkConnection} disabled={loading}>
{loading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <RefreshCw className="w-4 h-4 mr-2" />}
Tester la connexion
Test Connection
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Configuration Actuelle */}
{/* Current Configuration */}
<div className="grid grid-cols-2 gap-4">
<div className="p-4 rounded-lg bg-secondary/50">
<p className="text-sm font-medium text-muted-foreground mb-1">Provider Configuré</p>
<p className="text-sm font-medium text-muted-foreground mb-1">Configured Provider</p>
<p className="text-lg font-mono">{config?.provider || '...'}</p>
</div>
<div className="p-4 rounded-lg bg-secondary/50">
<p className="text-sm font-medium text-muted-foreground mb-1">État API</p>
<p className="text-sm font-medium text-muted-foreground mb-1">API Status</p>
<Badge variant={status === 'success' ? 'default' : 'destructive'}>
{status === 'success' ? 'Opérationnel' : 'Erreur'}
{status === 'success' ? 'Operational' : 'Error'}
</Badge>
</div>
</div>
{/* Résultat du Test */}
{/* Test Result */}
{result && (
<div className="space-y-2">
<h3 className="text-sm font-medium">Détails du test :</h3>
<h3 className="text-sm font-medium">Test Details:</h3>
<div className={`p-4 rounded-md font-mono text-xs overflow-x-auto ${status === 'error' ? 'bg-red-50 text-red-900 border border-red-200' : 'bg-slate-950 text-slate-50'}`}>
<pre>{JSON.stringify(result, null, 2)}</pre>
</div>
{status === 'error' && (
<div className="text-sm text-red-600 mt-2">
<p className="font-bold">Conseil de dépannage :</p>
<p className="font-bold">Troubleshooting Tips:</p>
<ul className="list-disc list-inside mt-1">
<li>Vérifiez que Ollama tourne (<code>ollama list</code>)</li>
<li>Vérifiez l'URL (http://localhost:11434)</li>
<li>Vérifiez que le modèle (ex: granite4:latest) est bien téléchargé</li>
<li>Regardez le terminal du serveur Next.js pour plus de logs</li>
<li>Check that Ollama is running (<code>ollama list</code>)</li>
<li>Check the URL (http://localhost:11434)</li>
<li>Verify the model (e.g., granite4:latest) is downloaded</li>
<li>Check the Next.js server terminal for more logs</li>
</ul>
</div>
)}
@@ -132,18 +148,34 @@ export default function SettingsPage() {
<Database className="w-5 h-5" />
Maintenance
</CardTitle>
<CardDescription>Outils pour maintenir la santé de votre base de données.</CardDescription>
<CardDescription>Tools to maintain your database health.</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<h3 className="font-medium">Nettoyage des tags orphelins</h3>
<p className="text-sm text-muted-foreground">Supprime les tags qui ne sont plus utilisés par aucune note.</p>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<h3 className="font-medium">Clean Orphan Tags</h3>
<p className="text-sm text-muted-foreground">Remove tags that are no longer used by any notes.</p>
</div>
<Button variant="secondary" onClick={handleCleanup} disabled={cleanupLoading}>
{cleanupLoading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Trash2 className="w-4 h-4 mr-2" />}
Clean
</Button>
</div>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<h3 className="font-medium flex items-center gap-2">
Semantic Indexing
<Badge variant="outline" className="text-[10px]">AI</Badge>
</h3>
<p className="text-sm text-muted-foreground">Generate vectors for all notes to enable intent-based search.</p>
</div>
<Button variant="secondary" onClick={handleSync} disabled={syncLoading}>
{syncLoading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <BrainCircuit className="w-4 h-4 mr-2" />}
Index All
</Button>
</div>
<Button variant="secondary" onClick={handleCleanup} disabled={cleanupLoading}>
{cleanupLoading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Trash2 className="w-4 h-4 mr-2" />}
Nettoyer
</Button>
</div>
</CardContent>
</Card>

View File

@@ -0,0 +1,28 @@
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { ProfileForm } from './profile-form'
import prisma from '@/lib/prisma'
export default async function ProfilePage() {
const session = await auth()
if (!session?.user?.id) {
redirect('/login')
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { name: true, email: true, role: true }
})
if (!user) {
redirect('/login')
}
return (
<div className="container max-w-2xl mx-auto py-10 px-4">
<h1 className="text-3xl font-bold mb-8">Account Settings</h1>
<ProfileForm user={user} />
</div>
)
}

View File

@@ -0,0 +1,83 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { updateProfile, changePassword } from '@/app/actions/profile'
import { toast } from 'sonner'
export function ProfileForm({ user }: { user: any }) {
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Profile Information</CardTitle>
<CardDescription>Update your display name and other public information.</CardDescription>
</CardHeader>
<form action={async (formData) => {
const result = await updateProfile({ name: formData.get('name') as string })
if (result?.error) {
toast.error('Failed to update profile')
} else {
toast.success('Profile updated')
}
}}>
<CardContent className="space-y-4">
<div className="space-y-2">
<label htmlFor="name" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">Display Name</label>
<Input id="name" name="name" defaultValue={user.name} />
</div>
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">Email</label>
<Input id="email" value={user.email} disabled className="bg-muted" />
</div>
</CardContent>
<CardFooter>
<Button type="submit">Save Changes</Button>
</CardFooter>
</form>
</Card>
<Card>
<CardHeader>
<CardTitle>Change Password</CardTitle>
<CardDescription>Update your password. You will need your current password.</CardDescription>
</CardHeader>
<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] || 'Failed to change password'
toast.error(msg)
} else {
toast.success('Password changed successfully')
// Reset form manually or redirect
const form = document.querySelector('form#password-form') as HTMLFormElement
form?.reset()
}
}} id="password-form">
<CardContent className="space-y-4">
<div className="space-y-2">
<label htmlFor="currentPassword" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">Current Password</label>
<Input id="currentPassword" name="currentPassword" type="password" required />
</div>
<div className="space-y-2">
<label htmlFor="newPassword" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">New Password</label>
<Input id="newPassword" name="newPassword" type="password" required minLength={6} />
</div>
<div className="space-y-2">
<label htmlFor="confirmPassword" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">Confirm Password</label>
<Input id="confirmPassword" name="confirmPassword" type="password" required minLength={6} />
</div>
</CardContent>
<CardFooter>
<Button type="submit">Update Password</Button>
</CardFooter>
</form>
</Card>
</div>
)
}

View File

@@ -0,0 +1,156 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
export default function SupportPage() {
return (
<div className="container mx-auto py-10 max-w-4xl">
<div className="text-center mb-10">
<h1 className="text-4xl font-bold mb-4">
Support Memento Development
</h1>
<p className="text-muted-foreground text-lg">
Memento is 100% free and open-source. Your support helps keep it that way.
</p>
</div>
<div className="grid gap-6 md:grid-cols-2 mb-10">
{/* Ko-fi Card */}
<Card className="border-2 border-primary">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl"></span>
Buy me a coffee
</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-4">
Make a one-time donation or become a monthly supporter.
</p>
<Button asChild className="w-full">
<a href="https://ko-fi.com/yourusername" target="_blank" rel="noopener noreferrer">
Donate on Ko-fi
</a>
</Button>
<p className="text-xs text-muted-foreground mt-2">
No platform fees Instant payouts Secure
</p>
</CardContent>
</Card>
{/* GitHub Sponsors Card */}
<Card className="border-2 border-primary">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">💚</span>
Sponsor on GitHub
</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-4">
Become a monthly sponsor and get recognition.
</p>
<Button asChild variant="outline" className="w-full">
<a href="https://github.com/sponsors/yourusername" target="_blank" rel="noopener noreferrer">
Sponsor on GitHub
</a>
</Button>
<p className="text-xs text-muted-foreground mt-2">
Recurring support Public recognition Developer-focused
</p>
</CardContent>
</Card>
</div>
{/* How Donations Are Used */}
<Card className="mb-10">
<CardHeader>
<CardTitle>How Your Support Helps</CardTitle>
</CardHeader>
<CardContent>
<div className="grid md:grid-cols-2 gap-4">
<div>
<h3 className="font-semibold mb-2">💰 Direct Impact</h3>
<ul className="space-y-2 text-sm">
<li> Keeps me fueled with coffee</li>
<li>🐛 Covers hosting and server costs</li>
<li> Funds development of new features</li>
<li>📚 Improves documentation</li>
<li>🌍 Keeps Memento 100% open-source</li>
</ul>
</div>
<div>
<h3 className="font-semibold mb-2">🎁 Sponsor Perks</h3>
<ul className="space-y-2 text-sm">
<li>🥉 $5/month - Bronze: Name in supporters list</li>
<li>🥈 $15/month - Silver: Priority feature requests</li>
<li>🥇 $50/month - Gold: Logo in footer, priority support</li>
<li>💎 $100/month - Platinum: Custom features, consulting</li>
</ul>
</div>
</div>
</CardContent>
</Card>
{/* Transparency */}
<Card>
<CardHeader>
<CardTitle>💡 Transparency</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm mb-4">
I believe in complete transparency. Here's how donations are used:
</p>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span>Hosting & servers:</span>
<span className="font-mono">~$20/month</span>
</div>
<div className="flex justify-between">
<span>Domain & SSL:</span>
<span className="font-mono">~$15/year</span>
</div>
<div className="flex justify-between">
<span>AI API costs:</span>
<span className="font-mono">~$30/month</span>
</div>
<div className="flex justify-between border-t pt-2">
<span className="font-semibold">Total expenses:</span>
<span className="font-mono font-semibold">~$50/month</span>
</div>
</div>
<p className="text-xs text-muted-foreground mt-4">
Any amount beyond these costs goes directly into improving Memento
and funding new features. Thank you for your support! 💚
</p>
</CardContent>
</Card>
{/* Alternative Ways to Support */}
<div className="mt-10 text-center">
<h2 className="text-2xl font-bold mb-4">Other Ways to Support</h2>
<div className="flex flex-wrap justify-center gap-4">
<Button variant="outline" asChild>
<a href="https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
Star on GitHub
</a>
</Button>
<Button variant="outline" asChild>
<a href="https://github.com/yourusername/memento/issues" target="_blank" rel="noopener noreferrer">
🐛 Report a bug
</a>
</Button>
<Button variant="outline" asChild>
<a href="https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
📝 Contribute code
</a>
</Button>
<Button variant="outline" asChild>
<a href="https://twitter.com/intent/tweet?text=Check%20out%20Memento%20-%20a%20great%20open-source%20note-taking%20app!%20https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
🐦 Share on Twitter
</a>
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,59 @@
'use server'
import { revalidatePath } from 'next/cache'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { sendEmail } from '@/lib/mail'
async function checkAdmin() {
const session = await auth()
if (!session?.user?.id || (session.user as any).role !== 'ADMIN') {
throw new Error('Unauthorized: Admin access required')
}
return session
}
export async function testSMTP() {
const session = await checkAdmin()
const email = session.user?.email
if (!email) throw new Error("No admin email found")
const result = await sendEmail({
to: email,
subject: "Memento SMTP Test",
html: "<p>This is a test email from your Memento instance. <strong>SMTP is working!</strong></p>"
})
return result
}
export async function getSystemConfig() {
await checkAdmin()
const configs = await prisma.systemConfig.findMany()
return configs.reduce((acc, conf) => {
acc[conf.key] = conf.value
return acc
}, {} as Record<string, string>)
}
export async function updateSystemConfig(data: Record<string, string>) {
await checkAdmin()
try {
const operations = Object.entries(data).map(([key, value]) =>
prisma.systemConfig.upsert({
where: { key },
update: { value },
create: { key, value }
})
)
await prisma.$transaction(operations)
revalidatePath('/admin/settings')
return { success: true }
} catch (error) {
console.error('Failed to update settings:', error)
return { error: 'Failed to update settings' }
}
}

View File

@@ -0,0 +1,120 @@
'use server'
import { revalidatePath } from 'next/cache'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
// Schema pour la création d'utilisateur
const CreateUserSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email address"),
password: z.string().min(6, "Password must be at least 6 characters"),
role: z.enum(["USER", "ADMIN"]).default("USER"),
})
async function checkAdmin() {
const session = await auth()
if (!session?.user?.id || (session.user as any).role !== 'ADMIN') {
throw new Error('Unauthorized: Admin access required')
}
return session
}
export async function getUsers() {
await checkAdmin()
try {
const users = await prisma.user.findMany({
orderBy: { createdAt: 'desc' },
select: {
id: true,
name: true,
email: true,
role: true,
createdAt: true,
}
})
return users
} catch (error) {
console.error('Failed to fetch users:', error)
throw new Error('Failed to fetch users')
}
}
export async function createUser(formData: FormData) {
await checkAdmin()
const rawData = {
name: formData.get('name'),
email: formData.get('email'),
password: formData.get('password'),
role: formData.get('role'),
}
const validatedFields = CreateUserSchema.safeParse(rawData)
if (!validatedFields.success) {
return {
error: validatedFields.error.flatten().fieldErrors,
}
}
const { name, email, password, role } = validatedFields.data
const hashedPassword = await bcrypt.hash(password, 10)
try {
await prisma.user.create({
data: {
name,
email: email.toLowerCase(),
password: hashedPassword,
role,
},
})
revalidatePath('/admin')
return { success: true }
} catch (error: any) {
if (error.code === 'P2002') {
return { error: { email: ['Email already exists'] } }
}
return { error: { _form: ['Failed to create user'] } }
}
}
export async function deleteUser(userId: string) {
const session = await checkAdmin()
if (session.user?.id === userId) {
throw new Error("You cannot delete your own account")
}
try {
await prisma.user.delete({
where: { id: userId },
})
revalidatePath('/admin')
return { success: true }
} catch (error) {
throw new Error('Failed to delete user')
}
}
export async function updateUserRole(userId: string, newRole: string) {
const session = await checkAdmin()
if (session.user?.id === userId) {
throw new Error("You cannot change your own role")
}
try {
await prisma.user.update({
where: { id: userId },
data: { role: newRole },
})
revalidatePath('/admin')
return { success: true }
} catch (error) {
throw new Error('Failed to update role')
}
}

View File

@@ -0,0 +1,86 @@
'use server'
import prisma from '@/lib/prisma'
import { sendEmail } from '@/lib/mail'
import bcrypt from 'bcryptjs'
import { getEmailTemplate } from '@/lib/email-template'
// Helper simple pour générer un token sans dépendance externe lourde
function generateToken() {
const array = new Uint8Array(32);
globalThis.crypto.getRandomValues(array);
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
}
export async function forgotPassword(email: string) {
if (!email) return { error: "Email is required" };
try {
const user = await prisma.user.findUnique({ where: { email: email.toLowerCase() } });
if (!user) {
// Pour des raisons de sécurité, on ne dit pas si l'email existe ou pas
return { success: true };
}
const token = generateToken();
const expiry = new Date(Date.now() + 3600000); // 1 hour
await prisma.user.update({
where: { id: user.id },
data: {
resetToken: token,
resetTokenExpiry: expiry
}
});
const resetLink = `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/reset-password?token=${token}`;
const html = getEmailTemplate(
"Reset your Password",
"<p>You requested a password reset for your Memento account.</p><p>Click the button below to set a new password. This link is valid for 1 hour.</p>",
resetLink,
"Reset Password"
);
await sendEmail({
to: user.email,
subject: "Reset your Memento password",
html
});
return { success: true };
} catch (error) {
console.error('Forgot password error:', error);
return { error: "Failed to send reset email" };
}
}
export async function resetPassword(token: string, newPassword: string) {
if (!token || !newPassword) return { error: "Missing token or password" };
try {
const user = await prisma.user.findUnique({
where: { resetToken: token }
});
if (!user || !user.resetTokenExpiry || user.resetTokenExpiry < new Date()) {
return { error: "Invalid or expired token" };
}
const hashedPassword = await bcrypt.hash(newPassword, 10);
await prisma.user.update({
where: { id: user.id },
data: {
password: hashedPassword,
resetToken: null,
resetTokenExpiry: null
}
});
return { success: true };
} catch (error) {
console.error('Reset password error:', error);
return { error: "Failed to reset password" };
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,100 @@
'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 },
})
return { success: true }
} catch (error) {
return { error: 'Failed to update theme' }
}
}

View File

@@ -4,6 +4,7 @@ import bcrypt from 'bcryptjs';
import prisma from '@/lib/prisma';
import { z } from 'zod';
import { redirect } from 'next/navigation';
import { getSystemConfig } from '@/lib/config';
const RegisterSchema = z.object({
email: z.string().email(),
@@ -12,6 +13,14 @@ const RegisterSchema = z.object({
});
export async function register(prevState: string | undefined, formData: FormData) {
// Check if registration is allowed
const config = await getSystemConfig();
const allowRegister = config.ALLOW_REGISTRATION !== 'false' && process.env.ALLOW_REGISTRATION !== 'false';
if (!allowRegister) {
return 'Registration is currently disabled by the administrator.';
}
const validatedFields = RegisterSchema.safeParse({
email: formData.get('email'),
password: formData.get('password'),

View File

@@ -20,12 +20,18 @@ export async function fetchLinkMetadata(url: string): Promise<LinkMetadata | nul
const response = await fetch(targetUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; Memento/1.0; +http://localhost:3000)',
// Use a real browser User-Agent to avoid 403 Forbidden from strict sites
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5'
},
next: { revalidate: 3600 } // Cache for 1 hour
});
if (!response.ok) return null;
if (!response.ok) {
console.warn(`[Scrape] Failed to fetch ${targetUrl}: ${response.status} ${response.statusText}`);
return null;
}
const html = await response.text();
const $ = cheerio.load(html);
@@ -34,21 +40,21 @@ export async function fetchLinkMetadata(url: string): Promise<LinkMetadata | nul
$(`meta[property="${prop}"]`).attr('content') ||
$(`meta[name="${prop}"]`).attr('content');
const title = getMeta('og:title') || $('title').text() || '';
const description = getMeta('og:description') || getMeta('description') || '';
const imageUrl = getMeta('og:image');
const siteName = getMeta('og:site_name');
// Robust extraction with fallbacks
const title = getMeta('og:title') || $('title').text() || getMeta('twitter:title') || url;
const description = getMeta('og:description') || getMeta('description') || getMeta('twitter:description') || '';
const imageUrl = getMeta('og:image') || getMeta('twitter:image') || $('link[rel="image_src"]').attr('href');
const siteName = getMeta('og:site_name') || '';
return {
url: targetUrl,
title: title.substring(0, 100), // Truncate if too long
title: title.substring(0, 100),
description: description.substring(0, 200),
imageUrl,
siteName
};
} catch (error) {
console.error('Error fetching link metadata:', error);
console.error(`[Scrape] Error fetching ${url}:`, error);
return null;
}
}

View File

@@ -0,0 +1,100 @@
import { NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { validateEmbedding } from '@/lib/utils'
/**
* Admin endpoint to validate all embeddings in the database
* Returns a list of notes with invalid embeddings
*/
export async function GET() {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check if user is admin
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { role: true }
})
if (!user || user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Forbidden - Admin only' }, { status: 403 })
}
// Fetch all notes with embeddings
const allNotes = await prisma.note.findMany({
select: {
id: true,
title: true,
embedding: true
}
})
const invalidNotes: Array<{
id: string
title: string
issues: string[]
}> = []
let validCount = 0
let missingCount = 0
let invalidCount = 0
for (const note of allNotes) {
// Check if embedding is missing
if (!note.embedding) {
missingCount++
invalidNotes.push({
id: note.id,
title: note.title || 'Untitled',
issues: ['Missing embedding']
})
continue
}
// Parse and validate embedding
try {
const embedding = JSON.parse(note.embedding)
const validation = validateEmbedding(embedding)
if (!validation.valid) {
invalidCount++
invalidNotes.push({
id: note.id,
title: note.title || 'Untitled',
issues: validation.issues
})
} else {
validCount++
}
} catch (error) {
invalidCount++
invalidNotes.push({
id: note.id,
title: note.title || 'Untitled',
issues: [`Failed to parse embedding: ${error}`]
})
}
}
return NextResponse.json({
success: true,
summary: {
total: allNotes.length,
valid: validCount,
missing: missingCount,
invalid: invalidCount
},
invalidNotes
})
} catch (error) {
console.error('[EMBEDDING_VALIDATION] Error:', error)
return NextResponse.json(
{ success: false, error: String(error) },
{ status: 500 }
)
}
}

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAIProvider } from '@/lib/ai/factory';
import { getSystemConfig } from '@/lib/config';
import { z } from 'zod';
const requestSchema = z.object({
@@ -11,16 +12,16 @@ export async function POST(req: NextRequest) {
const body = await req.json();
const { content } = requestSchema.parse(body);
const provider = getAIProvider();
const config = await getSystemConfig();
const provider = getAIProvider(config);
const tags = await provider.generateTags(content);
console.log('[API Tags] Generated tags:', tags);
return NextResponse.json({ tags });
} catch (error: any) {
console.error('Erreur API tags:', error);
if (error instanceof z.ZodError) {
return NextResponse.json({ error: error.errors }, { status: 400 });
return NextResponse.json({ error: error.issues }, { status: 400 });
}
return NextResponse.json(

View File

@@ -1,27 +1,57 @@
import { NextResponse } from 'next/server';
import { getAIProvider } from '@/lib/ai/factory';
import { NextRequest, NextResponse } from 'next/server'
import { getAIProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
export async function GET() {
export async function GET(request: NextRequest) {
try {
const provider = getAIProvider();
const providerName = process.env.AI_PROVIDER || 'openai';
// Test simple de génération de tags sur un texte bidon
const testContent = "J'adore cuisiner des pâtes le dimanche soir avec ma famille.";
const tags = await provider.generateTags(testContent);
const config = await getSystemConfig()
const provider = getAIProvider(config)
// Test with a simple embedding request
const testText = 'test'
const embeddings = await provider.getEmbeddings(testText)
if (!embeddings || embeddings.length === 0) {
return NextResponse.json(
{
success: false,
provider: config.AI_PROVIDER || 'ollama',
error: 'No embeddings returned',
details: {
provider: config.AI_PROVIDER || 'ollama',
baseUrl: config.OLLAMA_BASE_URL || process.env.OLLAMA_BASE_URL || 'http://localhost:11434',
model: config.AI_MODEL_EMBEDDING || process.env.OLLAMA_EMBEDDING_MODEL || 'embeddinggemma:latest'
}
},
{ status: 500 }
)
}
return NextResponse.json({
status: 'success',
provider: providerName,
test_tags: tags,
message: 'Infrastructure IA opérationnelle'
});
success: true,
provider: config.AI_PROVIDER || 'ollama',
embeddingLength: embeddings.length,
firstValues: embeddings.slice(0, 5),
details: {
provider: config.AI_PROVIDER || 'ollama',
baseUrl: config.OLLAMA_BASE_URL || process.env.OLLAMA_BASE_URL || 'http://localhost:11434',
model: config.AI_MODEL_EMBEDDING || process.env.OLLAMA_EMBEDDING_MODEL || 'embeddinggemma:latest'
}
})
} catch (error: any) {
console.error('Erreur test IA détaillée:', error);
return NextResponse.json({
status: 'error',
message: error.message,
stack: error.stack
}, { status: 500 });
console.error('AI test error:', error)
return NextResponse.json(
{
success: false,
error: error.message || 'Unknown error',
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined,
details: {
provider: process.env.AI_PROVIDER || 'ollama',
baseUrl: process.env.OLLAMA_BASE_URL || 'http://localhost:11434',
model: process.env.OLLAMA_EMBEDDING_MODEL || 'embeddinggemma:latest'
}
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,124 @@
import { NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
function getHashColor(name: string): string {
const colors = ['red', 'blue', 'green', 'yellow', 'purple', 'pink', 'orange', 'gray']
let hash = 0
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash)
}
return colors[Math.abs(hash) % colors.length]
}
export async function POST() {
try {
const result = { created: 0, deleted: 0, missing: [] as string[] }
// Get ALL users
const users = await prisma.user.findMany({
select: { id: true, email: true }
})
console.log(`[FIX] Processing ${users.length} users`)
for (const user of users) {
const userId = user.id
// 1. Get all labels from notes
const allNotes = await prisma.note.findMany({
where: { userId },
select: { labels: true }
})
const labelsInNotes = new Set<string>()
allNotes.forEach(note => {
if (note.labels) {
try {
const parsed: string[] = JSON.parse(note.labels)
if (Array.isArray(parsed)) {
parsed.forEach(l => {
if (l && l.trim()) labelsInNotes.add(l.trim())
})
}
} catch (e) {}
}
})
console.log(`[FIX] User ${user.email}: ${labelsInNotes.size} labels in notes`, Array.from(labelsInNotes))
// 2. Get existing Label records
const existingLabels = await prisma.label.findMany({
where: { userId },
select: { id: true, name: true }
})
console.log(`[FIX] User ${user.email}: ${existingLabels.length} existing labels`, existingLabels.map(l => l.name))
const existingLabelMap = new Map<string, any>()
existingLabels.forEach(label => {
existingLabelMap.set(label.name.toLowerCase(), label)
})
// 3. Create missing Label records
for (const labelName of labelsInNotes) {
if (!existingLabelMap.has(labelName.toLowerCase())) {
console.log(`[FIX] Creating missing label: "${labelName}" for ${user.email}`)
try {
await prisma.label.create({
data: {
userId,
name: labelName,
color: getHashColor(labelName)
}
})
result.created++
console.log(`[FIX] ✓ Created: "${labelName}"`)
} catch (e: any) {
console.error(`[FIX] ✗ Failed to create "${labelName}":`, e.message, e.code)
result.missing.push(labelName)
}
}
}
// 4. Delete orphan Label records
const usedLabelsSet = new Set<string>()
allNotes.forEach(note => {
if (note.labels) {
try {
const parsed: string[] = JSON.parse(note.labels)
if (Array.isArray(parsed)) {
parsed.forEach(l => usedLabelsSet.add(l.toLowerCase()))
}
} catch (e) {}
}
})
for (const label of existingLabels) {
if (!usedLabelsSet.has(label.name.toLowerCase())) {
try {
await prisma.label.delete({
where: { id: label.id }
})
result.deleted++
console.log(`[FIX] Deleted orphan: "${label.name}"`)
} catch (e) {}
}
}
}
revalidatePath('/')
return NextResponse.json({
success: true,
...result,
message: `Created ${result.created} labels, deleted ${result.deleted} orphans`
})
} catch (error) {
console.error('[FIX] Error:', error)
return NextResponse.json(
{ success: false, error: String(error) },
{ status: 500 }
)
}
}

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
// GET /api/labels/[id] - Get a specific label
export async function GET(
@@ -42,14 +43,68 @@ export async function PUT(
const body = await request.json()
const { name, color } = body
// Get the current label first
const currentLabel = await prisma.label.findUnique({
where: { id }
})
if (!currentLabel) {
return NextResponse.json(
{ success: false, error: 'Label not found' },
{ status: 404 }
)
}
const newName = name ? name.trim() : currentLabel.name
// If renaming, update all notes that use this label
if (name && name.trim() !== currentLabel.name) {
// Get all notes that use this label
const allNotes = await prisma.note.findMany({
where: {
userId: currentLabel.userId,
labels: { not: null }
},
select: { id: true, labels: true }
})
// Update the label name in all notes that use it
for (const note of allNotes) {
if (note.labels) {
try {
const noteLabels: string[] = JSON.parse(note.labels)
const updatedLabels = noteLabels.map(l =>
l.toLowerCase() === currentLabel.name.toLowerCase() ? newName : l
)
// Update the note if labels changed
if (JSON.stringify(updatedLabels) !== JSON.stringify(noteLabels)) {
await prisma.note.update({
where: { id: note.id },
data: {
labels: JSON.stringify(updatedLabels)
}
})
}
} catch (e) {
console.error(`Failed to parse labels for note ${note.id}:`, e)
}
}
}
}
// Now update the label record
const label = await prisma.label.update({
where: { id },
data: {
...(name && { name: name.trim() }),
...(name && { name: newName }),
...(color && { color })
}
})
// Revalidate to refresh UI
revalidatePath('/')
return NextResponse.json({
success: true,
data: label
@@ -70,13 +125,63 @@ export async function DELETE(
) {
try {
const { id } = await params
// First, get the label to know its name and userId
const label = await prisma.label.findUnique({
where: { id }
})
if (!label) {
return NextResponse.json(
{ success: false, error: 'Label not found' },
{ status: 404 }
)
}
// Get all notes that use this label
const allNotes = await prisma.note.findMany({
where: {
userId: label.userId,
labels: { not: null }
},
select: { id: true, labels: true }
})
// Remove the label from all notes that use it
for (const note of allNotes) {
if (note.labels) {
try {
const noteLabels: string[] = JSON.parse(note.labels)
const filteredLabels = noteLabels.filter(
l => l.toLowerCase() !== label.name.toLowerCase()
)
// Update the note if labels changed
if (filteredLabels.length !== noteLabels.length) {
await prisma.note.update({
where: { id: note.id },
data: {
labels: filteredLabels.length > 0 ? JSON.stringify(filteredLabels) : null
}
})
}
} catch (e) {
console.error(`Failed to parse labels for note ${note.id}:`, e)
}
}
}
// Now delete the label record
await prisma.label.delete({
where: { id }
})
// Revalidate to refresh UI
revalidatePath('/')
return NextResponse.json({
success: true,
message: 'Label deleted successfully'
message: `Label "${label.name}" deleted and removed from ${allNotes.length} notes`
})
} catch (error) {
console.error('DELETE /api/labels/[id] error:', error)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -1,8 +1,21 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
/* Custom Prose overrides for compact notes */
@utility prose-compact {
& :where(h1, h2, h3, h4) {
margin-top: 0.5rem;
margin-bottom: 0.25rem;
}
& :where(p, ul, ol, li) {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);

View File

@@ -1,10 +1,10 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { HeaderWrapper } from "@/components/header-wrapper";
import { ToastProvider } from "@/components/ui/toast";
import { Toaster } from "@/components/ui/toast";
import { LabelProvider } from "@/context/LabelContext";
import { Sidebar } from "@/components/sidebar";
import { NoteRefreshProvider } from "@/context/NoteRefreshContext";
import { SessionProviderWrapper } from "@/components/session-provider-wrapper";
const inter = Inter({
subsets: ["latin"],
@@ -13,8 +13,24 @@ const inter = Inter({
export const metadata: Metadata = {
title: "Memento - Your Digital Notepad",
description: "A beautiful note-taking app inspired by Google Keep, built with Next.js 16",
manifest: "/manifest.json",
icons: {
icon: "/icons/icon-512.svg",
apple: "/icons/icon-512.svg",
},
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "Memento",
},
};
export const viewport: Viewport = {
themeColor: "#f59e0b",
};
export const dynamic = "force-dynamic";
export default function RootLayout({
children,
}: Readonly<{
@@ -23,19 +39,14 @@ export default function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<body className={inter.className}>
<ToastProvider>
<LabelProvider>
<div className="flex min-h-screen flex-col">
<HeaderWrapper />
<div className="flex flex-1">
<Sidebar className="shrink-0 border-r" />
<main className="flex-1">
{children}
</main>
</div>
</div>
</LabelProvider>
</ToastProvider>
<SessionProviderWrapper>
<NoteRefreshProvider>
<LabelProvider>
{children}
</LabelProvider>
</NoteRefreshProvider>
<Toaster />
</SessionProviderWrapper>
</body>
</html>
);

View File

@@ -1,11 +0,0 @@
import { LoginForm } from '@/components/login-form';
export default function LoginPage() {
return (
<main className="flex items-center justify-center md:h-screen">
<div className="relative mx-auto flex w-full max-w-[400px] flex-col space-y-2.5 p-4 md:-mt-32">
<LoginForm />
</div>
</main>
);
}

View File

@@ -1,11 +0,0 @@
import { RegisterForm } from '@/components/register-form';
export default function RegisterPage() {
return (
<main className="flex items-center justify-center md:h-screen">
<div className="relative mx-auto flex w-full max-w-[400px] flex-col space-y-2.5 p-4 md:-mt-32">
<RegisterForm />
</div>
</main>
);
}