fix: improve note interactions and markdown LaTeX support

## Bug Fixes

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

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

## Technical Details

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

## Testing

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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>
)
}

View File

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

View File

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

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

View File

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

View File

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

View File

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