Add BMAD framework, authentication, and new features

This commit is contained in:
2026-01-08 21:23:23 +01:00
parent f07d28aefd
commit 15a95fb319
1298 changed files with 73308 additions and 154901 deletions

View File

@@ -0,0 +1,33 @@
import { Button } from "@/components/ui/button"
import { X } from "lucide-react"
interface EditorImagesProps {
images: string[]
onRemove: (index: number) => void
}
export function EditorImages({ images, onRemove }: EditorImagesProps) {
if (!images || images.length === 0) return null
return (
<div className="flex flex-col gap-3 mb-4">
{images.map((img, idx) => (
<div key={idx} className="relative group">
<img
src={img}
alt=""
className="h-auto rounded-lg"
/>
<Button
variant="ghost"
size="sm"
className="absolute top-2 right-2 h-7 w-7 p-0 bg-white/90 hover:bg-white opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => onRemove(idx)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)
}

View File

@@ -3,18 +3,26 @@
import { useState, useEffect } from 'react'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Menu, Search, Archive, StickyNote, Tag, Moon, Sun } from 'lucide-react'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet'
import { Menu, Search, StickyNote, Tag, Moon, Sun, X, Bell, Trash2, Archive } from 'lucide-react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { cn } from '@/lib/utils'
import { searchNotes } from '@/app/actions/notes'
import { useRouter } from 'next/navigation'
import { useLabels } from '@/context/LabelContext'
import { LabelManagementDialog } from './label-management-dialog'
import { LabelFilter } from './label-filter'
interface HeaderProps {
@@ -24,129 +32,279 @@ interface HeaderProps {
onColorFilterChange?: (color: string | null) => void
}
export function Header({ selectedLabels = [], selectedColor, onLabelFilterChange, onColorFilterChange }: HeaderProps = {}) {
export function Header({
selectedLabels = [],
selectedColor = null,
onLabelFilterChange,
onColorFilterChange
}: HeaderProps = {}) {
const [searchQuery, setSearchQuery] = useState('')
const [isSearching, setIsSearching] = useState(false)
const [theme, setTheme] = useState<'light' | 'dark'>('light')
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
const pathname = usePathname()
const router = useRouter()
const searchParams = useSearchParams()
const { labels } = useLabels()
const currentLabels = searchParams.get('labels')?.split(',').filter(Boolean) || []
const currentSearch = searchParams.get('search') || ''
const currentColor = searchParams.get('color') || ''
useEffect(() => {
// Check for saved theme or system preference
const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | null
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
const initialTheme = savedTheme || systemTheme
setSearchQuery(currentSearch)
}, [currentSearch])
setTheme(initialTheme)
document.documentElement.classList.toggle('dark', initialTheme === 'dark')
useEffect(() => {
const savedTheme = localStorage.getItem('theme') || 'light'
applyTheme(savedTheme)
}, [])
const toggleTheme = () => {
const newTheme = theme === 'light' ? 'dark' : 'light'
setTheme(newTheme)
const applyTheme = (newTheme: string) => {
setTheme(newTheme as any)
localStorage.setItem('theme', newTheme)
document.documentElement.classList.toggle('dark', newTheme === 'dark')
}
// Remove all theme classes first
document.documentElement.classList.remove('dark')
document.documentElement.removeAttribute('data-theme')
const handleSearch = async (query: string) => {
setSearchQuery(query)
if (query.trim()) {
setIsSearching(true)
// Search functionality will be handled by the parent component
// For now, we'll just update the URL
router.push(`/?search=${encodeURIComponent(query)}`)
setIsSearching(false)
} else {
router.push('/')
if (newTheme === 'dark') {
document.documentElement.classList.add('dark')
} else if (newTheme !== 'light') {
document.documentElement.setAttribute('data-theme', newTheme)
if (newTheme === 'midnight') {
document.documentElement.classList.add('dark')
}
}
}
const handleSearch = (query: string) => {
setSearchQuery(query)
const params = new URLSearchParams(searchParams.toString())
if (query.trim()) {
params.set('search', query)
} else {
params.delete('search')
}
router.push(`/?${params.toString()}`)
}
const removeLabelFilter = (labelToRemove: string) => {
const newLabels = currentLabels.filter(l => l !== labelToRemove)
const params = new URLSearchParams(searchParams.toString())
if (newLabels.length > 0) {
params.set('labels', newLabels.join(','))
} else {
params.delete('labels')
}
router.push(`/?${params.toString()}`)
}
const removeColorFilter = () => {
const params = new URLSearchParams(searchParams.toString())
params.delete('color')
router.push(`/?${params.toString()}`)
}
const clearAllFilters = () => {
setSearchQuery('')
router.push('/')
}
const handleFilterChange = (newLabels: string[]) => {
const params = new URLSearchParams(searchParams.toString())
if (newLabels.length > 0) {
params.set('labels', newLabels.join(','))
} else {
params.delete('labels')
}
router.push(`/?${params.toString()}`)
}
const handleColorChange = (newColor: string | null) => {
const params = new URLSearchParams(searchParams.toString())
if (newColor) {
params.set('color', newColor)
} else {
params.delete('color')
}
router.push(`/?${params.toString()}`)
}
const NavItem = ({ href, icon: Icon, label, active }: any) => (
<Link
href={href}
onClick={() => setIsSidebarOpen(false)}
className={cn(
"flex items-center gap-3 px-4 py-3 rounded-r-full text-sm font-medium transition-colors mr-2",
active
? "bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-100"
: "hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-700 dark:text-gray-300"
)}
>
<Icon className={cn("h-5 w-5", active && "fill-current")} />
{label}
</Link>
)
const hasActiveFilters = currentLabels.length > 0 || !!currentSearch || !!currentColor
return (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-16 items-center px-4 gap-4">
{/* Mobile Menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="md:hidden">
<Menu className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuItem asChild>
<Link href="/" className="flex items-center">
<StickyNote className="h-4 w-4 mr-2" />
Notes
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/archive" className="flex items-center">
<Archive className="h-4 w-4 mr-2" />
Archive
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<>
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 flex flex-col transition-all duration-200">
<div className="flex h-16 items-center px-4 gap-4 shrink-0">
<Sheet open={isSidebarOpen} onOpenChange={setIsSidebarOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="-ml-2 md:hidden">
<Menu className="h-6 w-6" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-[280px] sm:w-[320px] p-0 pt-4">
<SheetHeader className="px-4 mb-4">
<SheetTitle className="flex items-center gap-2 text-xl font-normal text-amber-500">
<StickyNote className="h-6 w-6" />
Memento
</SheetTitle>
</SheetHeader>
<div className="flex flex-col gap-1 py-2">
<NavItem
href="/"
icon={StickyNote}
label="Notes"
active={pathname === '/' && !hasActiveFilters}
/>
<NavItem
href="/reminders"
icon={Bell}
label="Reminders"
active={pathname === '/reminders'}
/>
<div className="my-2 px-4 flex items-center justify-between">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">Labels</span>
<LabelManagementDialog />
</div>
{labels.map(label => (
<NavItem
key={label.id}
href={`/?labels=${encodeURIComponent(label.name)}`}
icon={Tag}
label={label.name}
active={currentLabels.includes(label.name)}
/>
))}
{/* Logo */}
<Link href="/" className="flex items-center gap-2">
<StickyNote className="h-6 w-6 text-yellow-500" />
<span className="font-semibold text-xl hidden sm:inline-block">Memento</span>
</Link>
<div className="my-2 border-t border-gray-200 dark:border-zinc-800" />
<NavItem
href="/archive"
icon={Archive}
label="Archive"
active={pathname === '/archive'}
/>
<NavItem
href="/trash"
icon={Trash2}
label="Trash"
active={pathname === '/trash'}
/>
</div>
</SheetContent>
</Sheet>
{/* Search Bar */}
<div className="flex-1 max-w-2xl flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="Search notes..."
className="pl-10"
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
/>
<Link href="/" className="flex items-center gap-2 mr-4">
<StickyNote className="h-7 w-7 text-amber-500" />
<span className="font-medium text-xl hidden sm:inline-block text-gray-600 dark:text-gray-200">
Memento
</span>
</Link>
<div className="flex-1 max-w-2xl relative">
<div className="relative group">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 group-focus-within:text-gray-600 dark:group-focus-within:text-gray-200 transition-colors" />
<Input
placeholder="Search"
className="pl-10 pr-12 h-11 bg-gray-100 dark:bg-zinc-800/50 border-transparent focus:bg-white dark:focus:bg-zinc-900 focus:border-gray-200 dark:focus:border-zinc-700 shadow-none transition-all"
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
/>
{searchQuery && (
<button
onClick={() => handleSearch('')}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<div className="absolute right-0 top-0 h-full flex items-center pr-2">
<LabelFilter
selectedLabels={currentLabels}
selectedColor={currentColor || null}
onFilterChange={handleFilterChange}
onColorChange={handleColorChange}
/>
</div>
</div>
<div className="flex items-center gap-1 sm:gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
{theme === 'light' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => applyTheme('light')}>Light</DropdownMenuItem>
<DropdownMenuItem onClick={() => applyTheme('dark')}>Dark</DropdownMenuItem>
<DropdownMenuItem onClick={() => applyTheme('midnight')}>Midnight</DropdownMenuItem>
<DropdownMenuItem onClick={() => applyTheme('sepia')}>Sepia</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{onLabelFilterChange && (
<LabelFilter
selectedLabels={selectedLabels}
selectedColor={selectedColor}
onFilterChange={onLabelFilterChange}
onColorChange={onColorFilterChange}
/>
)}
</div>
{/* Theme Toggle */}
<Button variant="ghost" size="sm" onClick={toggleTheme}>
{theme === 'light' ? (
<Moon className="h-5 w-5" />
) : (
<Sun className="h-5 w-5" />
)}
</Button>
</div>
{/* Desktop Navigation */}
<nav className="hidden md:flex border-t">
<Link
href="/"
className={cn(
'flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors hover:bg-accent',
pathname === '/' && 'bg-accent'
)}
>
<StickyNote className="h-4 w-4" />
Notes
</Link>
<Link
href="/archive"
className={cn(
'flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors hover:bg-accent',
pathname === '/archive' && 'bg-accent'
)}
>
<Archive className="h-4 w-4" />
Archive
</Link>
</nav>
</header>
{hasActiveFilters && (
<div className="px-4 pb-3 flex items-center gap-2 overflow-x-auto border-t border-gray-100 dark:border-zinc-800 pt-2 bg-white/50 dark:bg-zinc-900/50 backdrop-blur-sm animate-in slide-in-from-top-2">
{currentSearch && (
<Badge variant="secondary" className="flex items-center gap-1 h-7 whitespace-nowrap pl-2 pr-1">
Search: {currentSearch}
<button onClick={() => handleSearch('')} className="ml-1 hover:bg-black/10 dark:hover:bg-white/10 rounded-full p-0.5">
<X className="h-3 w-3" />
</button>
</Badge>
)}
{currentColor && (
<Badge variant="secondary" className="flex items-center gap-1 h-7 whitespace-nowrap pl-2 pr-1">
<div className={cn("w-3 h-3 rounded-full border border-black/10", `bg-${currentColor}-500`)} />
Color: {currentColor}
<button onClick={removeColorFilter} className="ml-1 hover:bg-black/10 dark:hover:bg-white/10 rounded-full p-0.5">
<X className="h-3 w-3" />
</button>
</Badge>
)}
{currentLabels.map(label => (
<Badge key={label} variant="secondary" className="flex items-center gap-1 h-7 whitespace-nowrap pl-2 pr-1">
<Tag className="h-3 w-3" />
{label}
<button onClick={() => removeLabelFilter(label)} className="ml-1 hover:bg-black/10 dark:hover:bg-white/10 rounded-full p-0.5">
<X className="h-3 w-3" />
</button>
</Badge>
))}
<Button
variant="ghost"
size="sm"
onClick={clearAllFilters}
className="h-7 text-xs text-blue-600 hover:text-blue-700 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/20 whitespace-nowrap ml-auto"
>
Clear all
</Button>
</div>
)}
</header>
</>
)
}

View File

@@ -0,0 +1,160 @@
'use client'
import { useState } from 'react'
import { Button } from './ui/button'
import { Input } from './ui/input'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from './ui/dialog'
import { Badge } from './ui/badge'
import { Settings, Plus, Palette, Trash2, Tag } from 'lucide-react'
import { LABEL_COLORS, LabelColorName } from '@/lib/types'
import { cn } from '@/lib/utils'
import { useLabels } from '@/context/LabelContext'
export function LabelManagementDialog() {
const { labels, loading, addLabel, updateLabel, deleteLabel } = useLabels()
const [newLabel, setNewLabel] = useState('')
const [editingColorId, setEditingColorId] = useState<string | null>(null)
const handleAddLabel = async () => {
const trimmed = newLabel.trim()
if (trimmed) {
try {
await addLabel(trimmed, 'gray')
setNewLabel('')
} catch (error) {
console.error('Failed to add label:', error)
}
}
}
const handleDeleteLabel = async (id: string) => {
if (confirm('Are you sure you want to delete this label?')) {
try {
await deleteLabel(id)
} catch (error) {
console.error('Failed to delete label:', error)
}
}
}
const handleChangeColor = async (id: string, color: LabelColorName) => {
try {
await updateLabel(id, { color })
setEditingColorId(null)
} catch (error) {
console.error('Failed to update label color:', error)
}
}
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" title="Manage Labels">
<Settings className="h-5 w-5" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Edit Labels</DialogTitle>
<DialogDescription>
Create, edit colors, or delete labels.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Add new label */}
<div className="flex gap-2">
<Input
placeholder="Create new label"
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddLabel()
}
}}
/>
<Button onClick={handleAddLabel} size="icon">
<Plus className="h-4 w-4" />
</Button>
</div>
{/* List labels */}
<div className="max-h-[60vh] overflow-y-auto space-y-2">
{loading ? (
<p className="text-sm text-gray-500">Loading...</p>
) : labels.length === 0 ? (
<p className="text-sm text-gray-500">No labels found.</p>
) : (
labels.map((label) => {
const colorClasses = LABEL_COLORS[label.color]
const isEditing = editingColorId === label.id
return (
<div key={label.id} className="flex items-center justify-between p-2 rounded-md hover:bg-gray-100 dark:hover:bg-zinc-800/50 group">
<div className="flex items-center gap-3 flex-1 relative">
<Tag className={cn("h-4 w-4", colorClasses.text)} />
<span className="font-medium text-sm">{label.name}</span>
{/* Color Picker Popover */}
{isEditing && (
<div className="absolute z-20 top-8 left-0 bg-white dark:bg-zinc-900 border rounded-lg shadow-xl p-3 animate-in fade-in zoom-in-95 w-48">
<div className="grid grid-cols-5 gap-2">
{(Object.keys(LABEL_COLORS) as LabelColorName[]).map((color) => {
const classes = LABEL_COLORS[color]
return (
<button
key={color}
className={cn(
'h-7 w-7 rounded-full border-2 transition-all hover:scale-110',
classes.bg,
label.color === color ? 'border-gray-900 dark:border-gray-100 ring-2 ring-offset-1' : 'border-transparent'
)}
onClick={() => handleChangeColor(label.id, color)}
title={color}
/>
)
})}
</div>
</div>
)}
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
onClick={() => setEditingColorId(isEditing ? null : label.id)}
title="Change Color"
>
<Palette className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20"
onClick={() => handleDeleteLabel(label.id)}
title="Delete Label"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
)
})
)}
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,9 +1,11 @@
'use client'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { useState } from 'react'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Tag } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Tag, Plus, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLabels } from '@/context/LabelContext'
import { LabelBadge } from './label-badge'
@@ -20,10 +22,15 @@ export function LabelSelector({
selectedLabels,
onLabelsChange,
variant = 'default',
triggerLabel = 'Tags',
triggerLabel = 'Labels',
align = 'start',
}: LabelSelectorProps) {
const { labels, loading } = useLabels()
const { labels, loading, addLabel } = useLabels()
const [search, setSearch] = useState('')
const filteredLabels = labels.filter(l =>
l.name.toLowerCase().includes(search.toLowerCase())
)
const handleToggleLabel = (labelName: string) => {
if (selectedLabels.includes(labelName)) {
@@ -33,39 +40,90 @@ export function LabelSelector({
}
}
const handleCreateLabel = async () => {
const trimmed = search.trim()
if (trimmed) {
await addLabel(trimmed) // Let backend assign random color
onLabelsChange([...selectedLabels, trimmed])
setSearch('')
}
}
const showCreateOption = search.trim() && !labels.some(l => l.name.toLowerCase() === search.trim().toLowerCase())
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8">
<Tag className="h-4 w-4 mr-2" />
<Button variant="ghost" size="sm" className="h-8 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 px-2">
<Tag className={cn("h-4 w-4", triggerLabel && "mr-2")} />
{triggerLabel}
{selectedLabels.length > 0 && (
<Badge variant="secondary" className="ml-2 h-5 min-w-5 px-1.5">
<Badge variant="secondary" className="ml-2 h-5 min-w-5 px-1.5 bg-gray-200 text-gray-800 dark:bg-zinc-700 dark:text-zinc-300">
{selectedLabels.length}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align={align} className="w-64">
<div className="max-h-64 overflow-y-auto">
<DropdownMenuContent align={align} className="w-64 p-0">
<div className="p-2">
<Input
placeholder="Enter label name"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8 text-sm"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
if (showCreateOption) handleCreateLabel()
}
}}
/>
</div>
<div className="max-h-64 overflow-y-auto px-1 pb-1">
{loading ? (
<div className="p-4 text-sm text-gray-500">Loading...</div>
) : labels.length === 0 ? (
<div className="p-4 text-sm text-gray-500">No labels yet</div>
<div className="p-2 text-sm text-gray-500 text-center">Loading...</div>
) : (
labels.map((label) => {
const isSelected = selectedLabels.includes(label.name)
<>
{filteredLabels.map((label) => {
const isSelected = selectedLabels.includes(label.name)
return (
<div
key={label.id}
onClick={(e) => {
e.preventDefault()
handleToggleLabel(label.name)
}}
className="flex items-center gap-2 p-2 rounded-sm hover:bg-gray-100 dark:hover:bg-zinc-800 cursor-pointer text-sm"
>
<div className={cn(
"h-4 w-4 border rounded flex items-center justify-center transition-colors",
isSelected ? "bg-blue-600 border-blue-600 text-white" : "border-gray-400"
)}>
{isSelected && <Check className="h-3 w-3" />}
</div>
<LabelBadge label={label.name} variant="clickable" />
</div>
)
})}
return (
<DropdownMenuItem
key={label.id}
onClick={() => handleToggleLabel(label.name)}
className="flex items-center justify-between gap-2"
{showCreateOption && (
<div
onClick={(e) => {
e.preventDefault()
handleCreateLabel()
}}
className="flex items-center gap-2 p-2 rounded-sm hover:bg-gray-100 dark:hover:bg-zinc-800 cursor-pointer text-sm border-t mt-1"
>
<LabelBadge label={label.name} isSelected={isSelected} />
</DropdownMenuItem>
)
})
<Plus className="h-4 w-4" />
<span>Create "{search}"</span>
</div>
)}
{filteredLabels.length === 0 && !showCreateOption && (
<div className="p-2 text-sm text-gray-500 text-center">No labels found</div>
)}
</>
)}
</div>
</DropdownMenuContent>

View File

@@ -0,0 +1,86 @@
'use client';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { authenticate } from '@/app/actions/auth';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import Link from 'next/link';
function LoginButton() {
const { pending } = useFormStatus();
return (
<Button className="w-full mt-4" aria-disabled={pending}>
Log in
</Button>
);
}
export function LoginForm() {
const [errorMessage, dispatch] = useActionState(authenticate, undefined);
return (
<form action={dispatch} className="space-y-3">
<div className="flex-1 rounded-lg bg-gray-50 px-6 pb-4 pt-8">
<h1 className="mb-3 text-2xl font-bold">
Please log in to continue.
</h1>
<div className="w-full">
<div>
<label
className="mb-3 mt-5 block text-xs font-medium text-gray-900"
htmlFor="email"
>
Email
</label>
<div className="relative">
<Input
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
id="email"
type="email"
name="email"
placeholder="Enter your email address"
required
/>
</div>
</div>
<div className="mt-4">
<label
className="mb-3 mt-5 block text-xs font-medium text-gray-900"
htmlFor="password"
>
Password
</label>
<div className="relative">
<Input
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
id="password"
type="password"
name="password"
placeholder="Enter password"
required
minLength={6}
/>
</div>
</div>
</div>
<LoginButton />
<div
className="flex h-8 items-end space-x-1"
aria-live="polite"
aria-atomic="true"
>
{errorMessage && (
<p className="text-sm text-red-500">{errorMessage}</p>
)}
</div>
<div className="mt-4 text-center text-sm">
Don't have an account?{' '}
<Link href="/register" className="underline">
Register
</Link>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,203 @@
'use client';
import { Note } from '@/lib/types';
import { NoteCard } from './note-card';
import { useState, useEffect, useRef, useCallback } from 'react';
import { NoteEditor } from './note-editor';
import { updateFullOrder } from '@/app/actions/notes';
import { useResizeObserver } from '@/hooks/use-resize-observer';
interface MasonryGridProps {
notes: Note[];
}
interface MasonryItemProps {
note: Note;
onEdit: (note: Note) => void;
onResize: () => void;
}
function MasonryItem({ note, onEdit, onResize }: MasonryItemProps) {
const resizeRef = useResizeObserver(() => {
onResize();
});
return (
<div
className="masonry-item absolute w-full sm:w-1/2 lg:w-1/3 xl:w-1/4 2xl:w-1/5 p-2"
data-id={note.id}
ref={resizeRef as any}
>
<div className="masonry-item-content relative">
<NoteCard note={note} onEdit={onEdit} />
</div>
</div>
);
}
export function MasonryGrid({ notes }: MasonryGridProps) {
const [editingNote, setEditingNote] = useState<Note | null>(null);
const pinnedGridRef = useRef<HTMLDivElement>(null);
const othersGridRef = useRef<HTMLDivElement>(null);
const pinnedMuuri = useRef<any>(null);
const othersMuuri = useRef<any>(null);
const pinnedNotes = notes.filter(n => n.isPinned).sort((a, b) => a.order - b.order);
const othersNotes = notes.filter(n => !n.isPinned).sort((a, b) => a.order - b.order);
const handleDragEnd = async (grid: any) => {
if (!grid) return;
const items = grid.getItems();
const ids = items
.map((item: any) => item.getElement()?.getAttribute('data-id'))
.filter((id: any): id is string => !!id);
try {
await updateFullOrder(ids);
} catch (error) {
console.error('Failed to persist order:', error);
}
};
const refreshLayout = useCallback(() => {
// Use requestAnimationFrame for smoother updates
requestAnimationFrame(() => {
if (pinnedMuuri.current) {
pinnedMuuri.current.refreshItems().layout();
}
if (othersMuuri.current) {
othersMuuri.current.refreshItems().layout();
}
});
}, []);
useEffect(() => {
let isMounted = true;
const initMuuri = async () => {
// Import web-animations-js polyfill
await import('web-animations-js');
// Dynamic import of Muuri to avoid SSR window error
const MuuriClass = (await import('muuri')).default;
if (!isMounted) return;
const layoutOptions = {
dragEnabled: true,
dragContainer: document.body,
dragStartPredicate: {
distance: 10,
delay: 0,
},
dragPlaceholder: {
enabled: true,
createElement: (item: any) => {
const el = item.getElement().cloneNode(true);
el.style.opacity = '0.5';
return el;
},
},
dragAutoScroll: {
targets: [window],
speed: (item: any, target: any, intersection: any) => {
return intersection * 20;
},
},
};
if (pinnedGridRef.current && !pinnedMuuri.current && pinnedNotes.length > 0) {
pinnedMuuri.current = new MuuriClass(pinnedGridRef.current, layoutOptions)
.on('dragEnd', () => handleDragEnd(pinnedMuuri.current));
}
if (othersGridRef.current && !othersMuuri.current && othersNotes.length > 0) {
othersMuuri.current = new MuuriClass(othersGridRef.current, layoutOptions)
.on('dragEnd', () => handleDragEnd(othersMuuri.current));
}
};
initMuuri();
return () => {
isMounted = false;
pinnedMuuri.current?.destroy();
othersMuuri.current?.destroy();
pinnedMuuri.current = null;
othersMuuri.current = null;
};
}, [pinnedNotes.length, othersNotes.length]);
// Synchronize items when notes change (e.g. searching, adding)
useEffect(() => {
if (pinnedMuuri.current) {
pinnedMuuri.current.refreshItems().layout();
}
if (othersMuuri.current) {
othersMuuri.current.refreshItems().layout();
}
}, [notes]);
return (
<div className="masonry-container">
{pinnedNotes.length > 0 && (
<div className="mb-8">
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3 px-2">Pinned</h2>
<div ref={pinnedGridRef} className="relative min-h-[100px]">
{pinnedNotes.map(note => (
<MasonryItem
key={note.id}
note={note}
onEdit={setEditingNote}
onResize={refreshLayout}
/>
))}
</div>
</div>
)}
{othersNotes.length > 0 && (
<div>
{pinnedNotes.length > 0 && (
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3 px-2">Others</h2>
)}
<div ref={othersGridRef} className="relative min-h-[100px]">
{othersNotes.map(note => (
<MasonryItem
key={note.id}
note={note}
onEdit={setEditingNote}
onResize={refreshLayout}
/>
))}
</div>
</div>
)}
{editingNote && (
<NoteEditor note={editingNote} onClose={() => setEditingNote(null)} />
)}
<style jsx global>{`
.masonry-item {
display: block;
position: absolute;
z-index: 1;
}
.masonry-item.muuri-item-dragging {
z-index: 3;
}
.masonry-item.muuri-item-releasing {
z-index: 2;
}
.masonry-item.muuri-item-hidden {
z-index: 0;
}
.masonry-item-content {
position: relative;
width: 100%;
height: 100%;
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,112 @@
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Archive,
ArchiveRestore,
MoreVertical,
Palette,
Pin,
Trash2,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { NOTE_COLORS } from "@/lib/types"
interface NoteActionsProps {
isPinned: boolean
isArchived: boolean
currentColor: string
onTogglePin: () => void
onToggleArchive: () => void
onColorChange: (color: string) => void
onDelete: () => void
className?: string
}
export function NoteActions({
isPinned,
isArchived,
currentColor,
onTogglePin,
onToggleArchive,
onColorChange,
onDelete,
className
}: NoteActionsProps) {
return (
<div
className={cn("flex items-center justify-end gap-1", className)}
onClick={(e) => e.stopPropagation()}
>
{/* Pin Button */}
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={onTogglePin}
title={isPinned ? 'Unpin' : 'Pin'}
>
<Pin className={cn('h-4 w-4', isPinned && 'fill-current')} />
</Button>
{/* Color Palette */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" title="Change color">
<Palette className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<div className="grid grid-cols-5 gap-2 p-2">
{Object.entries(NOTE_COLORS).map(([colorName, classes]) => (
<button
key={colorName}
className={cn(
'h-8 w-8 rounded-full border-2 transition-transform hover:scale-110',
classes.bg,
currentColor === colorName ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700'
)}
onClick={() => onColorChange(colorName)}
title={colorName}
/>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* More Options */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onToggleArchive}>
{isArchived ? (
<>
<ArchiveRestore className="h-4 w-4 mr-2" />
Unarchive
</>
) : (
<>
<Archive className="h-4 w-4 mr-2" />
Archive
</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onDelete} className="text-red-600 dark:text-red-400">
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}

View File

@@ -2,25 +2,7 @@
import { Note, NOTE_COLORS, NoteColor } from '@/lib/types'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Archive,
ArchiveRestore,
MoreVertical,
Palette,
Pin,
Tag,
Trash2,
Bell,
} from 'lucide-react'
import { Pin, Bell } from 'lucide-react'
import { useState } from 'react'
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote } from '@/app/actions/notes'
import { cn } from '@/lib/utils'
@@ -28,6 +10,9 @@ import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale'
import { MarkdownContent } from './markdown-content'
import { LabelBadge } from './label-badge'
import { NoteImages } from './note-images'
import { NoteChecklist } from './note-checklist'
import { NoteActions } from './note-actions'
interface NoteCardProps {
note: Note
@@ -116,62 +101,33 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver }: NoteCardProps
</h3>
)}
{/* Images */}
{note.images && note.images.length > 0 && (
<div className={cn(
"mb-3 -mx-4",
!note.title && "-mt-4"
)}>
{note.images.length === 1 ? (
<img
src={note.images[0]}
alt=""
className="w-full h-auto rounded-lg"
/>
) : note.images.length === 2 ? (
<div className="grid grid-cols-2 gap-2 px-4">
{note.images.map((img, idx) => (
<img
key={idx}
src={img}
alt=""
className="w-full h-auto rounded-lg"
/>
))}
</div>
) : note.images.length === 3 ? (
<div className="grid grid-cols-2 gap-2 px-4">
<img
src={note.images[0]}
alt=""
className="col-span-2 w-full h-auto rounded-lg"
/>
{note.images.slice(1).map((img, idx) => (
<img
key={idx}
src={img}
alt=""
className="w-full h-auto rounded-lg"
/>
))}
</div>
) : (
<div className="grid grid-cols-2 gap-2 px-4">
{note.images.slice(0, 4).map((img, idx) => (
<img
key={idx}
src={img}
alt=""
className="w-full h-auto rounded-lg"
/>
))}
{note.images.length > 4 && (
<div className="absolute bottom-2 right-2 bg-black/70 text-white px-2 py-1 rounded text-xs">
+{note.images.length - 4}
</div>
{/* Images Component */}
<NoteImages images={note.images || []} title={note.title} />
{/* Link Previews */}
{note.links && note.links.length > 0 && (
<div className="flex flex-col gap-2 mb-2">
{note.links.map((link, idx) => (
<a
key={idx}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="block border rounded-md overflow-hidden bg-white/50 dark:bg-black/20 hover:bg-white/80 dark:hover:bg-black/40 transition-colors"
onClick={(e) => e.stopPropagation()}
>
{link.imageUrl && (
<div className="h-24 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
)}
</div>
)}
<div className="p-2">
<h4 className="font-medium text-xs truncate text-gray-900 dark:text-gray-100">{link.title || link.url}</h4>
{link.description && <p className="text-xs text-gray-500 dark:text-gray-400 line-clamp-2 mt-0.5">{link.description}</p>}
<span className="text-[10px] text-blue-500 mt-1 block">
{new URL(link.url).hostname}
</span>
</div>
</a>
))}
</div>
)}
@@ -187,33 +143,10 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver }: NoteCardProps
</p>
)
) : (
<div className="space-y-1">
{note.checkItems?.map((item) => (
<div
key={item.id}
className="flex items-start gap-2"
onClick={(e) => {
e.stopPropagation()
handleCheckItem(item.id)
}}
>
<Checkbox
checked={item.checked}
className="mt-0.5"
/>
<span
className={cn(
'text-sm',
item.checked
? 'line-through text-gray-500 dark:text-gray-500'
: 'text-gray-700 dark:text-gray-300'
)}
>
{item.text}
</span>
</div>
))}
</div>
<NoteChecklist
items={note.checkItems || []}
onToggleItem={handleCheckItem}
/>
)}
{/* Labels */}
@@ -230,76 +163,17 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver }: NoteCardProps
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: fr })}
</div>
{/* Action Bar - Shows on Hover */}
<div
className="absolute bottom-0 left-0 right-0 p-2 flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
{/* Pin Button */}
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={handleTogglePin}
title={note.isPinned ? 'Unpin' : 'Pin'}
>
<Pin className={cn('h-4 w-4', note.isPinned && 'fill-current')} />
</Button>
{/* Color Palette */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" title="Change color">
<Palette className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<div className="grid grid-cols-5 gap-2 p-2">
{Object.entries(NOTE_COLORS).map(([colorName, classes]) => (
<button
key={colorName}
className={cn(
'h-8 w-8 rounded-full border-2 transition-transform hover:scale-110',
classes.bg,
note.color === colorName ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700'
)}
onClick={() => handleColorChange(colorName)}
title={colorName}
/>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* More Options */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleToggleArchive}>
{note.isArchived ? (
<>
<ArchiveRestore className="h-4 w-4 mr-2" />
Unarchive
</>
) : (
<>
<Archive className="h-4 w-4 mr-2" />
Archive
</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleDelete} className="text-red-600 dark:text-red-400">
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Action Bar Component */}
<NoteActions
isPinned={note.isPinned}
isArchived={note.isArchived}
currentColor={note.color}
onTogglePin={handleTogglePin}
onToggleArchive={handleToggleArchive}
onColorChange={handleColorChange}
onDelete={handleDelete}
className="absolute bottom-0 left-0 right-0 p-2 opacity-0 group-hover:opacity-100 transition-opacity"
/>
</Card>
)
}
}

View File

@@ -0,0 +1,42 @@
import { CheckItem } from "@/lib/types"
import { Checkbox } from "@/components/ui/checkbox"
import { cn } from "@/lib/utils"
interface NoteChecklistProps {
items: CheckItem[]
onToggleItem: (itemId: string) => void
}
export function NoteChecklist({ items, onToggleItem }: NoteChecklistProps) {
if (!items || items.length === 0) return null
return (
<div className="space-y-1">
{items.map((item) => (
<div
key={item.id}
className="flex items-start gap-2"
onClick={(e) => {
e.stopPropagation()
onToggleItem(item.id)
}}
>
<Checkbox
checked={item.checked}
className="mt-0.5"
/>
<span
className={cn(
'text-sm',
item.checked
? 'line-through text-gray-500 dark:text-gray-500'
: 'text-gray-700 dark:text-gray-300'
)}
>
{item.text}
</span>
</div>
))}
</div>
)
}

View File

@@ -1,12 +1,13 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { Note, CheckItem, NOTE_COLORS, NoteColor } from '@/lib/types'
import { useState, useRef } from 'react'
import { Note, CheckItem, NOTE_COLORS, NoteColor, LinkMetadata } from '@/lib/types'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
@@ -17,13 +18,16 @@ import {
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { X, Plus, Palette, Image as ImageIcon, Bell, FileText, Eye } from 'lucide-react'
import { X, Plus, Palette, Image as ImageIcon, Bell, FileText, Eye, Link as LinkIcon } from 'lucide-react'
import { updateNote } from '@/app/actions/notes'
import { fetchLinkMetadata } from '@/app/actions/scrape'
import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/toast'
import { MarkdownContent } from './markdown-content'
import { LabelManager } from './label-manager'
import { LabelBadge } from './label-badge'
import { ReminderDialog } from './reminder-dialog'
import { EditorImages } from './editor-images'
interface NoteEditorProps {
note: Note
@@ -37,6 +41,7 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || [])
const [labels, setLabels] = useState<string[]>(note.labels || [])
const [images, setImages] = useState<string[]>(note.images || [])
const [links, setLinks] = useState<LinkMetadata[]>(note.links || [])
const [newLabel, setNewLabel] = useState('')
const [color, setColor] = useState(note.color)
const [isSaving, setIsSaving] = useState(false)
@@ -46,69 +51,80 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
// Reminder state
const [showReminderDialog, setShowReminderDialog] = useState(false)
const [reminderDate, setReminderDate] = useState('')
const [reminderTime, setReminderTime] = useState('')
const [currentReminder, setCurrentReminder] = useState<Date | null>(note.reminder)
// Link state
const [showLinkDialog, setShowLinkDialog] = useState(false)
const [linkUrl, setLinkUrl] = useState('')
const colorClasses = NOTE_COLORS[color as NoteColor] || NOTE_COLORS.default
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files) return
Array.from(files).forEach(file => {
const reader = new FileReader()
reader.onloadend = () => {
setImages(prev => [...prev, reader.result as string])
for (const file of Array.from(files)) {
const formData = new FormData()
formData.append('file', file)
try {
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
})
if (!response.ok) throw new Error('Upload failed')
const data = await response.json()
setImages(prev => [...prev, data.url])
} catch (error) {
console.error('Upload error:', error)
addToast(`Failed to upload ${file.name}`, 'error')
}
reader.readAsDataURL(file)
})
}
}
const handleRemoveImage = (index: number) => {
setImages(images.filter((_, i) => i !== index))
}
const handleReminderOpen = () => {
if (currentReminder) {
const date = new Date(currentReminder)
setReminderDate(date.toISOString().split('T')[0])
setReminderTime(date.toTimeString().slice(0, 5))
} else {
const tomorrow = new Date(Date.now() + 86400000)
setReminderDate(tomorrow.toISOString().split('T')[0])
setReminderTime('09:00')
const handleAddLink = async () => {
if (!linkUrl) return
setShowLinkDialog(false)
try {
const metadata = await fetchLinkMetadata(linkUrl)
if (metadata) {
setLinks(prev => [...prev, metadata])
addToast('Link added', 'success')
} else {
addToast('Could not fetch link metadata', 'warning')
setLinks(prev => [...prev, { url: linkUrl, title: linkUrl }])
}
} catch (error) {
console.error('Failed to add link:', error)
addToast('Failed to add link', 'error')
} finally {
setLinkUrl('')
}
setShowReminderDialog(true)
}
const handleReminderSave = () => {
if (!reminderDate || !reminderTime) {
addToast('Please enter date and time', 'warning')
return
}
const dateTimeString = `${reminderDate}T${reminderTime}`
const date = new Date(dateTimeString)
if (isNaN(date.getTime())) {
addToast('Invalid date or time', 'error')
return
}
const handleRemoveLink = (index: number) => {
setLinks(links.filter((_, i) => i !== index))
}
const handleReminderSave = (date: Date) => {
if (date < new Date()) {
addToast('Reminder must be in the future', 'error')
return
}
setCurrentReminder(date)
addToast(`Reminder set for ${date.toLocaleString()}`, 'success')
setShowReminderDialog(false)
}
const handleRemoveReminder = () => {
setCurrentReminder(null)
setShowReminderDialog(false)
addToast('Reminder removed', 'success')
}
@@ -121,6 +137,7 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
checkItems: note.type === 'checklist' ? checkItems : null,
labels,
images,
links,
color,
reminder: currentReminder,
isMarkdown,
@@ -158,13 +175,6 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
setCheckItems(items => items.filter(item => item.id !== id))
}
const handleAddLabel = () => {
if (newLabel.trim() && !labels.includes(newLabel.trim())) {
setLabels([...labels, newLabel.trim()])
setNewLabel('')
}
}
const handleRemoveLabel = (label: string) => {
setLabels(labels.filter(l => l !== label))
}
@@ -191,22 +201,30 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
/>
{/* Images */}
{images.length > 0 && (
<div className="flex flex-col gap-3 mb-4">
{images.map((img, idx) => (
<div key={idx} className="relative group">
<img
src={img}
alt=""
className="h-auto rounded-lg"
/>
<EditorImages images={images} onRemove={handleRemoveImage} />
{/* Link Previews */}
{links.length > 0 && (
<div className="flex flex-col gap-2">
{links.map((link, idx) => (
<div key={idx} className="relative group border rounded-lg overflow-hidden bg-white/50 dark:bg-black/20 flex">
{link.imageUrl && (
<div className="w-24 h-24 flex-shrink-0 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
)}
<div className="p-2 flex-1 min-w-0 flex flex-col justify-center">
<h4 className="font-medium text-sm truncate">{link.title || link.url}</h4>
{link.description && <p className="text-xs text-gray-500 truncate">{link.description}</p>}
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-xs text-blue-500 truncate hover:underline block mt-1">
{new URL(link.url).hostname}
</a>
</div>
<Button
variant="ghost"
size="sm"
className="absolute top-2 right-2 h-7 w-7 p-0 bg-white/90 hover:bg-white opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => handleRemoveImage(idx)}
className="absolute top-1 right-1 h-6 w-6 p-0 bg-white/50 hover:bg-white opacity-0 group-hover:opacity-100 transition-opacity rounded-full"
onClick={() => handleRemoveLink(idx)}
>
<X className="h-4 w-4" />
<X className="h-3 w-3" />
</Button>
</div>
))}
@@ -324,7 +342,7 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
<Button
variant="ghost"
size="sm"
onClick={handleReminderOpen}
onClick={() => setShowReminderDialog(true)}
title="Set reminder"
className={currentReminder ? "text-blue-600" : ""}
>
@@ -341,6 +359,16 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
<ImageIcon className="h-4 w-4" />
</Button>
{/* Add Link Button */}
<Button
variant="ghost"
size="sm"
onClick={() => setShowLinkDialog(true)}
title="Add Link"
>
<LinkIcon className="h-4 w-4" />
</Button>
{/* Color Picker */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -393,57 +421,84 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
/>
</DialogContent>
{/* Reminder Dialog */}
<Dialog open={showReminderDialog} onOpenChange={setShowReminderDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Set Reminder</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label htmlFor="reminder-date" className="text-sm font-medium">
Date
</label>
<Input
id="reminder-date"
type="date"
value={reminderDate}
onChange={(e) => setReminderDate(e.target.value)}
className="w-full"
/>
</div>
<div className="space-y-2">
<label htmlFor="reminder-time" className="text-sm font-medium">
Time
</label>
<Input
id="reminder-time"
type="time"
value={reminderTime}
onChange={(e) => setReminderTime(e.target.value)}
className="w-full"
/>
</div>
</div>
<div className="flex justify-between">
<div>
{currentReminder && (
<Button variant="outline" onClick={handleRemoveReminder}>
Remove Reminder
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={() => setShowReminderDialog(false)}>
Cancel
</Button>
<Button onClick={handleReminderSave}>
Set Reminder
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</Dialog>
)
}
<ReminderDialog
open={showReminderDialog}
onOpenChange={setShowReminderDialog}
currentReminder={currentReminder}
onSave={handleReminderSave}
onRemove={handleRemoveReminder}
/>
<Dialog open={showLinkDialog} onOpenChange={setShowLinkDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Link</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<Input
placeholder="https://example.com"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddLink()
}
}}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setShowLinkDialog(false)}>
Cancel
</Button>
<Button onClick={handleAddLink}>
Add
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Dialog>
)
}

View File

@@ -1,269 +0,0 @@
'use client'
import { Note } from '@/lib/types'
import { NoteCard } from './note-card'
import { useState, useMemo, useEffect } from 'react'
import { NoteEditor } from './note-editor'
import { reorderNotes, getNotes } from '@/app/actions/notes'
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
closestCenter,
PointerSensor,
} from '@dnd-kit/core'
import {
SortableContext,
rectSortingStrategy,
useSortable,
arrayMove,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { useRouter } from 'next/navigation'
interface NoteGridProps {
notes: Note[]
}
function SortableNote({ note, onEdit }: { note: Note; onEdit: (note: Note) => void }) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: note.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 1000 : 1,
}
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
data-note-id={note.id}
data-draggable="true"
>
<NoteCard note={note} onEdit={onEdit} isDragging={isDragging} />
</div>
)
}
export function NoteGrid({ notes }: NoteGridProps) {
const router = useRouter()
const [editingNote, setEditingNote] = useState<Note | null>(null)
const [activeId, setActiveId] = useState<string | null>(null)
const [localPinnedNotes, setLocalPinnedNotes] = useState<Note[]>([])
const [localUnpinnedNotes, setLocalUnpinnedNotes] = useState<Note[]>([])
// Sync local state with props
useEffect(() => {
setLocalPinnedNotes(notes.filter(note => note.isPinned).sort((a, b) => a.order - b.order))
setLocalUnpinnedNotes(notes.filter(note => !note.isPinned).sort((a, b) => a.order - b.order))
}, [notes])
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(MouseSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 200,
tolerance: 6,
},
})
)
const handleDragStart = (event: DragStartEvent) => {
console.log('[DND-DEBUG] Drag started:', {
activeId: event.active.id,
activeData: event.active.data.current
})
setActiveId(event.active.id as string)
}
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event
console.log('[DND-DEBUG] Drag ended:', {
activeId: active.id,
overId: over?.id,
hasOver: !!over
})
setActiveId(null)
if (!over || active.id === over.id) {
console.log('[DND-DEBUG] Drag cancelled: no valid drop target or same element')
return
}
const activeIdStr = active.id as string
const overIdStr = over.id as string
// Determine which section the dragged note belongs to
const isInPinned = localPinnedNotes.some(n => n.id === activeIdStr)
const targetIsInPinned = localPinnedNotes.some(n => n.id === overIdStr)
console.log('[DND-DEBUG] Section check:', {
activeIdStr,
overIdStr,
isInPinned,
targetIsInPinned,
pinnedNotesCount: localPinnedNotes.length,
unpinnedNotesCount: localUnpinnedNotes.length
})
// Only allow reordering within the same section
if (isInPinned !== targetIsInPinned) {
console.log('[DND-DEBUG] Drag cancelled: crossing sections (pinned/unpinned)')
return
}
if (isInPinned) {
// Reorder pinned notes
const oldIndex = localPinnedNotes.findIndex(n => n.id === activeIdStr)
const newIndex = localPinnedNotes.findIndex(n => n.id === overIdStr)
console.log('[DND-DEBUG] Pinned reorder:', { oldIndex, newIndex })
if (oldIndex !== -1 && newIndex !== -1) {
const newOrder = arrayMove(localPinnedNotes, oldIndex, newIndex)
setLocalPinnedNotes(newOrder)
console.log('[DND-DEBUG] Calling reorderNotes for pinned notes')
await reorderNotes(activeIdStr, overIdStr)
// Refresh notes from server to sync state
console.log('[DND-DEBUG] Refreshing notes from server after reorder')
await refreshNotesFromServer()
} else {
console.log('[DND-DEBUG] Invalid indices for pinned reorder')
}
} else {
// Reorder unpinned notes
const oldIndex = localUnpinnedNotes.findIndex(n => n.id === activeIdStr)
const newIndex = localUnpinnedNotes.findIndex(n => n.id === overIdStr)
console.log('[DND-DEBUG] Unpinned reorder:', { oldIndex, newIndex })
if (oldIndex !== -1 && newIndex !== -1) {
const newOrder = arrayMove(localUnpinnedNotes, oldIndex, newIndex)
setLocalUnpinnedNotes(newOrder)
console.log('[DND-DEBUG] Calling reorderNotes for unpinned notes')
await reorderNotes(activeIdStr, overIdStr)
// Refresh notes from server to sync state
console.log('[DND-DEBUG] Refreshing notes from server after reorder')
await refreshNotesFromServer()
} else {
console.log('[DND-DEBUG] Invalid indices for unpinned reorder')
}
}
}
// Function to refresh notes from server without full page reload
const refreshNotesFromServer = async () => {
console.log('[DND-DEBUG] Fetching fresh notes from server...')
const freshNotes = await getNotes()
console.log('[DND-DEBUG] Received fresh notes:', freshNotes.length)
// Update local state with fresh data
const pinned = freshNotes.filter(note => note.isPinned).sort((a, b) => a.order - b.order)
const unpinned = freshNotes.filter(note => !note.isPinned).sort((a, b) => a.order - b.order)
setLocalPinnedNotes(pinned)
setLocalUnpinnedNotes(unpinned)
console.log('[DND-DEBUG] Local state updated with fresh server data')
}
// Find active note from either section
const activeNote = activeId
? localPinnedNotes.find(n => n.id === activeId) || localUnpinnedNotes.find(n => n.id === activeId)
: null
return (
<>
<div className="space-y-8">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
{localPinnedNotes.length > 0 && (
<div>
<h2 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3 px-2">
Pinned
</h2>
<SortableContext items={localPinnedNotes.map(n => n.id)} strategy={rectSortingStrategy}>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4 auto-rows-max">
{localPinnedNotes.map((note) => (
<SortableNote key={note.id} note={note} onEdit={setEditingNote} />
))}
</div>
</SortableContext>
</div>
)}
{localUnpinnedNotes.length > 0 && (
<div>
{localPinnedNotes.length > 0 && (
<h2 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3 px-2">
Others
</h2>
)}
<SortableContext items={localUnpinnedNotes.map(n => n.id)} strategy={rectSortingStrategy}>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4 auto-rows-max">
{localUnpinnedNotes.map((note) => (
<SortableNote key={note.id} note={note} onEdit={setEditingNote} />
))}
</div>
</SortableContext>
</div>
)}
<DragOverlay>
{activeNote ? (
<div className="opacity-90 rotate-2 scale-105 shadow-2xl">
<NoteCard
note={activeNote}
onEdit={() => {}}
isDragging={true}
/>
</div>
) : null}
</DragOverlay>
</DndContext>
{notes.length === 0 && (
<div className="text-center py-16">
<p className="text-gray-500 dark:text-gray-400 text-lg">No notes yet</p>
<p className="text-gray-400 dark:text-gray-500 text-sm mt-2">Create your first note to get started</p>
</div>
)}
</div>
{editingNote && (
<NoteEditor note={editingNote} onClose={() => setEditingNote(null)} />
)}
</>
)
}

View File

@@ -0,0 +1,68 @@
import { cn } from "@/lib/utils"
interface NoteImagesProps {
images: string[]
title?: string | null
}
export function NoteImages({ images, title }: NoteImagesProps) {
if (!images || images.length === 0) return null
return (
<div className={cn(
"mb-3 -mx-4",
!title && "-mt-4"
)}>
{images.length === 1 ? (
<img
src={images[0]}
alt=""
className="w-full h-auto rounded-lg"
/>
) : images.length === 2 ? (
<div className="grid grid-cols-2 gap-2 px-4">
{images.map((img, idx) => (
<img
key={idx}
src={img}
alt=""
className="w-full h-auto rounded-lg"
/>
))}
</div>
) : images.length === 3 ? (
<div className="grid grid-cols-2 gap-2 px-4">
<img
src={images[0]}
alt=""
className="col-span-2 w-full h-auto rounded-lg"
/>
{images.slice(1).map((img, idx) => (
<img
key={idx}
src={img}
alt=""
className="w-full h-auto rounded-lg"
/>
))}
</div>
) : (
<div className="grid grid-cols-2 gap-2 px-4">
{images.slice(0, 4).map((img, idx) => (
<img
key={idx}
src={img}
alt=""
className="w-full h-auto rounded-lg"
/>
))}
{images.length > 4 && (
<div className="absolute bottom-2 right-2 bg-black/70 text-white px-2 py-1 rounded text-xs">
+{images.length - 4}
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -17,10 +17,12 @@ import {
Undo2,
Redo2,
FileText,
Eye
Eye,
Link as LinkIcon
} from 'lucide-react'
import { createNote } from '@/app/actions/notes'
import { CheckItem, NOTE_COLORS, NoteColor } from '@/lib/types'
import { fetchLinkMetadata } from '@/app/actions/scrape'
import { CheckItem, NOTE_COLORS, NoteColor, LinkMetadata } from '@/lib/types'
import { Checkbox } from '@/components/ui/checkbox'
import {
Tooltip,
@@ -67,6 +69,7 @@ export function NoteInput() {
const [content, setContent] = useState('')
const [checkItems, setCheckItems] = useState<CheckItem[]>([])
const [images, setImages] = useState<string[]>([])
const [links, setLinks] = useState<LinkMetadata[]>([])
const [isMarkdown, setIsMarkdown] = useState(false)
const [showMarkdownPreview, setShowMarkdownPreview] = useState(false)
@@ -77,6 +80,8 @@ export function NoteInput() {
// Reminder dialog
const [showReminderDialog, setShowReminderDialog] = useState(false)
const [showLinkDialog, setShowLinkDialog] = useState(false)
const [linkUrl, setLinkUrl] = useState('')
const [reminderDate, setReminderDate] = useState('')
const [reminderTime, setReminderTime] = useState('')
const [currentReminder, setCurrentReminder] = useState<Date | null>(null)
@@ -148,7 +153,7 @@ export function NoteInput() {
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isExpanded, historyIndex, history])
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files) return
@@ -156,32 +161,70 @@ export function NoteInput() {
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
const maxSize = 5 * 1024 * 1024 // 5MB
Array.from(files).forEach(file => {
for (const file of Array.from(files)) {
// Validation
if (!validTypes.includes(file.type)) {
addToast(`Invalid file type: ${file.name}. Only JPEG, PNG, GIF, and WebP allowed.`, 'error')
return
continue
}
if (file.size > maxSize) {
addToast(`File too large: ${file.name}. Maximum size is 5MB.`, 'error')
return
continue
}
const reader = new FileReader()
reader.onloadend = () => {
setImages([...images, reader.result as string])
// Upload to server
const formData = new FormData()
formData.append('file', file)
try {
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
})
if (!response.ok) throw new Error('Upload failed')
const data = await response.json()
setImages(prev => [...prev, data.url])
} catch (error) {
console.error('Upload error:', error)
addToast(`Failed to upload ${file.name}`, 'error')
}
reader.onerror = () => {
addToast(`Failed to read file: ${file.name}`, 'error')
}
reader.readAsDataURL(file)
})
}
// Reset input
e.target.value = ''
}
const handleAddLink = async () => {
if (!linkUrl) return
// Optimistic add (or loading state)
setShowLinkDialog(false)
try {
const metadata = await fetchLinkMetadata(linkUrl)
if (metadata) {
setLinks(prev => [...prev, metadata])
addToast('Link added', 'success')
} else {
addToast('Could not fetch link metadata', 'warning')
// Fallback: just add the url as title
setLinks(prev => [...prev, { url: linkUrl, title: linkUrl }])
}
} catch (error) {
console.error('Failed to add link:', error)
addToast('Failed to add link', 'error')
} finally {
setLinkUrl('')
}
}
const handleRemoveLink = (index: number) => {
setLinks(links.filter((_, i) => i !== index))
}
const handleReminderOpen = () => {
const tomorrow = new Date(Date.now() + 86400000)
setReminderDate(tomorrow.toISOString().split('T')[0])
@@ -240,6 +283,7 @@ export function NoteInput() {
color,
isArchived,
images: images.length > 0 ? images : undefined,
links: links.length > 0 ? links : undefined,
reminder: currentReminder,
isMarkdown,
labels: selectedLabels.length > 0 ? selectedLabels : undefined,
@@ -250,6 +294,7 @@ export function NoteInput() {
setContent('')
setCheckItems([])
setImages([])
setLinks([])
setIsMarkdown(false)
setShowMarkdownPreview(false)
setHistory([{ title: '', content: '' }])
@@ -292,6 +337,7 @@ export function NoteInput() {
setContent('')
setCheckItems([])
setImages([])
setLinks([])
setIsMarkdown(false)
setShowMarkdownPreview(false)
setHistory([{ title: '', content: '' }])
@@ -369,17 +415,48 @@ export function NoteInput() {
</div>
)}
{/* Link Previews */}
{links.length > 0 && (
<div className="flex flex-col gap-2 mt-2">
{links.map((link, idx) => (
<div key={idx} className="relative group border rounded-lg overflow-hidden bg-gray-50 dark:bg-zinc-800/50 flex">
{link.imageUrl && (
<div className="w-24 h-24 flex-shrink-0 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
)}
<div className="p-2 flex-1 min-w-0 flex flex-col justify-center">
<h4 className="font-medium text-sm truncate">{link.title || link.url}</h4>
{link.description && <p className="text-xs text-gray-500 truncate">{link.description}</p>}
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-xs text-blue-500 truncate hover:underline block mt-1">
{new URL(link.url).hostname}
</a>
</div>
<Button
variant="ghost"
size="sm"
className="absolute top-1 right-1 h-6 w-6 p-0 bg-white/50 hover:bg-white opacity-0 group-hover:opacity-100 transition-opacity rounded-full"
onClick={() => handleRemoveLink(idx)}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
{type === 'text' ? (
<div className="space-y-2">
{/* Labels selector */}
<div className="flex items-center gap-2 mb-2">
<LabelSelector
selectedLabels={selectedLabels}
onLabelsChange={setSelectedLabels}
triggerLabel="Tags"
align="start"
/>
</div>
{/* Selected Labels Display */}
{selectedLabels.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
{selectedLabels.map(label => (
<LabelBadge
key={label}
label={label}
onRemove={() => setSelectedLabels(prev => prev.filter(l => l !== label))}
/>
))}
</div>
)}
{/* Markdown toggle button */}
{isMarkdown && (
@@ -519,6 +596,28 @@ export function NoteInput() {
<TooltipContent>Collaborator</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setShowLinkDialog(true)}
title="Add Link"
>
<LinkIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Add Link</TooltipContent>
</Tooltip>
<LabelSelector
selectedLabels={selectedLabels}
onLabelsChange={setSelectedLabels}
triggerLabel=""
align="start"
/>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
@@ -676,6 +775,36 @@ export function NoteInput() {
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showLinkDialog} onOpenChange={setShowLinkDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Link</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<Input
placeholder="https://example.com"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddLink()
}
}}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setShowLinkDialog(false)}>
Cancel
</Button>
<Button onClick={handleAddLink}>
Add
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,104 @@
'use client';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { register } from '@/app/actions/register';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import Link from 'next/link';
function RegisterButton() {
const { pending } = useFormStatus();
return (
<Button className="w-full mt-4" aria-disabled={pending}>
Register
</Button>
);
}
export function RegisterForm() {
const [errorMessage, dispatch] = useActionState(register, undefined);
return (
<form action={dispatch} className="space-y-3">
<div className="flex-1 rounded-lg bg-gray-50 px-6 pb-4 pt-8">
<h1 className="mb-3 text-2xl font-bold">
Create an account.
</h1>
<div className="w-full">
<div>
<label
className="mb-3 mt-5 block text-xs font-medium text-gray-900"
htmlFor="name"
>
Name
</label>
<div className="relative">
<Input
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
id="name"
type="text"
name="name"
placeholder="Enter your name"
required
/>
</div>
</div>
<div className="mt-4">
<label
className="mb-3 mt-5 block text-xs font-medium text-gray-900"
htmlFor="email"
>
Email
</label>
<div className="relative">
<Input
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
id="email"
type="email"
name="email"
placeholder="Enter your email address"
required
/>
</div>
</div>
<div className="mt-4">
<label
className="mb-3 mt-5 block text-xs font-medium text-gray-900"
htmlFor="password"
>
Password
</label>
<div className="relative">
<Input
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
id="password"
type="password"
name="password"
placeholder="Enter password (min 6 chars)"
required
minLength={6}
/>
</div>
</div>
</div>
<RegisterButton />
<div
className="flex h-8 items-end space-x-1"
aria-live="polite"
aria-atomic="true"
>
{errorMessage && (
<p className="text-sm text-red-500">{errorMessage}</p>
)}
</div>
<div className="mt-4 text-center text-sm">
Already have an account?{' '}
<Link href="/login" className="underline">
Log in
</Link>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,102 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { useState, useEffect } from "react"
interface ReminderDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
currentReminder: Date | null
onSave: (date: Date) => void
onRemove: () => void
}
export function ReminderDialog({
open,
onOpenChange,
currentReminder,
onSave,
onRemove
}: ReminderDialogProps) {
const [reminderDate, setReminderDate] = useState('')
const [reminderTime, setReminderTime] = useState('')
useEffect(() => {
if (open) {
if (currentReminder) {
const date = new Date(currentReminder)
setReminderDate(date.toISOString().split('T')[0])
setReminderTime(date.toTimeString().slice(0, 5))
} else {
const tomorrow = new Date(Date.now() + 86400000)
setReminderDate(tomorrow.toISOString().split('T')[0])
setReminderTime('09:00')
}
}
}, [open, currentReminder])
const handleSave = () => {
if (!reminderDate || !reminderTime) return
const dateTimeString = `${reminderDate}T${reminderTime}`
const date = new Date(dateTimeString)
if (!isNaN(date.getTime())) {
onSave(date)
onOpenChange(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Set Reminder</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label htmlFor="reminder-date" className="text-sm font-medium">
Date
</label>
<Input
id="reminder-date"
type="date"
value={reminderDate}
onChange={(e) => setReminderDate(e.target.value)}
className="w-full"
/>
</div>
<div className="space-y-2">
<label htmlFor="reminder-time" className="text-sm font-medium">
Time
</label>
<Input
id="reminder-time"
type="time"
value={reminderTime}
onChange={(e) => setReminderTime(e.target.value)}
className="w-full"
/>
</div>
</div>
<div className="flex justify-between">
<div>
{currentReminder && (
<Button variant="outline" onClick={() => { onRemove(); onOpenChange(false); }}>
Remove Reminder
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave}>
Set Reminder
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,110 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { usePathname, useSearchParams } from 'next/navigation'
import { cn } from '@/lib/utils'
import { StickyNote, Bell, Archive, Trash2, Tag, ChevronDown, ChevronUp } from 'lucide-react'
import { useLabels } from '@/context/LabelContext'
import { LabelManagementDialog } from './label-management-dialog'
import { LABEL_COLORS } from '@/lib/types'
export function Sidebar({ className }: { className?: string }) {
const pathname = usePathname()
const searchParams = useSearchParams()
const { labels, getLabelColor } = useLabels()
const [isLabelsExpanded, setIsLabelsExpanded] = useState(false)
const currentLabels = searchParams.get('labels')?.split(',').filter(Boolean) || []
const currentSearch = searchParams.get('search')
// Show first 5 labels by default, or all if expanded
const displayedLabels = isLabelsExpanded ? labels : labels.slice(0, 5)
const hasMoreLabels = labels.length > 5
const NavItem = ({ href, icon: Icon, label, active, onClick, iconColorClass }: any) => (
<Link
href={href}
onClick={onClick}
className={cn(
"flex items-center gap-3 px-4 py-3 rounded-r-full text-sm font-medium transition-colors mr-2",
active
? "bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-100"
: "hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-700 dark:text-gray-300"
)}
>
<Icon className={cn("h-5 w-5", active && "fill-current", !active && iconColorClass)} />
<span className="truncate">{label}</span>
</Link>
)
return (
<aside className={cn("w-[280px] flex-col gap-1 py-2 overflow-y-auto hidden md:flex", className)}>
<NavItem
href="/"
icon={StickyNote}
label="Notes"
active={pathname === '/' && currentLabels.length === 0 && !currentSearch}
/>
<NavItem
href="/reminders"
icon={Bell}
label="Reminders"
active={pathname === '/reminders'}
/>
<div className="my-2 px-4 flex items-center justify-between group">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">Labels</span>
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<LabelManagementDialog />
</div>
</div>
{displayedLabels.map(label => {
const colorName = getLabelColor(label.name)
const colorClass = LABEL_COLORS[colorName]?.icon
return (
<NavItem
key={label.id}
href={`/?labels=${encodeURIComponent(label.name)}`}
icon={Tag}
label={label.name}
active={currentLabels.includes(label.name)}
iconColorClass={colorClass}
/>
)
})}
{hasMoreLabels && (
<button
onClick={() => setIsLabelsExpanded(!isLabelsExpanded)}
className="flex items-center gap-3 px-4 py-3 rounded-r-full text-sm font-medium transition-colors mr-2 w-full hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-700 dark:text-gray-300"
>
{isLabelsExpanded ? (
<ChevronUp className="h-5 w-5" />
) : (
<ChevronDown className="h-5 w-5" />
)}
<span>{isLabelsExpanded ? 'Show less' : 'Show more'}</span>
</button>
)}
<div className="my-2 border-t border-gray-200 dark:border-zinc-800" />
<NavItem
href="/archive"
icon={Archive}
label="Archive"
active={pathname === '/archive'}
/>
<NavItem
href="/trash"
icon={Trash2}
label="Trash"
active={pathname === '/trash'}
/>
</aside>
)
}

View File

@@ -0,0 +1,140 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}