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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user