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>
|
||||
)
|
||||
}
|
||||
15
keep-notes/app/(main)/archive/page.tsx
Normal file
15
keep-notes/app/(main)/archive/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { getArchivedNotes } from '@/app/actions/notes'
|
||||
import { MasonryGrid } from '@/components/masonry-grid'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function ArchivePage() {
|
||||
const notes = await getArchivedNotes()
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
<h1 className="text-3xl font-bold mb-8">Archive</h1>
|
||||
<MasonryGrid notes={notes} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
69
keep-notes/app/(main)/page.tsx
Normal file
69
keep-notes/app/(main)/page.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { Note } from '@/lib/types'
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
const loadNotes = async () => {
|
||||
setIsLoading(true)
|
||||
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 getAllNotes()
|
||||
|
||||
// Filter by selected labels
|
||||
if (labelFilter.length > 0) {
|
||||
allNotes = allNotes.filter((note: any) =>
|
||||
note.labels?.some((label: string) => labelFilter.includes(label))
|
||||
)
|
||||
}
|
||||
|
||||
// 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))
|
||||
)
|
||||
}
|
||||
|
||||
setNotes(allNotes)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
loadNotes()
|
||||
// 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 />
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">Loading...</div>
|
||||
) : (
|
||||
<MasonryGrid notes={notes} />
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
184
keep-notes/app/(main)/settings/page.tsx
Normal file
184
keep-notes/app/(main)/settings/page.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
'use client';
|
||||
|
||||
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, BrainCircuit } from 'lucide-react';
|
||||
import { cleanupAllOrphans, syncAllEmbeddings } from '@/app/actions/notes';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function SettingsPage() {
|
||||
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);
|
||||
|
||||
const checkConnection = async () => {
|
||||
setLoading(true);
|
||||
setStatus('idle');
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await fetch('/api/ai/test');
|
||||
const data = await res.json();
|
||||
|
||||
setConfig({
|
||||
provider: data.provider,
|
||||
status: res.ok ? 'connected' : 'disconnected'
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setStatus('success');
|
||||
setResult(data);
|
||||
} else {
|
||||
setStatus('error');
|
||||
setResult(data);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
setStatus('error');
|
||||
setResult({ message: error.message, stack: error.stack });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCleanup = async () => {
|
||||
setCleanupLoading(true);
|
||||
try {
|
||||
const result = await cleanupAllOrphans();
|
||||
if (result.success) {
|
||||
toast.success(result.message || `Cleanup complete: ${result.created} created, ${result.deleted} removed`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Error during cleanup");
|
||||
} finally {
|
||||
setCleanupLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkConnection();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-10 px-4 max-w-4xl space-y-8">
|
||||
<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">
|
||||
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>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" />}
|
||||
Test Connection
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
|
||||
{/* 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">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">API Status</p>
|
||||
<Badge variant={status === 'success' ? 'default' : 'destructive'}>
|
||||
{status === 'success' ? 'Operational' : 'Error'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test Result */}
|
||||
{result && (
|
||||
<div className="space-y-2">
|
||||
<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">Troubleshooting Tips:</p>
|
||||
<ul className="list-disc list-inside mt-1">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database className="w-5 h-5" />
|
||||
Maintenance
|
||||
</CardTitle>
|
||||
<CardDescription>Tools to maintain your database health.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user