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:
77
keep-notes/app/(auth)/forgot-password/page.tsx
Normal file
77
keep-notes/app/(auth)/forgot-password/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
13
keep-notes/app/(auth)/layout.tsx
Normal file
13
keep-notes/app/(auth)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
keep-notes/app/(auth)/login/page.tsx
Normal file
17
keep-notes/app/(auth)/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
keep-notes/app/(auth)/register/page.tsx
Normal file
20
keep-notes/app/(auth)/register/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
keep-notes/app/(auth)/reset-password/page.tsx
Normal file
95
keep-notes/app/(auth)/reset-password/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
keep-notes/app/(main)/admin/create-user-dialog.tsx
Normal file
78
keep-notes/app/(main)/admin/create-user-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
keep-notes/app/(main)/admin/page.tsx
Normal file
39
keep-notes/app/(main)/admin/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
245
keep-notes/app/(main)/admin/settings/admin-settings-form.tsx
Normal file
245
keep-notes/app/(main)/admin/settings/admin-settings-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
21
keep-notes/app/(main)/admin/settings/page.tsx
Normal file
21
keep-notes/app/(main)/admin/settings/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
82
keep-notes/app/(main)/admin/user-list.tsx
Normal file
82
keep-notes/app/(main)/admin/user-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
23
keep-notes/app/(main)/layout.tsx
Normal file
23
keep-notes/app/(main)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
@@ -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>
|
||||
28
keep-notes/app/(main)/settings/profile/page.tsx
Normal file
28
keep-notes/app/(main)/settings/profile/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
83
keep-notes/app/(main)/settings/profile/profile-form.tsx
Normal file
83
keep-notes/app/(main)/settings/profile/profile-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
156
keep-notes/app/(main)/support/page.tsx
Normal file
156
keep-notes/app/(main)/support/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
keep-notes/app/actions/admin-settings.ts
Normal file
59
keep-notes/app/actions/admin-settings.ts
Normal 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' }
|
||||
}
|
||||
}
|
||||
120
keep-notes/app/actions/admin.ts
Normal file
120
keep-notes/app/actions/admin.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
86
keep-notes/app/actions/auth-reset.ts
Normal file
86
keep-notes/app/actions/auth-reset.ts
Normal 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
100
keep-notes/app/actions/profile.ts
Normal file
100
keep-notes/app/actions/profile.ts
Normal 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' }
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
100
keep-notes/app/api/admin/embeddings/validate/route.ts
Normal file
100
keep-notes/app/api/admin/embeddings/validate/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
124
keep-notes/app/api/fix-labels/route.ts
Normal file
124
keep-notes/app/api/fix-labels/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -37,6 +37,7 @@ export const { auth, signIn, signOut, handlers } = NextAuth({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
70
keep-notes/components/collaborator-avatars.tsx
Normal file
70
keep-notes/components/collaborator-avatars.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
330
keep-notes/components/collaborator-dialog.tsx
Normal file
330
keep-notes/components/collaborator-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>{`
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
234
keep-notes/components/notification-panel.tsx
Normal file
234
keep-notes/components/notification-panel.tsx
Normal 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 "{request.note.title || 'Untitled'}"
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
7
keep-notes/components/session-provider-wrapper.tsx
Normal file
7
keep-notes/components/session-provider-wrapper.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { SessionProvider } from 'next-auth/react'
|
||||
|
||||
export function SessionProviderWrapper({ children }: { children: React.ReactNode }) {
|
||||
return <SessionProvider>{children}</SessionProvider>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
50
keep-notes/components/ui/avatar.tsx
Normal file
50
keep-notes/components/ui/avatar.tsx
Normal 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 }
|
||||
16
keep-notes/components/ui/label.tsx
Normal file
16
keep-notes/components/ui/label.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
75
keep-notes/components/user-nav.tsx
Normal file
75
keep-notes/components/user-nav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
33
keep-notes/context/NoteRefreshContext.tsx
Normal file
33
keep-notes/context/NoteRefreshContext.tsx
Normal 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
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
55
keep-notes/lib/config.ts
Normal 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;
|
||||
35
keep-notes/lib/email-template.ts
Normal file
35
keep-notes/lib/email-template.ts
Normal 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
65
keep-notes/lib/mail.ts
Normal 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})`
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
// @ts-ignore - Generated client
|
||||
import { PrismaClient } from '../prisma/client-generated'
|
||||
|
||||
const prismaClientSingleton = () => {
|
||||
return new PrismaClient({
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
12838
keep-notes/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
1
keep-notes/prisma/client-generated/default.d.ts
vendored
Normal file
1
keep-notes/prisma/client-generated/default.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./index"
|
||||
1
keep-notes/prisma/client-generated/default.js
Normal file
1
keep-notes/prisma/client-generated/default.js
Normal file
@@ -0,0 +1 @@
|
||||
module.exports = { ...require('.') }
|
||||
1
keep-notes/prisma/client-generated/edge.d.ts
vendored
Normal file
1
keep-notes/prisma/client-generated/edge.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./default"
|
||||
280
keep-notes/prisma/client-generated/edge.js
Normal file
280
keep-notes/prisma/client-generated/edge.js
Normal file
File diff suppressed because one or more lines are too long
271
keep-notes/prisma/client-generated/index-browser.js
Normal file
271
keep-notes/prisma/client-generated/index-browser.js
Normal 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)
|
||||
13884
keep-notes/prisma/client-generated/index.d.ts
vendored
Normal file
13884
keep-notes/prisma/client-generated/index.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
301
keep-notes/prisma/client-generated/index.js
Normal file
301
keep-notes/prisma/client-generated/index.js
Normal file
File diff suppressed because one or more lines are too long
97
keep-notes/prisma/client-generated/package.json
Normal file
97
keep-notes/prisma/client-generated/package.json
Normal 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
|
||||
}
|
||||
BIN
keep-notes/prisma/client-generated/query_engine-windows.dll.node
Normal file
BIN
keep-notes/prisma/client-generated/query_engine-windows.dll.node
Normal file
Binary file not shown.
Binary file not shown.
31
keep-notes/prisma/client-generated/runtime/edge-esm.js
Normal file
31
keep-notes/prisma/client-generated/runtime/edge-esm.js
Normal file
File diff suppressed because one or more lines are too long
31
keep-notes/prisma/client-generated/runtime/edge.js
Normal file
31
keep-notes/prisma/client-generated/runtime/edge.js
Normal file
File diff suppressed because one or more lines are too long
365
keep-notes/prisma/client-generated/runtime/index-browser.d.ts
vendored
Normal file
365
keep-notes/prisma/client-generated/runtime/index-browser.d.ts
vendored
Normal 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 { }
|
||||
13
keep-notes/prisma/client-generated/runtime/index-browser.js
Normal file
13
keep-notes/prisma/client-generated/runtime/index-browser.js
Normal file
File diff suppressed because one or more lines are too long
3403
keep-notes/prisma/client-generated/runtime/library.d.ts
vendored
Normal file
3403
keep-notes/prisma/client-generated/runtime/library.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
143
keep-notes/prisma/client-generated/runtime/library.js
Normal file
143
keep-notes/prisma/client-generated/runtime/library.js
Normal file
File diff suppressed because one or more lines are too long
80
keep-notes/prisma/client-generated/runtime/react-native.js
vendored
Normal file
80
keep-notes/prisma/client-generated/runtime/react-native.js
vendored
Normal file
File diff suppressed because one or more lines are too long
32
keep-notes/prisma/client-generated/runtime/wasm.js
Normal file
32
keep-notes/prisma/client-generated/runtime/wasm.js
Normal file
File diff suppressed because one or more lines are too long
1
keep-notes/prisma/client-generated/wasm.d.ts
vendored
Normal file
1
keep-notes/prisma/client-generated/wasm.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./index"
|
||||
271
keep-notes/prisma/client-generated/wasm.js
Normal file
271
keep-notes/prisma/client-generated/wasm.js
Normal 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.
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Note" ADD COLUMN "embedding" TEXT;
|
||||
@@ -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
Reference in New Issue
Block a user