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>
);
}

View File

@@ -13,10 +13,17 @@ export const authConfig = {
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user;
const isAdmin = (auth?.user as any)?.role === 'ADMIN';
const isDashboardPage = nextUrl.pathname === '/' ||
nextUrl.pathname.startsWith('/reminders') ||
nextUrl.pathname.startsWith('/archive') ||
nextUrl.pathname.startsWith('/trash');
nextUrl.pathname.startsWith('/trash') ||
nextUrl.pathname.startsWith('/settings');
const isAdminPage = nextUrl.pathname.startsWith('/admin');
if (isAdminPage) {
return isLoggedIn && isAdmin;
}
if (isDashboardPage) {
if (isLoggedIn) return true;
@@ -29,12 +36,14 @@ export const authConfig = {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.role = (user as any).role;
}
return token;
},
async session({ session, token }) {
if (token && session.user) {
(session.user as any).id = token.id;
(session.user as any).role = token.role;
}
return session;
},

View File

@@ -37,6 +37,7 @@ export const { auth, signIn, signOut, handlers } = NextAuth({
id: user.id,
email: user.email,
name: user.name,
role: user.role,
};
}

View File

@@ -0,0 +1,70 @@
'use client'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
interface Collaborator {
id: string
name: string | null
email: string
image: string | null
}
interface CollaboratorAvatarsProps {
collaborators: Collaborator[]
ownerId: string
maxDisplay?: number
}
export function CollaboratorAvatars({ collaborators, ownerId, maxDisplay = 5 }: CollaboratorAvatarsProps) {
if (collaborators.length === 0) return null
const displayCollaborators = collaborators.slice(0, maxDisplay)
const remainingCount = collaborators.length - maxDisplay
return (
<div className="flex items-center gap-1 mt-2">
<TooltipProvider>
{displayCollaborators.map((collaborator) => (
<Tooltip key={collaborator.id}>
<TooltipTrigger asChild>
<div className="relative group">
<Avatar className="h-6 w-6 border-2 border-background">
<AvatarImage src={collaborator.image || undefined} />
<AvatarFallback className="text-xs">
{collaborator.name?.charAt(0).toUpperCase() || collaborator.email.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
{collaborator.id === ownerId && (
<div className="absolute -bottom-1 -right-1">
<Badge variant="secondary" className="text-[8px] h-3 px-1 min-w-0">
Owner
</Badge>
</div>
)}
</div>
</TooltipTrigger>
<TooltipContent>
<p className="font-medium">{collaborator.name || 'Unnamed User'}</p>
<p className="text-xs text-muted-foreground">{collaborator.email}</p>
</TooltipContent>
</Tooltip>
))}
{remainingCount > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center text-xs border-2 border-background">
+{remainingCount}
</div>
</TooltipTrigger>
<TooltipContent>
<p>{remainingCount} more collaborator{remainingCount > 1 ? 's' : ''}</p>
</TooltipContent>
</Tooltip>
)}
</TooltipProvider>
</div>
)
}

View File

@@ -0,0 +1,330 @@
'use client'
import { useState, useTransition, useEffect, useRef } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { X, Loader2, Mail } from "lucide-react"
import { addCollaborator, removeCollaborator, getNoteCollaborators } from "@/app/actions/notes"
import { toast } from "sonner"
interface Collaborator {
id: string
name: string | null
email: string
image: string | null
}
interface CollaboratorDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
noteId: string
noteOwnerId: string
currentUserId: string
onCollaboratorsChange?: (collaboratorIds: string[]) => void
initialCollaborators?: string[]
}
export function CollaboratorDialog({
open,
onOpenChange,
noteId,
noteOwnerId,
currentUserId,
onCollaboratorsChange,
initialCollaborators = []
}: CollaboratorDialogProps) {
const router = useRouter()
const [collaborators, setCollaborators] = useState<Collaborator[]>([])
const [localCollaboratorIds, setLocalCollaboratorIds] = useState<string[]>(initialCollaborators)
const [email, setEmail] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [isPending, startTransition] = useTransition()
const [justAddedCollaborator, setJustAddedCollaborator] = useState(false)
const isOwner = currentUserId === noteOwnerId
const isCreationMode = !noteId
const hasLoadedRef = useRef(false)
// Load collaborators when dialog opens (only for existing notes)
const loadCollaborators = async () => {
if (isCreationMode) return
setIsLoading(true)
try {
const result = await getNoteCollaborators(noteId)
setCollaborators(result)
hasLoadedRef.current = true
} catch (error: any) {
toast.error(error.message || 'Error loading collaborators')
} finally {
setIsLoading(false)
}
}
// Load collaborators when dialog opens
useEffect(() => {
if (open && !isCreationMode && !hasLoadedRef.current && !isLoading) {
loadCollaborators()
}
// Reset when dialog closes
if (!open) {
hasLoadedRef.current = false
}
}, [open, isCreationMode])
// Sync initial collaborators when prop changes (creation mode)
useEffect(() => {
if (isCreationMode) {
setLocalCollaboratorIds(initialCollaborators)
}
}, [initialCollaborators, isCreationMode])
// Handle adding a collaborator
const handleAddCollaborator = async (e: React.FormEvent) => {
e.preventDefault()
if (!email.trim()) return
if (isCreationMode) {
// Creation mode: just add email as placeholder, will be resolved on note creation
if (!localCollaboratorIds.includes(email)) {
const newIds = [...localCollaboratorIds, email]
setLocalCollaboratorIds(newIds)
onCollaboratorsChange?.(newIds)
setEmail('')
toast.success(`${email} will be added as collaborator when note is created`)
} else {
toast.warning('This email is already in the list')
}
} else {
// Existing note mode: use server action
setJustAddedCollaborator(true)
startTransition(async () => {
try {
const result = await addCollaborator(noteId, email)
if (result.success) {
setCollaborators([...collaborators, result.user])
setEmail('')
toast.success(`${result.user.name || result.user.email} now has access to this note`)
// Don't refresh here - it would close the dialog!
// The collaborator list is already updated in local state
setJustAddedCollaborator(false)
}
} catch (error: any) {
toast.error(error.message || 'Failed to add collaborator')
setJustAddedCollaborator(false)
}
})
}
}
// Handle removing a collaborator
const handleRemoveCollaborator = async (userId: string) => {
if (isCreationMode) {
// Creation mode: remove from local list
const newIds = localCollaboratorIds.filter(id => id !== userId)
setLocalCollaboratorIds(newIds)
onCollaboratorsChange?.(newIds)
} else {
// Existing note mode: use server action
startTransition(async () => {
try {
await removeCollaborator(noteId, userId)
setCollaborators(collaborators.filter(c => c.id !== userId))
toast.success('Access has been revoked')
// Don't refresh here - it would close the dialog!
// The collaborator list is already updated in local state
} catch (error: any) {
toast.error(error.message || 'Failed to remove collaborator')
}
})
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="sm:max-w-md"
onInteractOutside={(event) => {
// Prevent dialog from closing when interacting with Sonner toasts
// Sonner uses data-sonner-toast NOT data-toast
const target = event.target as HTMLElement;
const isSonnerElement =
target.closest('[data-sonner-toast]') ||
target.closest('[data-sonner-toaster]') ||
target.closest('[data-icon]') ||
target.closest('[data-content]') ||
target.closest('[data-description]') ||
target.closest('[data-title]') ||
target.closest('[data-button]');
if (isSonnerElement) {
event.preventDefault();
return;
}
// Also prevent if target is the toast container itself
if (target.getAttribute('data-sonner-toaster') !== null) {
event.preventDefault();
return;
}
}}
>
<DialogHeader>
<DialogTitle>Share with collaborators</DialogTitle>
<DialogDescription>
{isOwner
? "Add people to collaborate on this note by their email address."
: "You have access to this note. Only the owner can manage collaborators."}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{isOwner && (
<form onSubmit={handleAddCollaborator} className="flex gap-2">
<div className="flex-1">
<Label htmlFor="email" className="sr-only">Email address</Label>
<Input
id="email"
type="email"
placeholder="Enter email address"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isPending}
/>
</div>
<Button type="submit" disabled={isPending || !email.trim()}>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Mail className="h-4 w-4 mr-2" />
Invite
</>
)}
</Button>
</form>
)}
<div className="space-y-2">
<Label>People with access</Label>
{isLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : isCreationMode ? (
// Creation mode: show emails
localCollaboratorIds.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No collaborators yet. Add someone above!
</p>
) : (
<div className="space-y-2">
{localCollaboratorIds.map((emailOrId, idx) => (
<div
key={idx}
data-testid="collaborator-item"
className="flex items-center justify-between p-2 rounded-lg border bg-muted/50"
>
<div className="flex items-center gap-3 flex-1">
<Avatar className="h-8 w-8">
<AvatarFallback>
{emailOrId.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
Pending Invite
</p>
<p className="text-xs text-muted-foreground truncate">
{emailOrId}
</p>
</div>
<Badge variant="outline" className="ml-2">Pending</Badge>
</div>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => handleRemoveCollaborator(emailOrId)}
disabled={isPending}
aria-label="Remove"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)
) : collaborators.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No collaborators yet. {isOwner && "Add someone above!"}
</p>
) : (
<div className="space-y-2">
{collaborators.map((collaborator) => (
<div
key={collaborator.id}
data-testid="collaborator-item"
className="flex items-center justify-between p-2 rounded-lg border bg-muted/50"
>
<div className="flex items-center gap-3 flex-1">
<Avatar className="h-8 w-8">
<AvatarImage src={collaborator.image || undefined} />
<AvatarFallback>
{collaborator.name?.charAt(0).toUpperCase() || collaborator.email.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{collaborator.name || 'Unnamed User'}
</p>
<p className="text-xs text-muted-foreground truncate">
{collaborator.email}
</p>
</div>
{collaborator.id === noteOwnerId && (
<Badge variant="secondary" className="ml-2">Owner</Badge>
)}
</div>
{isOwner && collaborator.id !== noteOwnerId && (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => handleRemoveCollaborator(collaborator.id)}
disabled={isPending}
aria-label="Remove"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Done
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,38 +1,48 @@
import React from 'react';
import { TagSuggestion } from '@/lib/ai/types';
import { Loader2, Sparkles, X } from 'lucide-react';
import { Loader2, Sparkles, X, CheckCircle } from 'lucide-react';
import { cn, getHashColor } from '@/lib/utils';
import { LABEL_COLORS } from '@/lib/types';
interface GhostTagsProps {
suggestions: TagSuggestion[];
addedTags: string[]; // Nouveauté : tags déjà présents sur la note
isAnalyzing: boolean;
onSelectTag: (tag: string) => void;
onDismissTag: (tag: string) => void;
className?: string;
}
export function GhostTags({ suggestions, isAnalyzing, onSelectTag, onDismissTag, className }: GhostTagsProps) {
console.log('GhostTags Render:', { count: suggestions.length, isAnalyzing, suggestions });
// On n'affiche rien si pas d'analyse et pas de suggestions
if (!isAnalyzing && suggestions.length === 0) return null;
export function GhostTags({ suggestions, addedTags, isAnalyzing, onSelectTag, onDismissTag, className }: GhostTagsProps) {
// On filtre pour l'affichage conditionnel global, mais on garde les tags ajoutés pour l'affichage visuel "validé"
const visibleSuggestions = suggestions;
if (!isAnalyzing && visibleSuggestions.length === 0) return null;
return (
<div className={cn("flex flex-wrap items-center gap-2 mt-2 min-h-[24px] transition-all duration-500", className)}>
{/* Indicateur IA discret */}
{isAnalyzing && (
<div className="flex items-center text-purple-500 animate-pulse" title="IA en cours d'analyse...">
<Sparkles className="w-4 h-4" />
</div>
)}
{/* Liste des suggestions */}
{!isAnalyzing && suggestions.map((suggestion) => {
{!isAnalyzing && visibleSuggestions.map((suggestion) => {
const isAdded = addedTags.some(t => t.toLowerCase() === suggestion.tag.toLowerCase());
const colorName = getHashColor(suggestion.tag);
const colorClasses = LABEL_COLORS[colorName];
if (isAdded) {
// Tag déjà ajouté : on l'affiche en mode "confirmé" statique pour ne pas perdre le focus
return (
<div key={suggestion.tag} className={cn("flex items-center px-3 py-1 text-xs font-medium border rounded-full opacity-50 cursor-default", colorClasses.bg, colorClasses.text, colorClasses.border)}>
<CheckCircle className="w-3 h-3 mr-1.5" />
{suggestion.tag}
</div>
)
}
return (
<div
key={suggestion.tag}

View File

@@ -1,14 +1,16 @@
'use client'
import { Suspense } from 'react'
import { Header } from './header'
import { useSearchParams, useRouter } from 'next/navigation'
import { useLabels } from '@/context/LabelContext'
interface HeaderWrapperProps {
onColorFilterChange?: (color: string | null) => void
user?: any
}
export function HeaderWrapper({ onColorFilterChange }: HeaderWrapperProps) {
function HeaderContent({ onColorFilterChange, user }: HeaderWrapperProps) {
const searchParams = useSearchParams()
const router = useRouter()
const { labels } = useLabels()
@@ -47,6 +49,15 @@ export function HeaderWrapper({ onColorFilterChange }: HeaderWrapperProps) {
selectedColor={selectedColor}
onLabelFilterChange={handleLabelFilterChange}
onColorFilterChange={handleColorFilterChange}
user={user}
/>
)
}
export function HeaderWrapper(props: HeaderWrapperProps) {
return (
<Suspense fallback={<div className="h-16 border-b bg-background/95" />}>
<HeaderContent {...props} />
</Suspense>
)
}

View File

@@ -17,26 +17,31 @@ import {
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet'
import { Menu, Search, StickyNote, Tag, Moon, Sun, X, Bell, Trash2, Archive } from 'lucide-react'
import { Menu, Search, StickyNote, Tag, Moon, Sun, X, Bell, Trash2, Archive, Coffee } from 'lucide-react'
import Link from 'next/link'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { cn } from '@/lib/utils'
import { useLabels } from '@/context/LabelContext'
import { LabelManagementDialog } from './label-management-dialog'
import { LabelFilter } from './label-filter'
import { NotificationPanel } from './notification-panel'
import { UserNav } from './user-nav'
import { updateTheme } from '@/app/actions/profile'
interface HeaderProps {
selectedLabels?: string[]
selectedColor?: string | null
onLabelFilterChange?: (labels: string[]) => void
onColorFilterChange?: (color: string | null) => void
user?: any
}
export function Header({
selectedLabels = [],
selectedColor = null,
onLabelFilterChange,
onColorFilterChange
onColorFilterChange,
user
}: HeaderProps = {}) {
const [searchQuery, setSearchQuery] = useState('')
const [theme, setTheme] = useState<'light' | 'dark'>('light')
@@ -55,11 +60,12 @@ export function Header({
}, [currentSearch])
useEffect(() => {
const savedTheme = localStorage.getItem('theme') || 'light'
applyTheme(savedTheme)
}, [])
const savedTheme = user?.theme || localStorage.getItem('theme') || 'light'
// Don't persist on initial load to avoid unnecessary DB calls
applyTheme(savedTheme, false)
}, [user])
const applyTheme = (newTheme: string) => {
const applyTheme = async (newTheme: string, persist = true) => {
setTheme(newTheme as any)
localStorage.setItem('theme', newTheme)
@@ -75,6 +81,10 @@ export function Header({
document.documentElement.classList.add('dark')
}
}
if (persist && user) {
await updateTheme(newTheme)
}
}
const handleSearch = (query: string) => {
@@ -130,21 +140,59 @@ export function Header({
router.push(`/?${params.toString()}`)
}
const NavItem = ({ href, icon: Icon, label, active }: any) => (
<Link
href={href}
onClick={() => setIsSidebarOpen(false)}
className={cn(
"flex items-center gap-3 px-4 py-3 rounded-r-full text-sm font-medium transition-colors mr-2",
active
? "bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-100"
: "hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-700 dark:text-gray-300"
)}
>
<Icon className={cn("h-5 w-5", active && "fill-current")} />
{label}
</Link>
)
const toggleLabelFilter = (labelName: string) => {
const newLabels = currentLabels.includes(labelName)
? currentLabels.filter(l => l !== labelName)
: [...currentLabels, labelName]
const params = new URLSearchParams(searchParams.toString())
if (newLabels.length > 0) {
params.set('labels', newLabels.join(','))
} else {
params.delete('labels')
}
router.push(`/?${params.toString()}`)
}
const NavItem = ({ href, icon: Icon, label, active, onClick }: any) => {
const content = (
<>
<Icon className={cn("h-5 w-5", active && "fill-current")} />
{label}
</>
)
if (onClick) {
return (
<button
onClick={onClick}
className={cn(
"w-full flex items-center gap-3 px-4 py-3 rounded-r-full text-sm font-medium transition-colors mr-2 text-left",
active
? "bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-100"
: "hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-700 dark:text-gray-300"
)}
>
{content}
</button>
)
}
return (
<Link
href={href}
onClick={() => setIsSidebarOpen(false)}
className={cn(
"flex items-center gap-3 px-4 py-3 rounded-r-full text-sm font-medium transition-colors mr-2",
active
? "bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-100"
: "hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-700 dark:text-gray-300"
)}
>
{content}
</Link>
)
}
const hasActiveFilters = currentLabels.length > 0 || !!currentSearch || !!currentColor
@@ -188,26 +236,32 @@ export function Header({
{labels.map(label => (
<NavItem
key={label.id}
href={`/?labels=${encodeURIComponent(label.name)}`}
icon={Tag}
label={label.name}
active={currentLabels.includes(label.name)}
onClick={() => toggleLabelFilter(label.name)}
/>
))}
<div className="my-2 border-t border-gray-200 dark:border-zinc-800" />
<NavItem
href="/archive"
icon={Archive}
label="Archive"
active={pathname === '/archive'}
<NavItem
href="/archive"
icon={Archive}
label="Archive"
active={pathname === '/archive'}
/>
<NavItem
href="/trash"
icon={Trash2}
label="Trash"
active={pathname === '/trash'}
<NavItem
href="/trash"
icon={Trash2}
label="Trash"
active={pathname === '/trash'}
/>
<NavItem
href="/support"
icon={Coffee}
label="Support ☕"
active={pathname === '/support'}
/>
</div>
</SheetContent>
@@ -241,9 +295,7 @@ export function Header({
<div className="absolute right-0 top-0 h-full flex items-center pr-2">
<LabelFilter
selectedLabels={currentLabels}
selectedColor={currentColor || null}
onFilterChange={handleFilterChange}
onColorChange={handleColorChange}
/>
</div>
</div>
@@ -262,6 +314,9 @@ export function Header({
<DropdownMenuItem onClick={() => applyTheme('sepia')}>Sepia</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<NotificationPanel />
<UserNav user={user} />
</div>
</div>

View File

@@ -5,27 +5,23 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuCheckboxItem,
DropdownMenuSeparator,
DropdownMenuLabel,
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Filter } from 'lucide-react'
import { LABEL_COLORS } from '@/lib/types'
import { Filter, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLabels } from '@/context/LabelContext'
import { LabelBadge } from './label-badge'
interface LabelFilterProps {
selectedLabels: string[]
selectedColor?: string | null
onFilterChange: (labels: string[]) => void
onColorChange?: (color: string | null) => void
}
export function LabelFilter({ selectedLabels, selectedColor, onFilterChange, onColorChange }: LabelFilterProps) {
const { labels, loading, getLabelColor } = useLabels()
export function LabelFilter({ selectedLabels, onFilterChange }: LabelFilterProps) {
const { labels, loading } = useLabels()
const [allLabelNames, setAllLabelNames] = useState<string[]>([])
useEffect(() => {
@@ -43,15 +39,6 @@ export function LabelFilter({ selectedLabels, selectedColor, onFilterChange, onC
const handleClearAll = () => {
onFilterChange([])
onColorChange?.(null)
}
const handleColorFilter = (color: string) => {
if (selectedColor === color) {
onColorChange?.(null)
} else {
onColorChange?.(color)
}
}
if (loading || allLabelNames.length === 0) return null
@@ -86,56 +73,35 @@ export function LabelFilter({ selectedLabels, selectedColor, onFilterChange, onC
</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* Color Filter */}
<div className="p-2">
<p className="text-xs font-medium mb-2 text-gray-600 dark:text-gray-400">Filter by Color</p>
<div className="grid grid-cols-3 gap-2">
{Object.entries(LABEL_COLORS).map(([colorName, colorClasses]) => {
const isSelected = selectedColor === colorName
const labelCount = labels.filter((l: any) => l.color === colorName).length
return (
<button
key={colorName}
onClick={() => handleColorFilter(colorName)}
className={cn(
'flex items-center gap-2 p-2 rounded-lg border-2 transition-all hover:scale-105',
isSelected ? 'ring-2 ring-blue-500' : 'border-gray-300 dark:border-gray-600'
)}
>
<div
className={cn(
'w-6 h-6 rounded-full border-2',
colorClasses.bg,
isSelected ? 'border-blue-500 dark:border-blue-400' : colorClasses.border
)}
/>
<span className="text-xs ml-2">{labelCount}</span>
</button>
)
})}
</div>
</div>
<DropdownMenuSeparator />
{/* Label Filters */}
{!loading && allLabelNames.map((labelName: string) => {
const isSelected = selectedLabels.includes(labelName)
const isColorFiltered = selectedColor && selectedColor !== 'gray'
<div className="max-h-64 overflow-y-auto px-1 pb-1">
{!loading && allLabelNames.map((labelName: string) => {
const isSelected = selectedLabels.includes(labelName)
return (
<DropdownMenuCheckboxItem
key={labelName}
checked={isSelected && !isColorFiltered}
onCheckedChange={() => handleToggleLabel(labelName)}
>
<LabelBadge
label={labelName}
isDisabled={!!isColorFiltered}
/>
</DropdownMenuCheckboxItem>
)
})}
return (
<div
key={labelName}
onClick={(e) => {
e.preventDefault()
handleToggleLabel(labelName)
}}
className={cn(
"flex items-center gap-2 p-2 rounded-sm cursor-pointer text-sm hover:bg-accent hover:text-accent-foreground"
)}
>
<div className={cn(
"flex h-4 w-4 items-center justify-center rounded border border-primary",
isSelected ? "bg-primary text-primary-foreground" : "opacity-50 [&_svg]:invisible"
)}>
<Check className="h-3 w-3" />
</div>
<LabelBadge
label={labelName}
/>
</div>
)
})}
</div>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -60,7 +60,32 @@ export function LabelManagementDialog() {
<Settings className="h-5 w-5" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogContent
className="max-w-md"
onInteractOutside={(event) => {
// Prevent dialog from closing when interacting with Sonner toasts
const target = event.target as HTMLElement;
const isSonnerElement =
target.closest('[data-sonner-toast]') ||
target.closest('[data-sonner-toaster]') ||
target.closest('[data-icon]') ||
target.closest('[data-content]') ||
target.closest('[data-description]') ||
target.closest('[data-title]') ||
target.closest('[data-button]');
if (isSonnerElement) {
event.preventDefault();
return;
}
if (target.getAttribute('data-sonner-toaster') !== null) {
event.preventDefault();
return;
}
}}
>
<DialogHeader>
<DialogTitle>Edit Labels</DialogTitle>
<DialogDescription>

View File

@@ -102,7 +102,32 @@ export function LabelManager({ existingLabels, onUpdate }: LabelManagerProps) {
Labels
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogContent
className="max-w-md"
onInteractOutside={(event) => {
// Prevent dialog from closing when interacting with Sonner toasts
const target = event.target as HTMLElement;
const isSonnerElement =
target.closest('[data-sonner-toast]') ||
target.closest('[data-sonner-toaster]') ||
target.closest('[data-icon]') ||
target.closest('[data-content]') ||
target.closest('[data-description]') ||
target.closest('[data-title]') ||
target.closest('[data-button]');
if (isSonnerElement) {
event.preventDefault();
return;
}
if (target.getAttribute('data-sonner-toaster') !== null) {
event.preventDefault();
return;
}
}}
>
<DialogHeader>
<DialogTitle>Manage Labels</DialogTitle>
<DialogDescription>

View File

@@ -94,7 +94,7 @@ export function LabelSelector({
e.preventDefault()
handleToggleLabel(label.name)
}}
className="flex items-center gap-2 p-2 rounded-sm hover:bg-gray-100 dark:hover:bg-zinc-800 cursor-pointer text-sm"
className="flex items-center gap-2 p-2 rounded-sm cursor-pointer text-sm hover:bg-accent hover:text-accent-foreground"
>
<div className={cn(
"h-4 w-4 border rounded flex items-center justify-center transition-colors",
@@ -113,7 +113,7 @@ export function LabelSelector({
e.preventDefault()
handleCreateLabel()
}}
className="flex items-center gap-2 p-2 rounded-sm hover:bg-gray-100 dark:hover:bg-zinc-800 cursor-pointer text-sm border-t mt-1"
className="flex items-center gap-2 p-2 rounded-sm cursor-pointer text-sm border-t mt-1 font-medium hover:bg-accent hover:text-accent-foreground"
>
<Plus className="h-4 w-4" />
<span>Create "{search}"</span>

View File

@@ -16,7 +16,7 @@ function LoginButton() {
);
}
export function LoginForm() {
export function LoginForm({ allowRegister = true }: { allowRegister?: boolean }) {
const [errorMessage, dispatch] = useActionState(authenticate, undefined);
return (
@@ -64,6 +64,14 @@ export function LoginForm() {
</div>
</div>
</div>
<div className="flex items-center justify-end mt-2">
<Link
href="/forgot-password"
className="text-xs text-gray-500 hover:text-gray-900 underline"
>
Forgot password?
</Link>
</div>
<LoginButton />
<div
className="flex h-8 items-end space-x-1"
@@ -74,12 +82,14 @@ export function LoginForm() {
<p className="text-sm text-red-500">{errorMessage}</p>
)}
</div>
<div className="mt-4 text-center text-sm">
Don't have an account?{' '}
<Link href="/register" className="underline">
Register
</Link>
</div>
{allowRegister && (
<div className="mt-4 text-center text-sm">
Don't have an account?{' '}
<Link href="/register" className="underline">
Register
</Link>
</div>
)}
</div>
</form>
);

View File

@@ -2,6 +2,9 @@
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import 'katex/dist/katex.min.css'
interface MarkdownContentProps {
content: string
@@ -10,8 +13,16 @@ interface MarkdownContentProps {
export function MarkdownContent({ content, className = '' }: MarkdownContentProps) {
return (
<div className={`prose prose-sm dark:prose-invert max-w-none ${className}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
<div className={`prose prose-sm prose-compact dark:prose-invert max-w-none break-words ${className}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
a: ({ node, ...props }) => (
<a {...props} className="text-blue-500 hover:underline" target="_blank" rel="noopener noreferrer" />
)
}}
>
{content}
</ReactMarkdown>
</div>

View File

@@ -1,8 +1,8 @@
'use client';
'use client'
import { useState, useEffect, useRef, useCallback, memo } from 'react';
import { Note } from '@/lib/types';
import { NoteCard } from './note-card';
import { useState, useEffect, useRef, useCallback } from 'react';
import { NoteEditor } from './note-editor';
import { updateFullOrder } from '@/app/actions/notes';
import { useResizeObserver } from '@/hooks/use-resize-observer';
@@ -13,18 +13,32 @@ interface MasonryGridProps {
interface MasonryItemProps {
note: Note;
onEdit: (note: Note) => void;
onEdit: (note: Note, readOnly?: boolean) => void;
onResize: () => void;
}
function MasonryItem({ note, onEdit, onResize }: MasonryItemProps) {
function getSizeClasses(size: string = 'small') {
switch (size) {
case 'medium':
return 'w-full sm:w-full lg:w-2/3 xl:w-2/4 2xl:w-2/5';
case 'large':
return 'w-full';
case 'small':
default:
return 'w-full sm:w-1/2 lg:w-1/3 xl:w-1/4 2xl:w-1/5';
}
}
const MasonryItem = memo(function MasonryItem({ note, onEdit, onResize }: MasonryItemProps) {
const resizeRef = useResizeObserver(() => {
onResize();
});
const sizeClasses = getSizeClasses(note.size);
return (
<div
className="masonry-item absolute w-full sm:w-1/2 lg:w-1/3 xl:w-1/4 2xl:w-1/5 p-2"
className={`masonry-item absolute p-2 ${sizeClasses}`}
data-id={note.id}
ref={resizeRef as any}
>
@@ -33,20 +47,28 @@ function MasonryItem({ note, onEdit, onResize }: MasonryItemProps) {
</div>
</div>
);
}
}, (prev, next) => {
// Custom comparison to avoid re-render on function prop changes if note data is same
return prev.note === next.note;
});
export function MasonryGrid({ notes }: MasonryGridProps) {
const [editingNote, setEditingNote] = useState<Note | null>(null);
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null);
const pinnedGridRef = useRef<HTMLDivElement>(null);
const othersGridRef = useRef<HTMLDivElement>(null);
const pinnedMuuri = useRef<any>(null);
const othersMuuri = useRef<any>(null);
const isDraggingRef = useRef(false);
const pinnedNotes = notes.filter(n => n.isPinned).sort((a, b) => a.order - b.order);
const othersNotes = notes.filter(n => !n.isPinned).sort((a, b) => a.order - b.order);
const handleDragEnd = async (grid: any) => {
if (!grid) return;
// Prevent layout refresh during server update
isDraggingRef.current = true;
const items = grid.getItems();
const ids = items
.map((item: any) => item.getElement()?.getAttribute('data-id'))
@@ -56,6 +78,11 @@ export function MasonryGrid({ notes }: MasonryGridProps) {
await updateFullOrder(ids);
} catch (error) {
console.error('Failed to persist order:', error);
} finally {
// Reset after animation/server roundtrip
setTimeout(() => {
isDraggingRef.current = false;
}, 1000);
}
};
@@ -82,8 +109,13 @@ export function MasonryGrid({ notes }: MasonryGridProps) {
if (!isMounted) return;
// Detect if we are on a touch device (mobile behavior)
const isMobile = window.matchMedia('(pointer: coarse)').matches;
const layoutOptions = {
dragEnabled: true,
// On mobile, restrict drag to handle to allow scrolling. On desktop, allow drag from anywhere.
dragHandle: isMobile ? '.drag-handle' : undefined,
dragContainer: document.body,
dragStartPredicate: {
distance: 10,
@@ -125,10 +157,12 @@ export function MasonryGrid({ notes }: MasonryGridProps) {
pinnedMuuri.current = null;
othersMuuri.current = null;
};
}, [pinnedNotes.length, othersNotes.length]);
}, [pinnedNotes.length > 0, othersNotes.length > 0]);
// Synchronize items when notes change (e.g. searching, adding)
useEffect(() => {
if (isDraggingRef.current) return;
if (pinnedMuuri.current) {
pinnedMuuri.current.refreshItems().layout();
}
@@ -144,10 +178,10 @@ export function MasonryGrid({ notes }: MasonryGridProps) {
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3 px-2">Pinned</h2>
<div ref={pinnedGridRef} className="relative min-h-[100px]">
{pinnedNotes.map(note => (
<MasonryItem
key={note.id}
note={note}
onEdit={setEditingNote}
<MasonryItem
key={note.id}
note={note}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
onResize={refreshLayout}
/>
))}
@@ -162,10 +196,10 @@ export function MasonryGrid({ notes }: MasonryGridProps) {
)}
<div ref={othersGridRef} className="relative min-h-[100px]">
{othersNotes.map(note => (
<MasonryItem
key={note.id}
note={note}
onEdit={setEditingNote}
<MasonryItem
key={note.id}
note={note}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
onResize={refreshLayout}
/>
))}
@@ -174,7 +208,11 @@ export function MasonryGrid({ notes }: MasonryGridProps) {
)}
{editingNote && (
<NoteEditor note={editingNote} onClose={() => setEditingNote(null)} />
<NoteEditor
note={editingNote.note}
readOnly={editingNote.readOnly}
onClose={() => setEditingNote(null)}
/>
)}
<style jsx global>{`

View File

@@ -13,6 +13,8 @@ import {
Palette,
Pin,
Trash2,
Users,
Maximize2,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { NOTE_COLORS } from "@/lib/types"
@@ -21,10 +23,13 @@ interface NoteActionsProps {
isPinned: boolean
isArchived: boolean
currentColor: string
currentSize?: 'small' | 'medium' | 'large'
onTogglePin: () => void
onToggleArchive: () => void
onColorChange: (color: string) => void
onSizeChange?: (size: 'small' | 'medium' | 'large') => void
onDelete: () => void
onShareCollaborators?: () => void
className?: string
}
@@ -32,10 +37,13 @@ export function NoteActions({
isPinned,
isArchived,
currentColor,
currentSize = 'small',
onTogglePin,
onToggleArchive,
onColorChange,
onSizeChange,
onDelete,
onShareCollaborators,
className
}: NoteActionsProps) {
return (
@@ -43,17 +51,6 @@ export function NoteActions({
className={cn("flex items-center justify-end gap-1", className)}
onClick={(e) => e.stopPropagation()}
>
{/* Pin Button */}
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={onTogglePin}
title={isPinned ? 'Unpin' : 'Pin'}
>
<Pin className={cn('h-4 w-4', isPinned && 'fill-current')} />
</Button>
{/* Color Palette */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -82,7 +79,7 @@ export function NoteActions({
{/* More Options */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" aria-label="More options">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@@ -100,6 +97,46 @@ export function NoteActions({
</>
)}
</DropdownMenuItem>
{/* Size Selector */}
{onSizeChange && (
<>
<DropdownMenuSeparator />
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
Size
</div>
{(['small', 'medium', 'large'] as const).map((size) => (
<DropdownMenuItem
key={size}
onClick={() => onSizeChange(size)}
className={cn(
"capitalize",
currentSize === size && "bg-accent"
)}
>
<Maximize2 className="h-4 w-4 mr-2" />
{size}
</DropdownMenuItem>
))}
</>
)}
{/* Collaborators */}
{onShareCollaborators && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
onShareCollaborators()
}}
>
<Users className="h-4 w-4 mr-2" />
Share with collaborators
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onDelete} className="text-red-600 dark:text-red-400">
<Trash2 className="h-4 w-4 mr-2" />

View File

@@ -2,9 +2,12 @@
import { Note, NOTE_COLORS, NoteColor } from '@/lib/types'
import { Card } from '@/components/ui/card'
import { Pin, Bell } from 'lucide-react'
import { useState } from 'react'
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote } from '@/app/actions/notes'
import { Button } from '@/components/ui/button'
import { Pin, Bell, GripVertical, X } from 'lucide-react'
import { useState, useEffect, useTransition, useOptimistic } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote, getNoteAllUsers, leaveSharedNote } from '@/app/actions/notes'
import { cn } from '@/lib/utils'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale'
@@ -13,20 +16,60 @@ import { LabelBadge } from './label-badge'
import { NoteImages } from './note-images'
import { NoteChecklist } from './note-checklist'
import { NoteActions } from './note-actions'
import { CollaboratorDialog } from './collaborator-dialog'
import { CollaboratorAvatars } from './collaborator-avatars'
import { useLabels } from '@/context/LabelContext'
interface NoteCardProps {
note: Note
onEdit?: (note: Note) => void
onEdit?: (note: Note, readOnly?: boolean) => void
isDragging?: boolean
isDragOver?: boolean
}
export function NoteCard({ note, onEdit, isDragging, isDragOver }: NoteCardProps) {
const router = useRouter()
const { refreshLabels } = useLabels()
const { data: session } = useSession()
const [isPending, startTransition] = useTransition()
const [isDeleting, setIsDeleting] = useState(false)
const [showCollaboratorDialog, setShowCollaboratorDialog] = useState(false)
const [collaborators, setCollaborators] = useState<any[]>([])
const [owner, setOwner] = useState<any>(null)
const colorClasses = NOTE_COLORS[note.color as NoteColor] || NOTE_COLORS.default
// Optimistic UI state for instant feedback
const [optimisticNote, addOptimisticNote] = useOptimistic(
note,
(state, newProps: Partial<Note>) => ({ ...state, ...newProps })
)
const currentUserId = session?.user?.id
const canManageCollaborators = currentUserId && note.userId && currentUserId === note.userId
const isSharedNote = currentUserId && note.userId && currentUserId !== note.userId
const isOwner = currentUserId && note.userId && currentUserId === note.userId
// Load collaborators when note changes
useEffect(() => {
const loadCollaborators = async () => {
if (note.userId) {
try {
const users = await getNoteAllUsers(note.id)
setCollaborators(users)
// Owner is always first in the list
if (users.length > 0) {
setOwner(users[0])
}
} catch (error) {
console.error('Failed to load collaborators:', error)
setCollaborators([])
}
}
}
loadCollaborators()
}, [note.id, note.userId])
const handleDelete = async () => {
if (confirm('Are you sure you want to delete this note?')) {
setIsDeleting(true)
@@ -42,15 +85,35 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver }: NoteCardProps
}
const handleTogglePin = async () => {
await togglePin(note.id, !note.isPinned)
startTransition(async () => {
addOptimisticNote({ isPinned: !note.isPinned })
await togglePin(note.id, !note.isPinned)
router.refresh()
})
}
const handleToggleArchive = async () => {
await toggleArchive(note.id, !note.isArchived)
startTransition(async () => {
addOptimisticNote({ isArchived: !note.isArchived })
await toggleArchive(note.id, !note.isArchived)
router.refresh()
})
}
const handleColorChange = async (color: string) => {
await updateColor(note.id, color)
startTransition(async () => {
addOptimisticNote({ color })
await updateColor(note.id, color)
router.refresh()
})
}
const handleSizeChange = async (size: 'small' | 'medium' | 'large') => {
startTransition(async () => {
addOptimisticNote({ size })
await updateNote(note.id, { size })
router.refresh()
})
}
const handleCheckItem = async (checkItemId: string) => {
@@ -58,7 +121,22 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver }: NoteCardProps
const updatedItems = note.checkItems.map(item =>
item.id === checkItemId ? { ...item, checked: !item.checked } : item
)
await updateNote(note.id, { checkItems: updatedItems })
startTransition(async () => {
addOptimisticNote({ checkItems: updatedItems })
await updateNote(note.id, { checkItems: updatedItems })
router.refresh()
})
}
}
const handleLeaveShare = async () => {
if (confirm('Are you sure you want to leave this shared note?')) {
try {
await leaveSharedNote(note.id)
setIsDeleting(true) // Hide the note from view
} catch (error) {
console.error('Failed to leave share:', error)
}
}
}
@@ -66,6 +144,7 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver }: NoteCardProps
return (
<Card
data-testid="note-card"
className={cn(
'note-card-main group relative p-4 transition-all duration-200 border cursor-move',
'hover:shadow-md',
@@ -78,40 +157,79 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver }: NoteCardProps
onClick={(e) => {
// Only trigger edit if not clicking on buttons
const target = e.target as HTMLElement
if (!target.closest('button') && !target.closest('[role="checkbox"]')) {
onEdit?.(note)
if (!target.closest('button') && !target.closest('[role="checkbox"]') && !target.closest('.drag-handle')) {
// For shared notes, pass readOnly flag
onEdit?.(note, !!isSharedNote) // Pass second parameter as readOnly flag (convert to boolean)
}
}}
>
{/* Pin Icon */}
{note.isPinned && (
<Pin className="absolute top-3 right-3 h-4 w-4 text-gray-600 dark:text-gray-400" fill="currentColor" />
)}
{/* Drag Handle - Visible only on mobile/touch devices */}
<div className="absolute top-2 left-2 z-20 md:hidden cursor-grab active:cursor-grabbing drag-handle touch-none">
<GripVertical className="h-4 w-4 text-gray-400 dark:text-gray-500" />
</div>
{/* Reminder Icon */}
{/* Pin Button - Visible on hover or if pinned, always accessible */}
<Button
variant="ghost"
size="sm"
className={cn(
"absolute top-2 right-2 z-20 h-8 w-8 p-0 rounded-full transition-opacity",
optimisticNote.isPinned ? "opacity-100" : "opacity-0 group-hover:opacity-100",
"md:flex", // On desktop follow hover logic
"flex" // Ensure it's a flex container for the icon
)}
onClick={(e) => {
e.stopPropagation();
handleTogglePin();
}}
>
<Pin
className={cn("h-4 w-4", optimisticNote.isPinned ? "fill-current text-blue-600" : "text-gray-400")}
/>
</Button>
{/* Reminder Icon - Move slightly if pin button is there */}
{note.reminder && new Date(note.reminder) > new Date() && (
<Bell
className={cn(
"absolute h-4 w-4 text-blue-600 dark:text-blue-400",
note.isPinned ? "top-3 right-9" : "top-3 right-3"
)}
className="absolute top-3 right-10 h-4 w-4 text-blue-600 dark:text-blue-400"
/>
)}
{/* Title */}
{note.title && (
<h3 className="text-base font-medium mb-2 pr-6 text-gray-900 dark:text-gray-100">
{note.title}
{optimisticNote.title && (
<h3 className="text-base font-medium mb-2 pr-10 text-gray-900 dark:text-gray-100">
{optimisticNote.title}
</h3>
)}
{/* Shared badge */}
{isSharedNote && owner && (
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium">
Shared by {owner.name || owner.email}
</span>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-gray-500 hover:text-red-600 dark:hover:text-red-400"
onClick={(e) => {
e.stopPropagation()
handleLeaveShare()
}}
>
<X className="h-3 w-3 mr-1" />
Leave
</Button>
</div>
)}
{/* Images Component */}
<NoteImages images={note.images || []} title={note.title} />
<NoteImages images={optimisticNote.images || []} title={optimisticNote.title} />
{/* Link Previews */}
{note.links && note.links.length > 0 && (
{optimisticNote.links && optimisticNote.links.length > 0 && (
<div className="flex flex-col gap-2 mb-2">
{note.links.map((link, idx) => (
{optimisticNote.links.map((link, idx) => (
<a
key={idx}
href={link.url}
@@ -136,48 +254,68 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver }: NoteCardProps
)}
{/* Content */}
{note.type === 'text' ? (
note.isMarkdown ? (
<div className="text-sm line-clamp-10">
<MarkdownContent content={note.content} />
</div>
) : (
<p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap line-clamp-10">
{note.content}
</p>
)
{optimisticNote.type === 'text' ? (
<div className="text-sm text-gray-700 dark:text-gray-300 line-clamp-10">
<MarkdownContent content={optimisticNote.content} />
</div>
) : (
<NoteChecklist
items={note.checkItems || []}
onToggleItem={handleCheckItem}
<NoteChecklist
items={optimisticNote.checkItems || []}
onToggleItem={handleCheckItem}
/>
)}
{/* Labels */}
{note.labels && note.labels.length > 0 && (
{optimisticNote.labels && optimisticNote.labels.length > 0 && (
<div className="flex flex-wrap gap-1 mt-3">
{note.labels.map((label) => (
{optimisticNote.labels.map((label) => (
<LabelBadge key={label} label={label} />
))}
</div>
)}
{/* Collaborators */}
{optimisticNote.userId && collaborators.length > 0 && (
<CollaboratorAvatars
collaborators={collaborators}
ownerId={optimisticNote.userId}
/>
)}
{/* Creation Date */}
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: fr })}
</div>
{/* Action Bar Component */}
<NoteActions
isPinned={note.isPinned}
isArchived={note.isArchived}
currentColor={note.color}
onTogglePin={handleTogglePin}
onToggleArchive={handleToggleArchive}
onColorChange={handleColorChange}
onDelete={handleDelete}
className="absolute bottom-0 left-0 right-0 p-2 opacity-0 group-hover:opacity-100 transition-opacity"
/>
{/* Action Bar Component - Only for owner */}
{isOwner && (
<NoteActions
isPinned={optimisticNote.isPinned}
isArchived={optimisticNote.isArchived}
currentColor={optimisticNote.color}
currentSize={optimisticNote.size}
onTogglePin={handleTogglePin}
onToggleArchive={handleToggleArchive}
onColorChange={handleColorChange}
onSizeChange={handleSizeChange}
onDelete={handleDelete}
onShareCollaborators={() => setShowCollaboratorDialog(true)}
className="absolute bottom-0 left-0 right-0 p-2 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity"
/>
)}
{/* Collaborator Dialog */}
{currentUserId && note.userId && (
<div onClick={(e) => e.stopPropagation()}>
<CollaboratorDialog
open={showCollaboratorDialog}
onOpenChange={setShowCollaboratorDialog}
noteId={note.id}
noteOwnerId={note.userId}
currentUserId={currentUserId}
/>
</div>
)}
</Card>
)
}

View File

@@ -18,11 +18,11 @@ import {
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { X, Plus, Palette, Image as ImageIcon, Bell, FileText, Eye, Link as LinkIcon, Sparkles } from 'lucide-react'
import { updateNote } from '@/app/actions/notes'
import { X, Plus, Palette, Image as ImageIcon, Bell, FileText, Eye, Link as LinkIcon, Sparkles, Maximize2, Copy } from 'lucide-react'
import { updateNote, createNote } from '@/app/actions/notes'
import { fetchLinkMetadata } from '@/app/actions/scrape'
import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/toast'
import { toast } from 'sonner'
import { MarkdownContent } from './markdown-content'
import { LabelManager } from './label-manager'
import { LabelBadge } from './label-badge'
@@ -31,14 +31,16 @@ import { EditorImages } from './editor-images'
import { useAutoTagging } from '@/hooks/use-auto-tagging'
import { GhostTags } from './ghost-tags'
import { useLabels } from '@/context/LabelContext'
import { NoteSize } from '@/lib/types'
import { Badge } from '@/components/ui/badge'
interface NoteEditorProps {
note: Note
readOnly?: boolean
onClose: () => void
}
export function NoteEditor({ note, onClose }: NoteEditorProps) {
const { addToast } = useToast()
export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps) {
const { labels: globalLabels, addLabel, refreshLabels } = useLabels()
const [title, setTitle] = useState(note.title || '')
const [content, setContent] = useState(note.content)
@@ -48,9 +50,10 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
const [links, setLinks] = useState<LinkMetadata[]>(note.links || [])
const [newLabel, setNewLabel] = useState('')
const [color, setColor] = useState(note.color)
const [size, setSize] = useState<NoteSize>(note.size || 'small')
const [isSaving, setIsSaving] = useState(false)
const [isMarkdown, setIsMarkdown] = useState(note.isMarkdown || false)
const [showMarkdownPreview, setShowMarkdownPreview] = useState(false)
const [showMarkdownPreview, setShowMarkdownPreview] = useState(note.isMarkdown || false)
const fileInputRef = useRef<HTMLInputElement>(null)
// Auto-tagging hook
@@ -88,7 +91,7 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
console.error('Erreur création label auto:', err)
}
}
addToast(`Tag "${tag}" ajouté`, 'success')
toast.success(`Tag "${tag}" ajouté`)
}
}
@@ -96,11 +99,11 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
setDismissedTags(prev => [...prev, tag])
}
// Filtrer les suggestions pour ne pas afficher celles rejetées ou déjà ajoutées (insensible à la casse)
// Filtrer les suggestions pour ne pas afficher celles rejetées par l'utilisateur
// (On garde celles déjà ajoutées pour les afficher en mode "validé")
const filteredSuggestions = suggestions.filter(s => {
if (!s || !s.tag) return false
return !labels.some(l => l.toLowerCase() === s.tag.toLowerCase()) &&
!dismissedTags.includes(s.tag)
return !dismissedTags.includes(s.tag)
})
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -123,7 +126,7 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
setImages(prev => [...prev, data.url])
} catch (error) {
console.error('Upload error:', error)
addToast(`Failed to upload ${file.name}`, 'error')
toast.error(`Failed to upload ${file.name}`)
}
}
}
@@ -141,14 +144,14 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
const metadata = await fetchLinkMetadata(linkUrl)
if (metadata) {
setLinks(prev => [...prev, metadata])
addToast('Link added', 'success')
toast.success('Link added')
} else {
addToast('Could not fetch link metadata', 'warning')
toast.warning('Could not fetch link metadata')
setLinks(prev => [...prev, { url: linkUrl, title: linkUrl }])
}
} catch (error) {
console.error('Failed to add link:', error)
addToast('Failed to add link', 'error')
toast.error('Failed to add link')
} finally {
setLinkUrl('')
}
@@ -160,16 +163,16 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
const handleReminderSave = (date: Date) => {
if (date < new Date()) {
addToast('Reminder must be in the future', 'error')
toast.error('Reminder must be in the future')
return
}
setCurrentReminder(date)
addToast(`Reminder set for ${date.toLocaleString()}`, 'success')
toast.success(`Reminder set for ${date.toLocaleString()}`)
}
const handleRemoveReminder = () => {
setCurrentReminder(null)
addToast('Reminder removed', 'success')
toast.success('Reminder removed')
}
const handleSave = async () => {
@@ -185,6 +188,7 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
color,
reminder: currentReminder,
isMarkdown,
size,
})
// Rafraîchir les labels globaux pour refléter les suppressions éventuelles (orphans)
@@ -227,16 +231,57 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
setLabels(labels.filter(l => l !== label))
}
const handleMakeCopy = async () => {
try {
const newNote = await createNote({
title: `${title || 'Untitled'} (Copy)`,
content: content,
color: color,
type: note.type,
checkItems: checkItems,
labels: labels,
images: images,
links: links,
isMarkdown: isMarkdown,
size: size,
})
toast.success('Note copied successfully!')
onClose()
// Force refresh to show the new note
window.location.reload()
} catch (error) {
console.error('Failed to copy note:', error)
toast.error('Failed to copy note')
}
}
return (
<Dialog open onOpenChange={onClose}>
<Dialog open={true} onOpenChange={onClose}>
<DialogContent
className={cn(
'!max-w-[min(95vw,1600px)] max-h-[90vh] overflow-y-auto',
colorClasses.bg
)}
onInteractOutside={(event) => {
// Prevent ALL outside interactions from closing dialog
// This prevents closing when clicking outside (including on toasts)
event.preventDefault()
}}
onPointerDownOutside={(event) => {
// Prevent ALL pointer down outside from closing dialog
event.preventDefault()
}}
>
<DialogHeader>
<DialogTitle className="sr-only">Edit Note</DialogTitle>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{readOnly ? 'View Note' : 'Edit Note'}</h2>
{readOnly && (
<Badge variant="secondary" className="bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300">
Read Only
</Badge>
)}
</div>
</DialogHeader>
<div className="space-y-4">
@@ -246,7 +291,11 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
placeholder="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="text-lg font-semibold border-0 focus-visible:ring-0 px-0 bg-transparent pr-8"
disabled={readOnly}
className={cn(
"text-lg font-semibold border-0 focus-visible:ring-0 px-0 bg-transparent pr-8",
readOnly && "cursor-default"
)}
/>
{filteredSuggestions.length > 0 && (
<div className="absolute right-0 top-1/2 -translate-y-1/2" title="Suggestions IA disponibles">
@@ -327,8 +376,8 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
</div>
{showMarkdownPreview && isMarkdown ? (
<MarkdownContent
content={content || '*No content*'}
<MarkdownContent
content={content || '*No content*'}
className="min-h-[200px] p-3 rounded-md border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50"
/>
) : (
@@ -336,13 +385,18 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
placeholder={isMarkdown ? "Take a note... (Markdown supported)" : "Take a note..."}
value={content}
onChange={(e) => setContent(e.target.value)}
className="min-h-[200px] border-0 focus-visible:ring-0 px-0 bg-transparent resize-none"
disabled={readOnly}
className={cn(
"min-h-[200px] border-0 focus-visible:ring-0 px-0 bg-transparent resize-none",
readOnly && "cursor-default"
)}
/>
)}
{/* AI Auto-tagging Suggestions */}
<GhostTags
suggestions={filteredSuggestions}
addedTags={labels}
isAnalyzing={isAnalyzing}
onSelectTag={handleSelectGhostTag}
onDismissTag={handleDismissGhostTag}
@@ -401,76 +455,130 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
{/* Toolbar */}
<div className="flex items-center justify-between pt-4 border-t">
<div className="flex items-center gap-2">
{/* Reminder Button */}
<Button
variant="ghost"
size="sm"
onClick={() => setShowReminderDialog(true)}
title="Set reminder"
className={currentReminder ? "text-blue-600" : ""}
>
<Bell className="h-4 w-4" />
</Button>
{/* Add Image Button */}
<Button
variant="ghost"
size="sm"
onClick={() => fileInputRef.current?.click()}
title="Add image"
>
<ImageIcon className="h-4 w-4" />
</Button>
{/* Add Link Button */}
<Button
variant="ghost"
size="sm"
onClick={() => setShowLinkDialog(true)}
title="Add Link"
>
<LinkIcon className="h-4 w-4" />
</Button>
{/* Color Picker */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" title="Change color">
<Palette className="h-4 w-4" />
{!readOnly && (
<>
{/* Reminder Button */}
<Button
variant="ghost"
size="sm"
onClick={() => setShowReminderDialog(true)}
title="Set reminder"
className={currentReminder ? "text-blue-600" : ""}
>
<Bell className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<div className="grid grid-cols-5 gap-2 p-2">
{Object.entries(NOTE_COLORS).map(([colorName, classes]) => (
<button
key={colorName}
className={cn(
'h-8 w-8 rounded-full border-2 transition-transform hover:scale-110',
classes.bg,
color === colorName ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700'
)}
onClick={() => setColor(colorName)}
title={colorName}
/>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* Label Manager */}
<LabelManager
existingLabels={labels}
onUpdate={setLabels}
/>
{/* Add Image Button */}
<Button
variant="ghost"
size="sm"
onClick={() => fileInputRef.current?.click()}
title="Add image"
>
<ImageIcon className="h-4 w-4" />
</Button>
{/* Add Link Button */}
<Button
variant="ghost"
size="sm"
onClick={() => setShowLinkDialog(true)}
title="Add Link"
>
<LinkIcon className="h-4 w-4" />
</Button>
{/* Size Selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" title="Change size">
<Maximize2 className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<div className="flex flex-col gap-1 p-1">
{['small', 'medium', 'large'].map((s) => (
<Button
key={s}
variant="ghost"
size="sm"
onClick={() => setSize(s as NoteSize)}
className={cn(
"justify-start capitalize",
size === s && "bg-accent"
)}
>
{s}
</Button>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* Color Picker */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" title="Change color">
<Palette className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<div className="grid grid-cols-5 gap-2 p-2">
{Object.entries(NOTE_COLORS).map(([colorName, classes]) => (
<button
key={colorName}
className={cn(
'h-8 w-8 rounded-full border-2 transition-transform hover:scale-110',
classes.bg,
color === colorName ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700'
)}
onClick={() => setColor(colorName)}
title={colorName}
/>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* Label Manager */}
<LabelManager
existingLabels={labels}
onUpdate={setLabels}
/>
</>
)}
{readOnly && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="text-xs">This note is shared with you in read-only mode</span>
</div>
)}
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save'}
</Button>
{readOnly ? (
<>
<Button
variant="default"
onClick={handleMakeCopy}
className="flex items-center gap-2"
>
<Copy className="h-4 w-4" />
Make a copy
</Button>
<Button variant="ghost" onClick={onClose}>
Close
</Button>
</>
) : (
<>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save'}
</Button>
</>
)}
</div>
</div>
</div>

View File

@@ -36,14 +36,16 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/toast'
import { toast } from 'sonner'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { MarkdownContent } from './markdown-content'
import { LabelSelector } from './label-selector'
import { LabelBadge } from './label-badge'
import { useAutoTagging } from '@/hooks/use-auto-tagging'
import { GhostTags } from './ghost-tags'
import { CollaboratorDialog } from './collaborator-dialog'
import { useLabels } from '@/context/LabelContext'
import { useSession } from 'next-auth/react'
interface HistoryState {
title: string
@@ -58,23 +60,36 @@ interface NoteState {
}
export function NoteInput() {
const { addToast } = useToast()
const { labels: globalLabels, addLabel } = useLabels()
const { data: session } = useSession()
const [isExpanded, setIsExpanded] = useState(false)
const [type, setType] = useState<'text' | 'checklist'>('text')
const [isSubmitting, setIsSubmitting] = useState(false)
const [color, setColor] = useState<NoteColor>('default')
const [isArchived, setIsArchived] = useState(false)
const [selectedLabels, setSelectedLabels] = useState<string[]>([])
const [collaborators, setCollaborators] = useState<string[]>([])
const [showCollaboratorDialog, setShowCollaboratorDialog] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
// Simple state without complex undo/redo - like Google Keep
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [checkItems, setCheckItems] = useState<CheckItem[]>([])
const [images, setImages] = useState<string[]>([])
const [links, setLinks] = useState<LinkMetadata[]>([])
const [isMarkdown, setIsMarkdown] = useState(false)
const [showMarkdownPreview, setShowMarkdownPreview] = useState(false)
// Combine text content and link metadata for AI analysis
const fullContentForAI = [
content,
...links.map(l => `${l.title || ''} ${l.description || ''}`)
].join(' ').trim();
// Auto-tagging hook
const { suggestions, isAnalyzing } = useAutoTagging({
content: type === 'text' ? content : '',
content: type === 'text' ? fullContentForAI : '',
enabled: type === 'text' && isExpanded
})
@@ -96,7 +111,7 @@ export function NoteInput() {
}
}
addToast(`Tag "${tag}" ajouté`, 'success')
toast.success(`Tag "${tag}" ajouté`)
}
}
@@ -109,13 +124,6 @@ export function NoteInput() {
return !selectedLabels.some(l => l.toLowerCase() === s.tag.toLowerCase()) &&
!dismissedTags.includes(s.tag)
})
const [checkItems, setCheckItems] = useState<CheckItem[]>([])
const [images, setImages] = useState<string[]>([])
const [links, setLinks] = useState<LinkMetadata[]>([])
const [isMarkdown, setIsMarkdown] = useState(false)
const [showMarkdownPreview, setShowMarkdownPreview] = useState(false)
// Undo/Redo history (title and content only)
const [history, setHistory] = useState<HistoryState[]>([{ title: '', content: '' }])
@@ -208,12 +216,12 @@ export function NoteInput() {
for (const file of Array.from(files)) {
// Validation
if (!validTypes.includes(file.type)) {
addToast(`Invalid file type: ${file.name}. Only JPEG, PNG, GIF, and WebP allowed.`, 'error')
toast.error(`Invalid file type: ${file.name}. Only JPEG, PNG, GIF, and WebP allowed.`)
continue
}
if (file.size > maxSize) {
addToast(`File too large: ${file.name}. Maximum size is 5MB.`, 'error')
toast.error(`File too large: ${file.name}. Maximum size is 5MB.`)
continue
}
@@ -233,7 +241,7 @@ export function NoteInput() {
setImages(prev => [...prev, data.url])
} catch (error) {
console.error('Upload error:', error)
addToast(`Failed to upload ${file.name}`, 'error')
toast.error(`Failed to upload ${file.name}`)
}
}
@@ -251,15 +259,15 @@ export function NoteInput() {
const metadata = await fetchLinkMetadata(linkUrl)
if (metadata) {
setLinks(prev => [...prev, metadata])
addToast('Link added', 'success')
toast.success('Link added')
} else {
addToast('Could not fetch link metadata', 'warning')
toast.warning('Could not fetch link metadata')
// Fallback: just add the url as title
setLinks(prev => [...prev, { url: linkUrl, title: linkUrl }])
}
} catch (error) {
console.error('Failed to add link:', error)
addToast('Failed to add link', 'error')
toast.error('Failed to add link')
} finally {
setLinkUrl('')
}
@@ -278,7 +286,7 @@ export function NoteInput() {
const handleReminderSave = () => {
if (!reminderDate || !reminderTime) {
addToast('Please enter date and time', 'warning')
toast.warning('Please enter date and time')
return
}
@@ -286,34 +294,34 @@ export function NoteInput() {
const date = new Date(dateTimeString)
if (isNaN(date.getTime())) {
addToast('Invalid date or time', 'error')
toast.error('Invalid date or time')
return
}
if (date < new Date()) {
addToast('Reminder must be in the future', 'error')
toast.error('Reminder must be in the future')
return
}
setCurrentReminder(date)
addToast(`Reminder set for ${date.toLocaleString()}`, 'success')
toast.success(`Reminder set for ${date.toLocaleString()}`)
setShowReminderDialog(false)
setReminderDate('')
setReminderTime('')
}
const handleSubmit = async () => {
// Validation
if (type === 'text' && !content.trim()) {
addToast('Please enter some content', 'warning')
// Validation: Allow submit if content OR images OR links exist
const hasContent = content.trim().length > 0;
const hasMedia = images.length > 0 || links.length > 0;
const hasCheckItems = checkItems.some(i => i.text.trim().length > 0);
if (type === 'text' && !hasContent && !hasMedia) {
toast.warning('Please enter some content or add a link/image')
return
}
if (type === 'checklist' && checkItems.length === 0) {
addToast('Please add at least one item', 'warning')
return
}
if (type === 'checklist' && checkItems.every(item => !item.text.trim())) {
addToast('Checklist items cannot be empty', 'warning')
if (type === 'checklist' && !hasCheckItems && !hasMedia) {
toast.warning('Please add at least one item or media')
return
}
@@ -331,6 +339,7 @@ export function NoteInput() {
reminder: currentReminder,
isMarkdown,
labels: selectedLabels.length > 0 ? selectedLabels : undefined,
sharedWith: collaborators.length > 0 ? collaborators : undefined,
})
// Reset form
@@ -349,11 +358,12 @@ export function NoteInput() {
setIsArchived(false)
setCurrentReminder(null)
setSelectedLabels([])
setCollaborators([])
addToast('Note created successfully', 'success')
toast.success('Note created successfully')
} catch (error) {
console.error('Failed to create note:', error)
addToast('Failed to create note', 'error')
toast.error('Failed to create note')
} finally {
setIsSubmitting(false)
}
@@ -391,6 +401,7 @@ export function NoteInput() {
setIsArchived(false)
setCurrentReminder(null)
setSelectedLabels([])
setCollaborators([])
}
if (!isExpanded) {
@@ -462,11 +473,32 @@ export function NoteInput() {
{/* Link Previews */}
{links.length > 0 && (
<div className="flex flex-col gap-2 mt-2">
{/* ... */}
{links.map((link, idx) => (
<div key={idx} className="relative group border rounded-lg overflow-hidden bg-white/50 dark:bg-black/20 flex">
{link.imageUrl && (
<div className="w-24 h-24 flex-shrink-0 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
)}
<div className="p-2 flex-1 min-w-0 flex flex-col justify-center">
<h4 className="font-medium text-sm truncate">{link.title || link.url}</h4>
{link.description && <p className="text-xs text-gray-500 truncate">{link.description}</p>}
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-xs text-blue-500 truncate hover:underline block mt-1">
{new URL(link.url).hostname}
</a>
</div>
<Button
variant="ghost"
size="sm"
className="absolute top-1 right-1 h-6 w-6 p-0 bg-white/50 hover:bg-white opacity-0 group-hover:opacity-100 transition-opacity rounded-full"
onClick={() => handleRemoveLink(idx)}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
{/* Selected Labels Display (Moved here to be visible for both text and checklist) */}
{/* Selected Labels Display */}
{selectedLabels.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{selectedLabels.map(label => (
@@ -523,6 +555,7 @@ export function NoteInput() {
{/* AI Auto-tagging Suggestions */}
<GhostTags
suggestions={filteredSuggestions}
addedTags={selectedLabels}
isAnalyzing={isAnalyzing}
onSelectTag={handleSelectGhostTag}
onDismissTag={handleDismissGhostTag}
@@ -620,11 +653,17 @@ export function NoteInput() {
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" title="Collaborator">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title="Add collaborators"
onClick={() => setShowCollaboratorDialog(true)}
>
<UserPlus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Collaborator</TooltipContent>
<TooltipContent>Add collaborators</TooltipContent>
</Tooltip>
<Tooltip>
@@ -766,7 +805,31 @@ export function NoteInput() {
</Card>
<Dialog open={showReminderDialog} onOpenChange={setShowReminderDialog}>
<DialogContent>
<DialogContent
onInteractOutside={(event) => {
// Prevent dialog from closing when interacting with Sonner toasts
const target = event.target as HTMLElement;
const isSonnerElement =
target.closest('[data-sonner-toast]') ||
target.closest('[data-sonner-toaster]') ||
target.closest('[data-icon]') ||
target.closest('[data-content]') ||
target.closest('[data-description]') ||
target.closest('[data-title]') ||
target.closest('[data-button]');
if (isSonnerElement) {
event.preventDefault();
return;
}
if (target.getAttribute('data-sonner-toaster') !== null) {
event.preventDefault();
return;
}
}}
>
<DialogHeader>
<DialogTitle>Set Reminder</DialogTitle>
</DialogHeader>
@@ -808,7 +871,31 @@ export function NoteInput() {
</Dialog>
<Dialog open={showLinkDialog} onOpenChange={setShowLinkDialog}>
<DialogContent>
<DialogContent
onInteractOutside={(event) => {
// Prevent dialog from closing when interacting with Sonner toasts
const target = event.target as HTMLElement;
const isSonnerElement =
target.closest('[data-sonner-toast]') ||
target.closest('[data-sonner-toaster]') ||
target.closest('[data-icon]') ||
target.closest('[data-content]') ||
target.closest('[data-description]') ||
target.closest('[data-title]') ||
target.closest('[data-button]');
if (isSonnerElement) {
event.preventDefault();
return;
}
if (target.getAttribute('data-sonner-toaster') !== null) {
event.preventDefault();
return;
}
}}
>
<DialogHeader>
<DialogTitle>Add Link</DialogTitle>
</DialogHeader>
@@ -836,6 +923,16 @@ export function NoteInput() {
</DialogFooter>
</DialogContent>
</Dialog>
<CollaboratorDialog
open={showCollaboratorDialog}
onOpenChange={setShowCollaboratorDialog}
noteId=""
noteOwnerId={session?.user?.id || ""}
currentUserId={session?.user?.id || ""}
onCollaboratorsChange={setCollaborators}
initialCollaborators={collaborators}
/>
</>
)
}
}

View File

@@ -0,0 +1,234 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Bell, Check, X, Clock, User } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { getPendingShareRequests, respondToShareRequest, removeSharedNoteFromView } from '@/app/actions/notes'
import { toast } from 'sonner'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
import { cn } from '@/lib/utils'
interface ShareRequest {
id: string
status: string
permission: string
createdAt: Date
note: {
id: string
title: string | null
content: string
color: string
createdAt: Date
}
sharer: {
id: string
name: string | null
email: string
image: string | null
}
}
export function NotificationPanel() {
const router = useRouter()
const { triggerRefresh } = useNoteRefresh()
const [requests, setRequests] = useState<ShareRequest[]>([])
const [isLoading, setIsLoading] = useState(false)
const [pendingCount, setPendingCount] = useState(0)
const loadRequests = async () => {
setIsLoading(true)
try {
const data = await getPendingShareRequests()
setRequests(data)
setPendingCount(data.length)
} catch (error: any) {
console.error('Failed to load share requests:', error)
} finally {
setIsLoading(false)
}
}
useEffect(() => {
loadRequests()
const interval = setInterval(loadRequests, 10000)
return () => clearInterval(interval)
}, [])
const handleAccept = async (shareId: string) => {
console.log('[NOTIFICATION] Accepting share:', shareId)
try {
await respondToShareRequest(shareId, 'accept')
console.log('[NOTIFICATION] Share accepted, calling router.refresh()')
router.refresh()
console.log('[NOTIFICATION] Calling triggerRefresh()')
triggerRefresh()
setRequests(prev => prev.filter(r => r.id !== shareId))
setPendingCount(prev => prev - 1)
toast.success('Note shared successfully!', {
description: 'The note now appears in your list',
duration: 3000,
})
console.log('[NOTIFICATION] Done! Note should appear now')
} catch (error: any) {
console.error('[NOTIFICATION] Error:', error)
toast.error(error.message || 'Error')
}
}
const handleDecline = async (shareId: string) => {
console.log('[NOTIFICATION] Declining share:', shareId)
try {
await respondToShareRequest(shareId, 'decline')
router.refresh()
triggerRefresh()
setRequests(prev => prev.filter(r => r.id !== shareId))
setPendingCount(prev => prev - 1)
toast.info('Share declined')
} catch (error: any) {
console.error('[NOTIFICATION] Error:', error)
toast.error(error.message || 'Error')
}
}
const handleRemove = async (shareId: string) => {
try {
await removeSharedNoteFromView(shareId)
router.refresh()
triggerRefresh()
setRequests(prev => prev.filter(r => r.id !== shareId))
toast.info('Request hidden')
} catch (error: any) {
toast.error(error.message || 'Error')
}
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="relative h-9 w-9 p-0 hover:bg-accent/50 transition-all duration-200"
>
<Bell className="h-4 w-4 transition-transform duration-200 hover:scale-110" />
{pendingCount > 0 && (
<Badge
variant="destructive"
className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs animate-pulse shadow-lg"
>
{pendingCount > 9 ? '9+' : pendingCount}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
<div className="px-4 py-3 border-b bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-950/20 dark:to-indigo-950/20">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Bell className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="font-semibold text-sm">Pending Shares</span>
</div>
{pendingCount > 0 && (
<Badge className="bg-blue-600 hover:bg-blue-700 text-white shadow-md">
{pendingCount}
</Badge>
)}
</div>
</div>
{isLoading ? (
<div className="p-6 text-center text-sm text-muted-foreground">
<div className="animate-spin h-6 w-6 border-2 border-blue-600 border-t-transparent rounded-full mx-auto mb-2" />
Loading...
</div>
) : requests.length === 0 ? (
<div className="p-6 text-center text-sm text-muted-foreground">
<Bell className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p className="font-medium">No pending share requests</p>
</div>
) : (
<div className="max-h-96 overflow-y-auto">
{requests.map((request) => (
<div
key={request.id}
className="p-4 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150"
>
<div className="flex items-start gap-3 mb-3">
<div className="h-8 w-8 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white font-semibold text-xs shadow-md">
{(request.sharer.name || request.sharer.email)[0].toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold truncate">
{request.sharer.name || request.sharer.email}
</p>
<p className="text-xs text-muted-foreground truncate mt-0.5">
shared &quot;{request.note.title || 'Untitled'}&quot;
</p>
</div>
<Badge
variant="secondary"
className="text-xs capitalize bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 border-0"
>
{request.permission}
</Badge>
</div>
<div className="flex gap-2 mt-3">
<button
onClick={() => handleAccept(request.id)}
className={cn(
"flex-1 h-9 px-4 text-xs font-semibold rounded-lg",
"bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700",
"text-white shadow-md hover:shadow-lg",
"transition-all duration-200",
"flex items-center justify-center gap-1.5",
"active:scale-95"
)}
>
<Check className="h-3.5 w-3.5" />
YES
</button>
<button
onClick={() => handleDecline(request.id)}
className={cn(
"flex-1 h-9 px-4 text-xs font-semibold rounded-lg",
"bg-white dark:bg-gray-800",
"border-2 border-gray-200 dark:border-gray-700",
"text-gray-700 dark:text-gray-300",
"hover:bg-gray-50 dark:hover:bg-gray-700",
"hover:border-gray-300 dark:hover:border-gray-600",
"transition-all duration-200",
"flex items-center justify-center gap-1.5",
"active:scale-95"
)}
>
<X className="h-3.5 w-3.5" />
NO
</button>
</div>
<div className="flex items-center gap-1.5 mt-3 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
<span>{new Date(request.createdAt).toLocaleDateString()}</span>
<button
onClick={() => handleRemove(request.id)}
className="ml-auto text-muted-foreground hover:text-foreground transition-colors duration-150"
>
Hide
</button>
</div>
</div>
))}
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -49,7 +49,31 @@ export function ReminderDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent
onInteractOutside={(event) => {
// Prevent dialog from closing when interacting with Sonner toasts
const target = event.target as HTMLElement;
const isSonnerElement =
target.closest('[data-sonner-toast]') ||
target.closest('[data-sonner-toaster]') ||
target.closest('[data-icon]') ||
target.closest('[data-content]') ||
target.closest('[data-description]') ||
target.closest('[data-title]') ||
target.closest('[data-button]');
if (isSonnerElement) {
event.preventDefault();
return;
}
if (target.getAttribute('data-sonner-toaster') !== null) {
event.preventDefault();
return;
}
}}
>
<DialogHeader>
<DialogTitle>Set Reminder</DialogTitle>
</DialogHeader>

View File

@@ -0,0 +1,7 @@
'use client'
import { SessionProvider } from 'next-auth/react'
export function SessionProviderWrapper({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>
}

View File

@@ -1,21 +1,25 @@
'use client'
import { useState } from 'react'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { usePathname, useSearchParams } from 'next/navigation'
import { cn } from '@/lib/utils'
import { StickyNote, Bell, Archive, Trash2, Tag, ChevronDown, ChevronUp, Settings } from 'lucide-react'
import { StickyNote, Bell, Archive, Trash2, Tag, ChevronDown, ChevronUp, Settings, User, Shield, Coffee } from 'lucide-react'
import { useLabels } from '@/context/LabelContext'
import { LabelManagementDialog } from './label-management-dialog'
import { useSession } from 'next-auth/react'
import { LABEL_COLORS } from '@/lib/types'
export function Sidebar({ className }: { className?: string }) {
export function Sidebar({ className, user }: { className?: string, user?: any }) {
const pathname = usePathname()
const searchParams = useSearchParams()
const { labels, getLabelColor } = useLabels()
const [isLabelsExpanded, setIsLabelsExpanded] = useState(false)
const { data: session } = useSession()
const currentUser = user || session?.user
const currentLabels = searchParams.get('labels')?.split(',').filter(Boolean) || []
const currentSearch = searchParams.get('search')
@@ -28,9 +32,9 @@ export function Sidebar({ className }: { className?: string }) {
href={href}
onClick={onClick}
className={cn(
"flex items-center gap-3 px-4 py-3 rounded-r-full text-sm font-medium transition-colors mr-2",
active
? "bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-100"
"flex items-center gap-3 px-4 py-3 rounded-r-full text-sm font-medium transition-colors",
active
? "bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-100"
: "hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-700 dark:text-gray-300"
)}
>
@@ -40,31 +44,31 @@ export function Sidebar({ className }: { className?: string }) {
)
return (
<aside className={cn("w-[280px] flex-col gap-1 py-2 overflow-y-auto hidden md:flex", className)}>
<NavItem
href="/"
icon={StickyNote}
label="Notes"
active={pathname === '/' && currentLabels.length === 0 && !currentSearch}
<aside className={cn("w-[280px] flex-col gap-1 py-2 overflow-y-auto overflow-x-hidden hidden md:flex", className)}>
<NavItem
href="/"
icon={StickyNote}
label="Notes"
active={pathname === '/' && currentLabels.length === 0 && !currentSearch}
/>
<NavItem
href="/reminders"
icon={Bell}
label="Reminders"
active={pathname === '/reminders'}
<NavItem
href="/reminders"
icon={Bell}
label="Reminders"
active={pathname === '/reminders'}
/>
<div className="my-2 px-4 flex items-center justify-between group">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">Labels</span>
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<LabelManagementDialog />
</div>
</div>
{displayedLabels.map(label => {
const colorName = getLabelColor(label.name)
const colorClass = LABEL_COLORS[colorName]?.icon
return (
<NavItem
key={label.id}
@@ -80,7 +84,7 @@ export function Sidebar({ className }: { className?: string }) {
{hasMoreLabels && (
<button
onClick={() => setIsLabelsExpanded(!isLabelsExpanded)}
className="flex items-center gap-3 px-4 py-3 rounded-r-full text-sm font-medium transition-colors mr-2 w-full hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-700 dark:text-gray-300"
className="flex items-center gap-3 px-4 py-3 rounded-r-full text-sm font-medium transition-colors w-full hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-700 dark:text-gray-300"
>
{isLabelsExpanded ? (
<ChevronUp className="h-5 w-5" />
@@ -105,11 +109,37 @@ export function Sidebar({ className }: { className?: string }) {
label="Trash"
active={pathname === '/trash'}
/>
<div className="my-2 border-t border-gray-200 dark:border-zinc-800" />
<NavItem
href="/settings"
icon={Settings}
label="Settings"
active={pathname === '/settings'}
href="/settings/profile"
icon={User}
label="Profile"
active={pathname === '/settings/profile'}
/>
{(currentUser as any)?.role === 'ADMIN' && (
<NavItem
href="/admin"
icon={Shield}
label="Admin"
active={pathname === '/admin'}
/>
)}
<NavItem
href="/support"
icon={Coffee}
label="Support Memento ☕"
active={pathname === '/support'}
/>
<NavItem
href="/settings"
icon={Settings}
label="Diagnostics"
active={pathname === '/settings'}
/>
</aside>
)

View File

@@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,16 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {}
export function Label({ className, ...props }: LabelProps) {
return (
<label
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className
)}
{...props}
/>
)
}

View File

@@ -1,85 +1,27 @@
'use client'
import * as React from "react"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
import { Toaster as SonnerToaster, toast as sonnerToast } from 'sonner'
export interface ToastProps {
id: string
message: string
type?: 'success' | 'error' | 'info' | 'warning'
duration?: number
onClose: (id: string) => void
}
export function Toast({ id, message, type = 'info', duration = 3000, onClose }: ToastProps) {
React.useEffect(() => {
const timer = setTimeout(() => {
onClose(id)
}, duration)
return () => clearTimeout(timer)
}, [id, duration, onClose])
const bgColors = {
success: 'bg-green-600',
error: 'bg-red-600',
info: 'bg-blue-600',
warning: 'bg-yellow-600'
}
// Re-export toast functions from Sonner
export const toast = sonnerToast
// Toaster component with custom styles
export function Toaster() {
return (
<div
className={cn(
"flex items-center gap-2 rounded-lg px-4 py-3 text-sm text-white shadow-lg animate-in slide-in-from-top-5",
bgColors[type]
)}
>
<span className="flex-1">{message}</span>
<button
onClick={() => onClose(id)}
className="rounded-full p-1 hover:bg-white/20 transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
<SonnerToaster
position="top-right"
expand={false}
richColors
closeButton
duration={3000}
toastOptions={{
classNames: {
toast: 'toast pointer-events-auto',
description: 'toast-description',
actionButton: 'toast-action-button',
closeButton: 'toast-close-button',
},
}}
/>
)
}
export interface ToastContextType {
addToast: (message: string, type?: 'success' | 'error' | 'info' | 'warning') => void
}
const ToastContext = React.createContext<ToastContextType | null>(null)
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = React.useState<Array<Omit<ToastProps, 'onClose'>>>([])
const addToast = React.useCallback((message: string, type: 'success' | 'error' | 'info' | 'warning' = 'info') => {
const id = Math.random().toString(36).substring(7)
setToasts(prev => [...prev, { id, message, type }])
}, [])
const removeToast = React.useCallback((id: string) => {
setToasts(prev => prev.filter(toast => toast.id !== id))
}, [])
return (
<ToastContext.Provider value={{ addToast }}>
{children}
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 w-80">
{toasts.map(toast => (
<Toast key={toast.id} {...toast} onClose={removeToast} />
))}
</div>
</ToastContext.Provider>
)
}
export function useToast() {
const context = React.useContext(ToastContext)
if (!context) {
throw new Error('useToast must be used within ToastProvider')
}
return context
}

View File

@@ -0,0 +1,75 @@
'use client'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { useSession, signOut } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { LogOut, Settings, User, Shield } from 'lucide-react'
export function UserNav({ user }: { user?: any }) {
const { data: session } = useSession()
const router = useRouter()
const currentUser = user || session?.user
if (!currentUser) return null
const userRole = (currentUser as any).role || currentUser.role
const userInitials = currentUser.name
? currentUser.name.split(' ').map((n: string) => n[0]).join('').toUpperCase().substring(0, 2)
: 'U'
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
<Avatar className="h-8 w-8">
<AvatarImage src={currentUser.image || ''} alt={currentUser.name || ''} />
<AvatarFallback>{userInitials}</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{currentUser.name}</p>
<p className="text-xs leading-none text-muted-foreground">
{currentUser.email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => router.push('/settings/profile')}>
<User className="mr-2 h-4 w-4" />
<span>Profile</span>
</DropdownMenuItem>
{userRole === 'ADMIN' && (
<DropdownMenuItem onClick={() => router.push('/admin')}>
<Shield className="mr-2 h-4 w-4" />
<span>Admin Dashboard</span>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => router.push('/settings')}>
<Settings className="mr-2 h-4 w-4" />
<span>Diagnostics</span>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => signOut({ callbackUrl: '/login' })}>
<LogOut className="mr-2 h-4 w-4" />
<span>Log out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -32,7 +32,10 @@ export function LabelProvider({ children }: { children: ReactNode }) {
const fetchLabels = async () => {
try {
setLoading(true)
const response = await fetch('/api/labels', { cache: 'no-store' })
const response = await fetch('/api/labels', {
cache: 'no-store',
credentials: 'include'
})
const data = await response.json()
if (data.success && data.data) {
setLabels(data.data)

View File

@@ -0,0 +1,33 @@
'use client'
import { createContext, useContext, useState, useCallback } from 'react'
interface NoteRefreshContextType {
refreshKey: number
triggerRefresh: () => void
}
const NoteRefreshContext = createContext<NoteRefreshContextType | undefined>(undefined)
export function NoteRefreshProvider({ children }: { children: React.ReactNode }) {
const [refreshKey, setRefreshKey] = useState(0)
const triggerRefresh = useCallback(() => {
console.log('[NOTE_REFRESH] Triggering refresh, key:', refreshKey, '->', refreshKey + 1)
setRefreshKey(prev => prev + 1)
}, [refreshKey])
return (
<NoteRefreshContext.Provider value={{ refreshKey, triggerRefresh }}>
{children}
</NoteRefreshContext.Provider>
)
}
export function useNoteRefresh() {
const context = useContext(NoteRefreshContext)
if (!context) {
throw new Error('useNoteRefresh must be used within NoteRefreshProvider')
}
return context
}

View File

@@ -16,14 +16,12 @@ export function useAutoTagging({ content, enabled = true }: UseAutoTaggingProps)
const debouncedContent = useDebounce(content, 1500);
useEffect(() => {
// console.log('AutoTagging Effect:', { enabled, contentLength: debouncedContent?.length });
if (!enabled || !debouncedContent || debouncedContent.length < 10) {
setSuggestions([]);
return;
}
const analyzeContent = async () => {
console.log('🚀 Triggering AI analysis for:', debouncedContent.substring(0, 20) + '...');
setIsAnalyzing(true);
setError(null);
@@ -39,7 +37,6 @@ export function useAutoTagging({ content, enabled = true }: UseAutoTaggingProps)
}
const data = await response.json();
console.log('✅ AI Response:', data);
setSuggestions(data.tags || []);
} catch (err) {
console.error('❌ Auto-tagging error:', err);

View File

@@ -2,11 +2,10 @@
import { useState, useEffect } from 'react';
import { Note } from '@/lib/types';
import { useToast } from '@/components/ui/toast';
import { toast } from 'sonner';
export function useReminderCheck(notes: Note[]) {
const [notifiedReminders, setNotifiedReminders] = useState<Set<string>>(new Set());
const { addToast } = useToast();
useEffect(() => {
const checkReminders = () => {
@@ -26,7 +25,7 @@ export function useReminderCheck(notes: Note[]) {
// const audio = new Audio('/notification.mp3');
// audio.play().catch(e => console.log('Audio play failed', e));
addToast("🔔 Reminder: " + (note.title || "Untitled Note"), "info");
toast.info("🔔 Reminder: " + (note.title || "Untitled Note"));
// Mark as notified in local state
setNotifiedReminders(prev => new Set(prev).add(note.id));
@@ -41,5 +40,5 @@ export function useReminderCheck(notes: Note[]) {
const interval = setInterval(checkReminders, 30000);
return () => clearInterval(interval);
}, [notes, notifiedReminders, addToast]);
}, [notes, notifiedReminders]);
}

View File

@@ -2,24 +2,29 @@ import { OpenAIProvider } from './providers/openai';
import { OllamaProvider } from './providers/ollama';
import { AIProvider } from './types';
export function getAIProvider(): AIProvider {
const providerType = process.env.AI_PROVIDER || 'ollama'; // Default to ollama for local dev
export function getAIProvider(config?: Record<string, string>): AIProvider {
const providerType = config?.AI_PROVIDER || process.env.AI_PROVIDER || 'ollama';
switch (providerType.toLowerCase()) {
case 'ollama':
console.log('Using Ollama Provider with model:', process.env.OLLAMA_MODEL || 'granite4:latest');
return new OllamaProvider(
process.env.OLLAMA_BASE_URL || 'http://localhost:11434/api',
process.env.OLLAMA_MODEL || 'granite4:latest'
);
let baseUrl = config?.OLLAMA_BASE_URL || process.env.OLLAMA_BASE_URL || 'http://localhost:11434';
const model = config?.AI_MODEL_TAGS || process.env.OLLAMA_MODEL || 'granite4:latest';
const embedModel = config?.AI_MODEL_EMBEDDING || process.env.OLLAMA_EMBEDDING_MODEL || 'embeddinggemma:latest';
// Ensure baseUrl doesn't end with /api, we'll add it in OllamaProvider
if (baseUrl.endsWith('/api')) {
baseUrl = baseUrl.slice(0, -4); // Remove /api
}
return new OllamaProvider(baseUrl, model, embedModel);
case 'openai':
default:
if (!process.env.OPENAI_API_KEY) {
console.warn('OPENAI_API_KEY non configurée. Les fonctions IA pourraient échouer.');
const apiKey = config?.OPENAI_API_KEY || process.env.OPENAI_API_KEY || '';
const aiModel = config?.AI_MODEL_TAGS || process.env.OPENAI_MODEL || 'gpt-4o-mini';
if (!apiKey && providerType.toLowerCase() === 'openai') {
console.warn('OPENAI_API_KEY non configurée.');
}
return new OpenAIProvider(
process.env.OPENAI_API_KEY || '',
process.env.OPENAI_MODEL || 'gpt-4o-mini'
);
return new OpenAIProvider(apiKey, aiModel);
}
}

View File

@@ -3,10 +3,13 @@ import { AIProvider, TagSuggestion } from '../types';
export class OllamaProvider implements AIProvider {
private baseUrl: string;
private modelName: string;
private embeddingModelName: string;
constructor(baseUrl: string = 'http://localhost:11434/api', modelName: string = 'llama3') {
this.baseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
constructor(baseUrl: string = 'http://localhost:11434', modelName: string = 'llama3', embeddingModelName?: string) {
// Ensure baseUrl ends with /api for Ollama API
this.baseUrl = baseUrl.endsWith('/api') ? baseUrl : `${baseUrl}/api`;
this.modelName = modelName;
this.embeddingModelName = embeddingModelName || modelName;
}
async generateTags(content: string): Promise<TagSuggestion[]> {
@@ -16,7 +19,7 @@ export class OllamaProvider implements AIProvider {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.modelName,
prompt: `Analyse la note suivante et extrais les concepts clés sous forme de tags courts (1-3 mots max).
prompt: `Analyse la note suivante et extrais les concepts clés sous forme de tags courts (1-3 mots max).
Règles:
- Pas de mots de liaison (le, la, pour, et...).
@@ -36,13 +39,13 @@ export class OllamaProvider implements AIProvider {
const data = await response.json();
const text = data.response;
const jsonMatch = text.match(/\[\s*\{.*\}\s*\]/s);
const jsonMatch = text.match(/\[\s*\{[\s\S]*\}\s*\]/);
if (jsonMatch) {
return JSON.parse(jsonMatch[0]);
}
// Support pour le format { "tags": [...] }
const objectMatch = text.match(/\{\s*"tags"\s*:\s*(\[.*\])\s*\}/s);
const objectMatch = text.match(/\{\s*"tags"\s*:\s*(\[[\s\S]*\])\s*\}/);
if (objectMatch && objectMatch[1]) {
return JSON.parse(objectMatch[1]);
}
@@ -60,7 +63,7 @@ export class OllamaProvider implements AIProvider {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.modelName,
model: this.embeddingModelName,
prompt: text,
}),
});
@@ -74,4 +77,4 @@ export class OllamaProvider implements AIProvider {
return [];
}
}
}
}

View File

@@ -22,4 +22,5 @@ export interface AIConfig {
apiKey?: string;
baseUrl?: string; // Utile pour Ollama
model?: string;
embeddingModel?: string;
}

55
keep-notes/lib/config.ts Normal file
View File

@@ -0,0 +1,55 @@
import prisma from './prisma';
export async function getSystemConfig() {
try {
const configs = await prisma.systemConfig.findMany();
return configs.reduce((acc, conf) => {
acc[conf.key] = conf.value;
return acc;
}, {} as Record<string, string>);
} catch (e) {
console.error('Failed to load system config from DB:', e);
return {};
}
}
/**
* Get a config value with a default fallback
*/
export async function getConfigValue(key: string, defaultValue: string = ''): Promise<string> {
const config = await getSystemConfig();
return config[key] || defaultValue;
}
/**
* Get a numeric config value with a default fallback
*/
export async function getConfigNumber(key: string, defaultValue: number): Promise<number> {
const value = await getConfigValue(key, String(defaultValue));
const num = parseFloat(value);
return isNaN(num) ? defaultValue : num;
}
/**
* Get a boolean config value with a default fallback
*/
export async function getConfigBoolean(key: string, defaultValue: boolean): Promise<boolean> {
const value = await getConfigValue(key, String(defaultValue));
return value === 'true';
}
/**
* Search configuration defaults
*/
export const SEARCH_DEFAULTS = {
SEMANTIC_THRESHOLD: 0.65,
RRF_K_BASE: 20,
RRF_K_ADAPTIVE: true,
KEYWORD_BOOST_EXACT: 2.0,
KEYWORD_BOOST_CONCEPTUAL: 0.7,
SEMANTIC_BOOST_EXACT: 0.7,
SEMANTIC_BOOST_CONCEPTUAL: 1.5,
QUERY_EXPANSION_ENABLED: false,
QUERY_EXPANSION_MAX_SYNONYMS: 3,
DEBUG_MODE: false,
} as const;

View File

@@ -0,0 +1,35 @@
export function getEmailTemplate(title: string, content: string, actionLink?: string, actionText?: string) {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #eee; border-radius: 8px; background: #fff; }
.header { text-align: center; margin-bottom: 30px; }
.logo { color: #f59e0b; font-size: 24px; font-weight: bold; text-decoration: none; display: flex; align-items: center; justify-content: center; gap: 10px; }
.button { display: inline-block; padding: 12px 24px; background-color: #f59e0b; color: white !important; text-decoration: none; border-radius: 6px; font-weight: bold; margin: 20px 0; }
.footer { margin-top: 30px; font-size: 12px; color: #888; text-align: center; border-top: 1px solid #eee; padding-top: 20px; }
</style>
</head>
<body>
<div className="container">
<div className="header">
<a href="${process.env.NEXTAUTH_URL}" className="logo">
📒 Memento
</a>
</div>
<h1>${title}</h1>
<div>
${content}
</div>
${actionLink ? `<div style="text-align: center;"><a href="${actionLink}" className="button">${actionText || 'Click here'}</a></div>` : ''}
<div className="footer">
<p>This email was sent from your Memento instance.</p>
</div>
</div>
</body>
</html>
`;
}

65
keep-notes/lib/mail.ts Normal file
View File

@@ -0,0 +1,65 @@
import nodemailer from 'nodemailer';
import { getSystemConfig } from './config';
interface MailOptions {
to: string;
subject: string;
html: string;
}
export async function sendEmail({ to, subject, html }: MailOptions) {
const config = await getSystemConfig();
const host = config.SMTP_HOST || process.env.SMTP_HOST;
const port = parseInt(config.SMTP_PORT || process.env.SMTP_PORT || '587');
const user = (config.SMTP_USER || process.env.SMTP_USER || '').trim();
const pass = (config.SMTP_PASS || process.env.SMTP_PASS || '').trim();
const from = config.SMTP_FROM || process.env.SMTP_FROM || 'noreply@memento.app';
// Options de sécurité
const forceSecure = config.SMTP_SECURE === 'true'; // Forcé par l'admin
const isPort465 = port === 465;
// Si secure n'est pas forcé, on déduit du port (465 = secure, autres = starttls)
const secure = forceSecure || isPort465;
const ignoreCerts = config.SMTP_IGNORE_CERT === 'true';
const transporter = nodemailer.createTransport({
host: host || undefined,
port: port || undefined,
secure: secure || false,
auth: { user, pass },
// Force IPv4 pour éviter les problèmes de résolution DNS/Docker
family: 4,
// Force AUTH LOGIN pour meilleure compatibilité (Mailcow, Exchange) vs PLAIN par défaut
authMethod: 'LOGIN',
// Timeout généreux
connectionTimeout: 10000,
tls: {
// Si on ignore les certs, on autorise tout.
// Sinon on laisse les défauts stricts de Node.
rejectUnauthorized: !ignoreCerts,
// Compatibilité vieux serveurs si besoin (optionnel, activé si ignoreCerts pour maximiser les chances)
ciphers: ignoreCerts ? 'SSLv3' : undefined
}
} as any);
try {
await transporter.verify();
const info = await transporter.sendMail({
from: `"Memento App" <${from}>`,
to,
subject,
html,
});
return { success: true, messageId: info.messageId };
} catch (error: any) {
console.error("❌ Erreur SMTP:", error);
return {
success: false,
error: `Erreur envoi: ${error.message} (Code: ${error.code})`
};
}
}

View File

@@ -1,4 +1,5 @@
import { PrismaClient } from '@prisma/client'
// @ts-ignore - Generated client
import { PrismaClient } from '../prisma/client-generated'
const prismaClientSingleton = () => {
return new PrismaClient({

View File

@@ -37,11 +37,17 @@ export interface Note {
reminderRecurrence: string | null;
reminderLocation: string | null;
isMarkdown: boolean;
size: NoteSize;
order: number;
createdAt: Date;
updatedAt: Date;
embedding?: number[] | null;
sharedWith?: string[];
userId?: string | null;
}
export type NoteSize = 'small' | 'medium' | 'large';
export interface LabelWithColor {
name: string;
color: LabelColorName;
@@ -160,3 +166,11 @@ export const NOTE_COLORS = {
} as const;
export type NoteColor = keyof typeof NOTE_COLORS;
/**
* Query types for adaptive search weighting
* - 'exact': User searched with quotes, looking for exact match (e.g., "Error 404")
* - 'conceptual': User is asking a question or looking for concepts (e.g., "how to cook")
* - 'mixed': No specific pattern detected, use default weights
*/
export type QueryType = 'exact' | 'conceptual' | 'mixed';

View File

@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { LABEL_COLORS, LabelColorName } from "./types"
import { LABEL_COLORS, LabelColorName, QueryType } from "./types"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
@@ -19,3 +19,189 @@ export function getHashColor(name: string): LabelColorName {
return colorfulColors[colorIndex];
}
export function cosineSimilarity(vecA: number[], vecB: number[]): number {
if (vecA.length !== vecB.length) return 0;
let dotProduct = 0;
let mA = 0;
let mB = 0;
for (let i = 0; i < vecA.length; i++) {
dotProduct += vecA[i] * vecB[i];
mA += vecA[i] * vecA[i];
mB += vecB[i] * vecB[i];
}
mA = Math.sqrt(mA);
mB = Math.sqrt(mB);
if (mA === 0 || mB === 0) return 0;
return dotProduct / (mA * mB);
}
/**
* Validate an embedding vector for quality issues
*/
export function validateEmbedding(embedding: number[]): { valid: boolean; issues: string[] } {
const issues: string[] = [];
// Check 1: Dimensionality > 0
if (!embedding || embedding.length === 0) {
issues.push('Embedding is empty or has zero dimensionality');
return { valid: false, issues };
}
// Check 2: Valid numbers (no NaN or Infinity)
let hasNaN = false;
let hasInfinity = false;
let hasZeroVector = true;
for (let i = 0; i < embedding.length; i++) {
const val = embedding[i];
if (isNaN(val)) hasNaN = true;
if (!isFinite(val)) hasInfinity = true;
if (val !== 0) hasZeroVector = false;
}
if (hasNaN) {
issues.push('Embedding contains NaN values');
}
if (hasInfinity) {
issues.push('Embedding contains Infinity values');
}
if (hasZeroVector) {
issues.push('Embedding is a zero vector (all values are 0)');
}
// Check 3: L2 norm is in reasonable range (0.7 to 1.2)
const l2Norm = calculateL2Norm(embedding);
if (l2Norm < 0.7 || l2Norm > 1.2) {
issues.push(`L2 norm is ${l2Norm.toFixed(3)} (expected range: 0.7-1.2)`);
}
return {
valid: issues.length === 0,
issues
};
}
/**
* Calculate L2 norm of a vector
*/
export function calculateL2Norm(vector: number[]): number {
let sum = 0;
for (let i = 0; i < vector.length; i++) {
sum += vector[i] * vector[i];
}
return Math.sqrt(sum);
}
/**
* Normalize an embedding to unit L2 norm
*/
export function normalizeEmbedding(embedding: number[]): number[] {
const norm = calculateL2Norm(embedding);
if (norm === 0) return embedding; // Can't normalize zero vector
return embedding.map(val => val / norm);
}
/**
* Calculate the RRF (Reciprocal Rank Fusion) constant k
*
* RRF Formula: score = Σ 1 / (k + rank)
*
* The k constant controls how much we penalize lower rankings:
* - Lower k (e.g., 20) penalizes low ranks more heavily
* - Higher k (e.g., 60) is more lenient with low ranks
*
* Adaptive formula: k = max(20, totalNotes / 10)
* - For small datasets (< 200 notes): k = 20 (strict)
* - For larger datasets: k scales linearly
*
* Examples:
* - 50 notes → k = 20
* - 200 notes → k = 20
* - 500 notes → k = 50
* - 1000 notes → k = 100
*/
export function calculateRRFK(totalNotes: number): number {
const BASE_K = 20;
const adaptiveK = Math.floor(totalNotes / 10);
return Math.max(BASE_K, adaptiveK);
}
/**
* Detect the type of search query to adapt search weights
*
* Detection rules:
* 1. EXACT: Query contains quotes (e.g., "Error 404")
* 2. CONCEPTUAL: Query starts with question words or is a phrase like "how to X"
* 3. MIXED: No specific pattern detected
*
* Examples:
* - "exact phrase" → 'exact'
* - "how to cook pasta" → 'conceptual'
* - "what is python" → 'conceptual'
* - "javascript tutorial" → 'mixed'
*/
export function detectQueryType(query: string): QueryType {
const trimmed = query.trim().toLowerCase();
// Rule 1: Check for quotes (exact match)
if ((query.startsWith('"') && query.endsWith('"')) ||
(query.startsWith("'") && query.endsWith("'"))) {
return 'exact';
}
// Rule 2: Check for conceptual patterns
const conceptualPatterns = [
/^(how|what|when|where|why|who|which|whose|can|could|would|should|is|are|do|does|did)\b/,
/^(how to|ways to|best way to|guide for|tips for|learn about|understand)/,
/^(tutorial|guide|introduction|overview|explanation|examples)/,
];
for (const pattern of conceptualPatterns) {
if (pattern.test(trimmed)) {
return 'conceptual';
}
}
// Default: mixed search
return 'mixed';
}
/**
* Get search weight multipliers based on query type
*
* Returns keyword and semantic weight multipliers:
* - EXACT: Boost keyword matches (2.0x), reduce semantic (0.7x)
* - CONCEPTUAL: Reduce keyword (0.7x), boost semantic (1.5x)
* - MIXED: Default weights (1.0x, 1.0x)
*/
export function getSearchWeights(queryType: QueryType): {
keywordWeight: number;
semanticWeight: number;
} {
switch (queryType) {
case 'exact':
return {
keywordWeight: 2.0,
semanticWeight: 0.7
};
case 'conceptual':
return {
keywordWeight: 0.7,
semanticWeight: 1.5
};
case 'mixed':
default:
return {
keywordWeight: 1.0,
semanticWeight: 1.0
};
}
}

View File

@@ -1,11 +1,29 @@
import type { NextConfig } from "next";
import path from "path";
const withPWA = require("@ducanh2912/next-pwa").default({
dest: "public",
register: true,
skipWaiting: true,
disable: process.env.NODE_ENV === "development",
});
const nextConfig: NextConfig = {
// Enable standalone output for Docker
output: 'standalone',
turbopack: {
// Set root to parent directory to support monorepo workspace structure
root: path.resolve(__dirname, ".."),
},
// Optimize for production
reactStrictMode: true,
// Image optimization
images: {
unoptimized: true, // Required for standalone
},
};
export default nextConfig;
export default withPWA(nextConfig);

12838
keep-notes/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"db:generate": "prisma generate",
"test": "playwright test",
"test:ui": "playwright test --ui",
"test:headed": "playwright test --headed"
@@ -16,10 +17,11 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@ducanh2912/next-pwa": "^10.2.9",
"@libsql/client": "^0.15.15",
"@prisma/adapter-better-sqlite3": "^7.2.0",
"@prisma/adapter-libsql": "^7.2.0",
"@prisma/client": "^5.22.0",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -35,30 +37,38 @@
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dotenv": "^17.2.3",
"katex": "^0.16.27",
"lucide-react": "^0.562.0",
"muuri": "^0.9.5",
"next": "16.1.1",
"next-auth": "^5.0.0-beta.30",
"nodemailer": "^7.0.12",
"ollama-ai-provider": "^1.2.0",
"prisma": "^5.22.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-grid-layout": "^2.2.2",
"react-markdown": "^10.1.0",
"react-masonry-css": "^1.0.16",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"web-animations-js": "^2.3.2",
"zod": "^4.3.5"
},
"devDependencies": {
"@playwright/test": "^1.57.0",
"@prisma/client": "^5.22.0",
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20",
"@types/nodemailer": "^7.0.4",
"@types/react": "^19",
"@types/react-dom": "^19",
"prisma": "^5.22.0",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
export * from "./index"

View File

@@ -0,0 +1 @@
module.exports = { ...require('.') }

View File

@@ -0,0 +1 @@
export * from "./default"

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,271 @@
Object.defineProperty(exports, "__esModule", { value: true });
const {
Decimal,
objectEnumValues,
makeStrictEnum,
Public,
getRuntime,
skip
} = require('./runtime/index-browser.js')
const Prisma = {}
exports.Prisma = Prisma
exports.$Enums = {}
/**
* Prisma Client JS version: 5.22.0
* Query Engine version: 605197351a3c8bdd595af2d2a9bc3025bca48ea2
*/
Prisma.prismaVersion = {
client: "5.22.0",
engine: "605197351a3c8bdd595af2d2a9bc3025bca48ea2"
}
Prisma.PrismaClientKnownRequestError = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`PrismaClientKnownRequestError is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)};
Prisma.PrismaClientUnknownRequestError = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`PrismaClientUnknownRequestError is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.PrismaClientRustPanicError = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`PrismaClientRustPanicError is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.PrismaClientInitializationError = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`PrismaClientInitializationError is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.PrismaClientValidationError = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`PrismaClientValidationError is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.NotFoundError = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`NotFoundError is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.Decimal = Decimal
/**
* Re-export of sql-template-tag
*/
Prisma.sql = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`sqltag is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.empty = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`empty is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.join = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`join is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.raw = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`raw is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.validator = Public.validator
/**
* Extensions
*/
Prisma.getExtensionContext = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`Extensions.getExtensionContext is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.defineExtension = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`Extensions.defineExtension is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
/**
* Shorthand utilities for JSON filtering
*/
Prisma.DbNull = objectEnumValues.instances.DbNull
Prisma.JsonNull = objectEnumValues.instances.JsonNull
Prisma.AnyNull = objectEnumValues.instances.AnyNull
Prisma.NullTypes = {
DbNull: objectEnumValues.classes.DbNull,
JsonNull: objectEnumValues.classes.JsonNull,
AnyNull: objectEnumValues.classes.AnyNull
}
/**
* Enums
*/
exports.Prisma.TransactionIsolationLevel = makeStrictEnum({
Serializable: 'Serializable'
});
exports.Prisma.UserScalarFieldEnum = {
id: 'id',
name: 'name',
email: 'email',
emailVerified: 'emailVerified',
password: 'password',
role: 'role',
image: 'image',
theme: 'theme',
resetToken: 'resetToken',
resetTokenExpiry: 'resetTokenExpiry',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.AccountScalarFieldEnum = {
userId: 'userId',
type: 'type',
provider: 'provider',
providerAccountId: 'providerAccountId',
refresh_token: 'refresh_token',
access_token: 'access_token',
expires_at: 'expires_at',
token_type: 'token_type',
scope: 'scope',
id_token: 'id_token',
session_state: 'session_state',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.SessionScalarFieldEnum = {
sessionToken: 'sessionToken',
userId: 'userId',
expires: 'expires',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.VerificationTokenScalarFieldEnum = {
identifier: 'identifier',
token: 'token',
expires: 'expires'
};
exports.Prisma.LabelScalarFieldEnum = {
id: 'id',
name: 'name',
color: 'color',
userId: 'userId',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.NoteScalarFieldEnum = {
id: 'id',
title: 'title',
content: 'content',
color: 'color',
isPinned: 'isPinned',
isArchived: 'isArchived',
type: 'type',
checkItems: 'checkItems',
labels: 'labels',
images: 'images',
links: 'links',
reminder: 'reminder',
isReminderDone: 'isReminderDone',
reminderRecurrence: 'reminderRecurrence',
reminderLocation: 'reminderLocation',
isMarkdown: 'isMarkdown',
size: 'size',
embedding: 'embedding',
sharedWith: 'sharedWith',
userId: 'userId',
order: 'order',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.NoteShareScalarFieldEnum = {
id: 'id',
noteId: 'noteId',
userId: 'userId',
sharedBy: 'sharedBy',
status: 'status',
permission: 'permission',
notifiedAt: 'notifiedAt',
respondedAt: 'respondedAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.SystemConfigScalarFieldEnum = {
key: 'key',
value: 'value'
};
exports.Prisma.SortOrder = {
asc: 'asc',
desc: 'desc'
};
exports.Prisma.NullsOrder = {
first: 'first',
last: 'last'
};
exports.Prisma.ModelName = {
User: 'User',
Account: 'Account',
Session: 'Session',
VerificationToken: 'VerificationToken',
Label: 'Label',
Note: 'Note',
NoteShare: 'NoteShare',
SystemConfig: 'SystemConfig'
};
/**
* This is a stub Prisma Client that will error at runtime if called.
*/
class PrismaClient {
constructor() {
return new Proxy(this, {
get(target, prop) {
let message
const runtime = getRuntime()
if (runtime.isEdge) {
message = `PrismaClient is not configured to run in ${runtime.prettyName}. In order to run Prisma Client on edge runtime, either:
- Use Prisma Accelerate: https://pris.ly/d/accelerate
- Use Driver Adapters: https://pris.ly/d/driver-adapters
`;
} else {
message = 'PrismaClient is unable to run in this browser environment, or has been bundled for the browser (running in `' + runtime.prettyName + '`).'
}
message += `
If this is unexpected, please open an issue: https://pris.ly/prisma-prisma-bug-report`
throw new Error(message)
}
})
}
}
exports.PrismaClient = PrismaClient
Object.assign(exports, Prisma)

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,97 @@
{
"name": "prisma-client-0924f65183f5d4eff92dbcaaa75ea4e789cffdcbc361852cabe5beaded9c8358",
"main": "index.js",
"types": "index.d.ts",
"browser": "index-browser.js",
"exports": {
"./package.json": "./package.json",
".": {
"require": {
"node": "./index.js",
"edge-light": "./wasm.js",
"workerd": "./wasm.js",
"worker": "./wasm.js",
"browser": "./index-browser.js",
"default": "./index.js"
},
"import": {
"node": "./index.js",
"edge-light": "./wasm.js",
"workerd": "./wasm.js",
"worker": "./wasm.js",
"browser": "./index-browser.js",
"default": "./index.js"
},
"default": "./index.js"
},
"./edge": {
"types": "./edge.d.ts",
"require": "./edge.js",
"import": "./edge.js",
"default": "./edge.js"
},
"./react-native": {
"types": "./react-native.d.ts",
"require": "./react-native.js",
"import": "./react-native.js",
"default": "./react-native.js"
},
"./extension": {
"types": "./extension.d.ts",
"require": "./extension.js",
"import": "./extension.js",
"default": "./extension.js"
},
"./index-browser": {
"types": "./index.d.ts",
"require": "./index-browser.js",
"import": "./index-browser.js",
"default": "./index-browser.js"
},
"./index": {
"types": "./index.d.ts",
"require": "./index.js",
"import": "./index.js",
"default": "./index.js"
},
"./wasm": {
"types": "./wasm.d.ts",
"require": "./wasm.js",
"import": "./wasm.js",
"default": "./wasm.js"
},
"./runtime/library": {
"types": "./runtime/library.d.ts",
"require": "./runtime/library.js",
"import": "./runtime/library.js",
"default": "./runtime/library.js"
},
"./runtime/binary": {
"types": "./runtime/binary.d.ts",
"require": "./runtime/binary.js",
"import": "./runtime/binary.js",
"default": "./runtime/binary.js"
},
"./generator-build": {
"require": "./generator-build/index.js",
"import": "./generator-build/index.js",
"default": "./generator-build/index.js"
},
"./sql": {
"require": {
"types": "./sql.d.ts",
"node": "./sql.js",
"default": "./sql.js"
},
"import": {
"types": "./sql.d.ts",
"node": "./sql.mjs",
"default": "./sql.mjs"
},
"default": "./sql.js"
},
"./*": "./*"
},
"version": "5.22.0",
"sideEffects": false
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,365 @@
declare class AnyNull extends NullTypesEnumValue {
}
declare type Args<T, F extends Operation> = T extends {
[K: symbol]: {
types: {
operations: {
[K in F]: {
args: any;
};
};
};
};
} ? T[symbol]['types']['operations'][F]['args'] : any;
declare class DbNull extends NullTypesEnumValue {
}
export declare namespace Decimal {
export type Constructor = typeof Decimal;
export type Instance = Decimal;
export type Rounding = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
export type Modulo = Rounding | 9;
export type Value = string | number | Decimal;
// http://mikemcl.github.io/decimal.js/#constructor-properties
export interface Config {
precision?: number;
rounding?: Rounding;
toExpNeg?: number;
toExpPos?: number;
minE?: number;
maxE?: number;
crypto?: boolean;
modulo?: Modulo;
defaults?: boolean;
}
}
export declare class Decimal {
readonly d: number[];
readonly e: number;
readonly s: number;
constructor(n: Decimal.Value);
absoluteValue(): Decimal;
abs(): Decimal;
ceil(): Decimal;
clampedTo(min: Decimal.Value, max: Decimal.Value): Decimal;
clamp(min: Decimal.Value, max: Decimal.Value): Decimal;
comparedTo(n: Decimal.Value): number;
cmp(n: Decimal.Value): number;
cosine(): Decimal;
cos(): Decimal;
cubeRoot(): Decimal;
cbrt(): Decimal;
decimalPlaces(): number;
dp(): number;
dividedBy(n: Decimal.Value): Decimal;
div(n: Decimal.Value): Decimal;
dividedToIntegerBy(n: Decimal.Value): Decimal;
divToInt(n: Decimal.Value): Decimal;
equals(n: Decimal.Value): boolean;
eq(n: Decimal.Value): boolean;
floor(): Decimal;
greaterThan(n: Decimal.Value): boolean;
gt(n: Decimal.Value): boolean;
greaterThanOrEqualTo(n: Decimal.Value): boolean;
gte(n: Decimal.Value): boolean;
hyperbolicCosine(): Decimal;
cosh(): Decimal;
hyperbolicSine(): Decimal;
sinh(): Decimal;
hyperbolicTangent(): Decimal;
tanh(): Decimal;
inverseCosine(): Decimal;
acos(): Decimal;
inverseHyperbolicCosine(): Decimal;
acosh(): Decimal;
inverseHyperbolicSine(): Decimal;
asinh(): Decimal;
inverseHyperbolicTangent(): Decimal;
atanh(): Decimal;
inverseSine(): Decimal;
asin(): Decimal;
inverseTangent(): Decimal;
atan(): Decimal;
isFinite(): boolean;
isInteger(): boolean;
isInt(): boolean;
isNaN(): boolean;
isNegative(): boolean;
isNeg(): boolean;
isPositive(): boolean;
isPos(): boolean;
isZero(): boolean;
lessThan(n: Decimal.Value): boolean;
lt(n: Decimal.Value): boolean;
lessThanOrEqualTo(n: Decimal.Value): boolean;
lte(n: Decimal.Value): boolean;
logarithm(n?: Decimal.Value): Decimal;
log(n?: Decimal.Value): Decimal;
minus(n: Decimal.Value): Decimal;
sub(n: Decimal.Value): Decimal;
modulo(n: Decimal.Value): Decimal;
mod(n: Decimal.Value): Decimal;
naturalExponential(): Decimal;
exp(): Decimal;
naturalLogarithm(): Decimal;
ln(): Decimal;
negated(): Decimal;
neg(): Decimal;
plus(n: Decimal.Value): Decimal;
add(n: Decimal.Value): Decimal;
precision(includeZeros?: boolean): number;
sd(includeZeros?: boolean): number;
round(): Decimal;
sine() : Decimal;
sin() : Decimal;
squareRoot(): Decimal;
sqrt(): Decimal;
tangent() : Decimal;
tan() : Decimal;
times(n: Decimal.Value): Decimal;
mul(n: Decimal.Value) : Decimal;
toBinary(significantDigits?: number): string;
toBinary(significantDigits: number, rounding: Decimal.Rounding): string;
toDecimalPlaces(decimalPlaces?: number): Decimal;
toDecimalPlaces(decimalPlaces: number, rounding: Decimal.Rounding): Decimal;
toDP(decimalPlaces?: number): Decimal;
toDP(decimalPlaces: number, rounding: Decimal.Rounding): Decimal;
toExponential(decimalPlaces?: number): string;
toExponential(decimalPlaces: number, rounding: Decimal.Rounding): string;
toFixed(decimalPlaces?: number): string;
toFixed(decimalPlaces: number, rounding: Decimal.Rounding): string;
toFraction(max_denominator?: Decimal.Value): Decimal[];
toHexadecimal(significantDigits?: number): string;
toHexadecimal(significantDigits: number, rounding: Decimal.Rounding): string;
toHex(significantDigits?: number): string;
toHex(significantDigits: number, rounding?: Decimal.Rounding): string;
toJSON(): string;
toNearest(n: Decimal.Value, rounding?: Decimal.Rounding): Decimal;
toNumber(): number;
toOctal(significantDigits?: number): string;
toOctal(significantDigits: number, rounding: Decimal.Rounding): string;
toPower(n: Decimal.Value): Decimal;
pow(n: Decimal.Value): Decimal;
toPrecision(significantDigits?: number): string;
toPrecision(significantDigits: number, rounding: Decimal.Rounding): string;
toSignificantDigits(significantDigits?: number): Decimal;
toSignificantDigits(significantDigits: number, rounding: Decimal.Rounding): Decimal;
toSD(significantDigits?: number): Decimal;
toSD(significantDigits: number, rounding: Decimal.Rounding): Decimal;
toString(): string;
truncated(): Decimal;
trunc(): Decimal;
valueOf(): string;
static abs(n: Decimal.Value): Decimal;
static acos(n: Decimal.Value): Decimal;
static acosh(n: Decimal.Value): Decimal;
static add(x: Decimal.Value, y: Decimal.Value): Decimal;
static asin(n: Decimal.Value): Decimal;
static asinh(n: Decimal.Value): Decimal;
static atan(n: Decimal.Value): Decimal;
static atanh(n: Decimal.Value): Decimal;
static atan2(y: Decimal.Value, x: Decimal.Value): Decimal;
static cbrt(n: Decimal.Value): Decimal;
static ceil(n: Decimal.Value): Decimal;
static clamp(n: Decimal.Value, min: Decimal.Value, max: Decimal.Value): Decimal;
static clone(object?: Decimal.Config): Decimal.Constructor;
static config(object: Decimal.Config): Decimal.Constructor;
static cos(n: Decimal.Value): Decimal;
static cosh(n: Decimal.Value): Decimal;
static div(x: Decimal.Value, y: Decimal.Value): Decimal;
static exp(n: Decimal.Value): Decimal;
static floor(n: Decimal.Value): Decimal;
static hypot(...n: Decimal.Value[]): Decimal;
static isDecimal(object: any): object is Decimal;
static ln(n: Decimal.Value): Decimal;
static log(n: Decimal.Value, base?: Decimal.Value): Decimal;
static log2(n: Decimal.Value): Decimal;
static log10(n: Decimal.Value): Decimal;
static max(...n: Decimal.Value[]): Decimal;
static min(...n: Decimal.Value[]): Decimal;
static mod(x: Decimal.Value, y: Decimal.Value): Decimal;
static mul(x: Decimal.Value, y: Decimal.Value): Decimal;
static noConflict(): Decimal.Constructor; // Browser only
static pow(base: Decimal.Value, exponent: Decimal.Value): Decimal;
static random(significantDigits?: number): Decimal;
static round(n: Decimal.Value): Decimal;
static set(object: Decimal.Config): Decimal.Constructor;
static sign(n: Decimal.Value): number;
static sin(n: Decimal.Value): Decimal;
static sinh(n: Decimal.Value): Decimal;
static sqrt(n: Decimal.Value): Decimal;
static sub(x: Decimal.Value, y: Decimal.Value): Decimal;
static sum(...n: Decimal.Value[]): Decimal;
static tan(n: Decimal.Value): Decimal;
static tanh(n: Decimal.Value): Decimal;
static trunc(n: Decimal.Value): Decimal;
static readonly default?: Decimal.Constructor;
static readonly Decimal?: Decimal.Constructor;
static readonly precision: number;
static readonly rounding: Decimal.Rounding;
static readonly toExpNeg: number;
static readonly toExpPos: number;
static readonly minE: number;
static readonly maxE: number;
static readonly crypto: boolean;
static readonly modulo: Decimal.Modulo;
static readonly ROUND_UP: 0;
static readonly ROUND_DOWN: 1;
static readonly ROUND_CEIL: 2;
static readonly ROUND_FLOOR: 3;
static readonly ROUND_HALF_UP: 4;
static readonly ROUND_HALF_DOWN: 5;
static readonly ROUND_HALF_EVEN: 6;
static readonly ROUND_HALF_CEIL: 7;
static readonly ROUND_HALF_FLOOR: 8;
static readonly EUCLID: 9;
}
declare type Exact<A, W> = (A extends unknown ? (W extends A ? {
[K in keyof A]: Exact<A[K], W[K]>;
} : W) : never) | (A extends Narrowable ? A : never);
export declare function getRuntime(): GetRuntimeOutput;
declare type GetRuntimeOutput = {
id: Runtime;
prettyName: string;
isEdge: boolean;
};
declare class JsonNull extends NullTypesEnumValue {
}
/**
* Generates more strict variant of an enum which, unlike regular enum,
* throws on non-existing property access. This can be useful in following situations:
* - we have an API, that accepts both `undefined` and `SomeEnumType` as an input
* - enum values are generated dynamically from DMMF.
*
* In that case, if using normal enums and no compile-time typechecking, using non-existing property
* will result in `undefined` value being used, which will be accepted. Using strict enum
* in this case will help to have a runtime exception, telling you that you are probably doing something wrong.
*
* Note: if you need to check for existence of a value in the enum you can still use either
* `in` operator or `hasOwnProperty` function.
*
* @param definition
* @returns
*/
export declare function makeStrictEnum<T extends Record<PropertyKey, string | number>>(definition: T): T;
declare type Narrowable = string | number | bigint | boolean | [];
declare class NullTypesEnumValue extends ObjectEnumValue {
_getNamespace(): string;
}
/**
* Base class for unique values of object-valued enums.
*/
declare abstract class ObjectEnumValue {
constructor(arg?: symbol);
abstract _getNamespace(): string;
_getName(): string;
toString(): string;
}
export declare const objectEnumValues: {
classes: {
DbNull: typeof DbNull;
JsonNull: typeof JsonNull;
AnyNull: typeof AnyNull;
};
instances: {
DbNull: DbNull;
JsonNull: JsonNull;
AnyNull: AnyNull;
};
};
declare type Operation = 'findFirst' | 'findFirstOrThrow' | 'findUnique' | 'findUniqueOrThrow' | 'findMany' | 'create' | 'createMany' | 'createManyAndReturn' | 'update' | 'updateMany' | 'upsert' | 'delete' | 'deleteMany' | 'aggregate' | 'count' | 'groupBy' | '$queryRaw' | '$executeRaw' | '$queryRawUnsafe' | '$executeRawUnsafe' | 'findRaw' | 'aggregateRaw' | '$runCommandRaw';
declare namespace Public {
export {
validator
}
}
export { Public }
declare type Runtime = "edge-routine" | "workerd" | "deno" | "lagon" | "react-native" | "netlify" | "electron" | "node" | "bun" | "edge-light" | "fastly" | "unknown";
declare function validator<V>(): <S>(select: Exact<S, V>) => S;
declare function validator<C, M extends Exclude<keyof C, `$${string}`>, O extends keyof C[M] & Operation>(client: C, model: M, operation: O): <S>(select: Exact<S, Args<C[M], O>>) => S;
declare function validator<C, M extends Exclude<keyof C, `$${string}`>, O extends keyof C[M] & Operation, P extends keyof Args<C[M], O>>(client: C, model: M, operation: O, prop: P): <S>(select: Exact<S, Args<C[M], O>[P]>) => S;
export { }

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
export * from "./index"

View File

@@ -0,0 +1,271 @@
Object.defineProperty(exports, "__esModule", { value: true });
const {
Decimal,
objectEnumValues,
makeStrictEnum,
Public,
getRuntime,
skip
} = require('./runtime/index-browser.js')
const Prisma = {}
exports.Prisma = Prisma
exports.$Enums = {}
/**
* Prisma Client JS version: 5.22.0
* Query Engine version: 605197351a3c8bdd595af2d2a9bc3025bca48ea2
*/
Prisma.prismaVersion = {
client: "5.22.0",
engine: "605197351a3c8bdd595af2d2a9bc3025bca48ea2"
}
Prisma.PrismaClientKnownRequestError = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`PrismaClientKnownRequestError is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)};
Prisma.PrismaClientUnknownRequestError = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`PrismaClientUnknownRequestError is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.PrismaClientRustPanicError = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`PrismaClientRustPanicError is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.PrismaClientInitializationError = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`PrismaClientInitializationError is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.PrismaClientValidationError = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`PrismaClientValidationError is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.NotFoundError = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`NotFoundError is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.Decimal = Decimal
/**
* Re-export of sql-template-tag
*/
Prisma.sql = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`sqltag is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.empty = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`empty is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.join = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`join is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.raw = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`raw is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.validator = Public.validator
/**
* Extensions
*/
Prisma.getExtensionContext = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`Extensions.getExtensionContext is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
Prisma.defineExtension = () => {
const runtimeName = getRuntime().prettyName;
throw new Error(`Extensions.defineExtension is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
)}
/**
* Shorthand utilities for JSON filtering
*/
Prisma.DbNull = objectEnumValues.instances.DbNull
Prisma.JsonNull = objectEnumValues.instances.JsonNull
Prisma.AnyNull = objectEnumValues.instances.AnyNull
Prisma.NullTypes = {
DbNull: objectEnumValues.classes.DbNull,
JsonNull: objectEnumValues.classes.JsonNull,
AnyNull: objectEnumValues.classes.AnyNull
}
/**
* Enums
*/
exports.Prisma.TransactionIsolationLevel = makeStrictEnum({
Serializable: 'Serializable'
});
exports.Prisma.UserScalarFieldEnum = {
id: 'id',
name: 'name',
email: 'email',
emailVerified: 'emailVerified',
password: 'password',
role: 'role',
image: 'image',
theme: 'theme',
resetToken: 'resetToken',
resetTokenExpiry: 'resetTokenExpiry',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.AccountScalarFieldEnum = {
userId: 'userId',
type: 'type',
provider: 'provider',
providerAccountId: 'providerAccountId',
refresh_token: 'refresh_token',
access_token: 'access_token',
expires_at: 'expires_at',
token_type: 'token_type',
scope: 'scope',
id_token: 'id_token',
session_state: 'session_state',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.SessionScalarFieldEnum = {
sessionToken: 'sessionToken',
userId: 'userId',
expires: 'expires',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.VerificationTokenScalarFieldEnum = {
identifier: 'identifier',
token: 'token',
expires: 'expires'
};
exports.Prisma.LabelScalarFieldEnum = {
id: 'id',
name: 'name',
color: 'color',
userId: 'userId',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.NoteScalarFieldEnum = {
id: 'id',
title: 'title',
content: 'content',
color: 'color',
isPinned: 'isPinned',
isArchived: 'isArchived',
type: 'type',
checkItems: 'checkItems',
labels: 'labels',
images: 'images',
links: 'links',
reminder: 'reminder',
isReminderDone: 'isReminderDone',
reminderRecurrence: 'reminderRecurrence',
reminderLocation: 'reminderLocation',
isMarkdown: 'isMarkdown',
size: 'size',
embedding: 'embedding',
sharedWith: 'sharedWith',
userId: 'userId',
order: 'order',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.NoteShareScalarFieldEnum = {
id: 'id',
noteId: 'noteId',
userId: 'userId',
sharedBy: 'sharedBy',
status: 'status',
permission: 'permission',
notifiedAt: 'notifiedAt',
respondedAt: 'respondedAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.SystemConfigScalarFieldEnum = {
key: 'key',
value: 'value'
};
exports.Prisma.SortOrder = {
asc: 'asc',
desc: 'desc'
};
exports.Prisma.NullsOrder = {
first: 'first',
last: 'last'
};
exports.Prisma.ModelName = {
User: 'User',
Account: 'Account',
Session: 'Session',
VerificationToken: 'VerificationToken',
Label: 'Label',
Note: 'Note',
NoteShare: 'NoteShare',
SystemConfig: 'SystemConfig'
};
/**
* This is a stub Prisma Client that will error at runtime if called.
*/
class PrismaClient {
constructor() {
return new Proxy(this, {
get(target, prop) {
let message
const runtime = getRuntime()
if (runtime.isEdge) {
message = `PrismaClient is not configured to run in ${runtime.prettyName}. In order to run Prisma Client on edge runtime, either:
- Use Prisma Accelerate: https://pris.ly/d/accelerate
- Use Driver Adapters: https://pris.ly/d/driver-adapters
`;
} else {
message = 'PrismaClient is unable to run in this browser environment, or has been bundled for the browser (running in `' + runtime.prettyName + '`).'
}
message += `
If this is unexpected, please open an issue: https://pris.ly/prisma-prisma-bug-report`
throw new Error(message)
}
})
}
}
exports.PrismaClient = PrismaClient
Object.assign(exports, Prisma)

Binary file not shown.

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Note" ADD COLUMN "embedding" TEXT;

View File

@@ -3,6 +3,7 @@
generator client {
provider = "prisma-client-js"
output = "./client-generated"
}
datasource db {
@@ -16,11 +17,17 @@ model User {
email String @unique
emailVerified DateTime?
password String? // Hashed password
role String @default("USER") // "USER" or "ADMIN"
image String?
theme String @default("light")
resetToken String? @unique
resetTokenExpiry DateTime?
accounts Account[]
sessions Session[]
notes Note[]
labels Label[]
receivedShares NoteShare[] @relation("ReceivedShares")
sentShares NoteShare[] @relation("SentShares")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@@ -94,8 +101,12 @@ model Note {
reminderRecurrence String? // "none", "daily", "weekly", "monthly", "custom"
reminderLocation String? // Location for location-based reminders
isMarkdown Boolean @default(false) // Whether content uses Markdown
size String @default("small") // "small", "medium", "large"
embedding String? // Vector embeddings stored as JSON string for semantic search
sharedWith String? // Array of user IDs (collaborators) stored as JSON string
userId String? // Owner of the note
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
shares NoteShare[] // All share records for this note
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -105,4 +116,30 @@ model Note {
@@index([order])
@@index([reminder])
@@index([userId])
}
model NoteShare {
id String @id @default(cuid())
noteId String
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
userId String
user User @relation("ReceivedShares", fields: [userId], references: [id], onDelete: Cascade)
sharedBy String // User ID who shared the note
sharer User @relation("SentShares", fields: [sharedBy], references: [id], onDelete: Cascade)
status String @default("pending") // "pending", "accepted", "declined", "removed"
permission String @default("view") // "view", "comment", "edit"
notifiedAt DateTime?
respondedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([noteId, userId])
@@index([userId])
@@index([status])
@@index([sharedBy])
}
model SystemConfig {
key String @id
value String
}

Some files were not shown because too many files have changed in this diff Show More