refactor(ux): consolidate BMAD skills, update design system, and clean up Prisma generated client

This commit is contained in:
Sepehr Ramezani
2026-04-19 19:21:27 +02:00
parent 5296c4da2c
commit 25529a24b8
2476 changed files with 127934 additions and 101962 deletions

View File

@@ -20,11 +20,11 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Pin, Bell, GripVertical, X, Link2, FolderOpen, StickyNote, LucideIcon, Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, LogOut, Trash2 } from 'lucide-react'
import { Pin, Bell, GripVertical, X, Link2, FolderOpen, StickyNote, LogOut, Trash2 } from 'lucide-react'
import { useState, useEffect, useTransition, useOptimistic, memo } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter, useSearchParams } from 'next/navigation'
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote, updateSize, getNoteAllUsers, leaveSharedNote, removeFusedBadge } from '@/app/actions/notes'
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote, updateSize, getNoteAllUsers, leaveSharedNote, removeFusedBadge, restoreNote, permanentDeleteNote, createNote } from '@/app/actions/notes'
import { cn } from '@/lib/utils'
import { formatDistanceToNow, Locale } from 'date-fns'
import { enUS } from 'date-fns/locale/en-US'
@@ -48,15 +48,19 @@ import { NoteImages } from './note-images'
import { NoteChecklist } from './note-checklist'
import { NoteActions } from './note-actions'
import { CollaboratorDialog } from './collaborator-dialog'
import { useCardSizeMode } from '@/hooks/use-card-size-mode'
import { CollaboratorAvatars } from './collaborator-avatars'
import { ConnectionsBadge } from './connections-badge'
import { ConnectionsOverlay } from './connections-overlay'
import { ComparisonModal } from './comparison-modal'
import { FusionModal } from './fusion-modal'
import { useConnectionsCompare } from '@/hooks/use-connections-compare'
import { useLabels } from '@/context/LabelContext'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
import { useLanguage } from '@/lib/i18n'
import { useNotebooks } from '@/context/notebooks-context'
import { toast } from 'sonner'
import { getNotebookIcon } from '@/lib/notebook-icon'
// Mapping of supported languages to date-fns locales
const localeMap: Record<string, Locale> = {
@@ -81,28 +85,6 @@ function getDateLocale(language: string): Locale {
return localeMap[language] || enUS
}
// Map icon names to lucide-react components
const ICON_MAP: Record<string, LucideIcon> = {
'folder': Folder,
'briefcase': Briefcase,
'document': FileText,
'lightning': Zap,
'chart': BarChart3,
'globe': Globe,
'sparkle': Sparkles,
'book': Book,
'heart': Heart,
'crown': Crown,
'music': Music,
'building': Building2,
}
// Function to get icon component by name
function getNotebookIcon(iconName: string): LucideIcon {
const IconComponent = ICON_MAP[iconName] || Folder
return IconComponent
}
interface NoteCardProps {
note: Note
onEdit?: (note: Note, readOnly?: boolean) => void
@@ -112,6 +94,7 @@ interface NoteCardProps {
onDragEnd?: () => void
onResize?: () => void
onSizeChange?: (newSize: 'small' | 'medium' | 'large') => void
isTrashView?: boolean
}
// Helper function to get initials from name
@@ -149,22 +132,26 @@ export const NoteCard = memo(function NoteCard({
onDragEnd,
isDragging,
onResize,
onSizeChange
onSizeChange,
isTrashView
}: NoteCardProps) {
const router = useRouter()
const searchParams = useSearchParams()
const { refreshLabels } = useLabels()
const { triggerRefresh } = useNoteRefresh()
const { data: session } = useSession()
const { t, language } = useLanguage()
const { notebooks, moveNoteToNotebookOptimistic } = useNotebooks()
const [, startTransition] = useTransition()
const [isDeleting, setIsDeleting] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [showPermanentDeleteDialog, setShowPermanentDeleteDialog] = useState(false)
const [showCollaboratorDialog, setShowCollaboratorDialog] = useState(false)
const [collaborators, setCollaborators] = useState<any[]>([])
const [owner, setOwner] = useState<any>(null)
const [showConnectionsOverlay, setShowConnectionsOverlay] = useState(false)
const [comparisonNotes, setComparisonNotes] = useState<string[] | null>(null)
const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([])
const [showNotebookMenu, setShowNotebookMenu] = useState(false)
// Move note to a notebook
@@ -198,6 +185,10 @@ export const NoteCard = memo(function NoteCard({
const isSharedNote = currentUserId && note.userId && currentUserId !== note.userId
const isOwner = currentUserId && note.userId && currentUserId === note.userId
// Card size mode from settings
const cardSizeMode = useCardSizeMode()
const isUniformMode = cardSizeMode === 'uniform'
// Load collaborators only for shared notes (not owned by current user)
useEffect(() => {
// Skip API call for notes owned by current user — no need to fetch collaborators
@@ -281,26 +272,16 @@ export const NoteCard = memo(function NoteCard({
})
}
const handleSizeChange = async (size: 'small' | 'medium' | 'large') => {
startTransition(async () => {
// Instant visual feedback for the card itself
addOptimisticNote({ size })
const handleSizeChange = (size: 'small' | 'medium' | 'large') => {
// Notifier le parent immédiatement (hors transition) — c'est lui
// qui détient la source de vérité via localNotes
onSizeChange?.(size)
onResize?.()
// Notify parent so it can update its local state
onSizeChange?.(size)
// Trigger layout refresh
onResize?.()
setTimeout(() => onResize?.(), 300)
// Update server in background
try {
await updateSize(note.id, size);
} catch (error) {
console.error('Failed to update note size:', error);
}
})
// Persister en arrière-plan
updateSize(note.id, size).catch(err =>
console.error('Failed to update note size:', err)
)
}
const handleCheckItem = async (checkItemId: string) => {
@@ -327,6 +308,27 @@ export const NoteCard = memo(function NoteCard({
}
}
const handleRestore = async () => {
try {
await restoreNote(note.id)
setIsDeleting(true) // Hide the note from trash view
toast.success(t('trash.noteRestored'))
} catch (error) {
console.error('Failed to restore note:', error)
}
}
const handlePermanentDelete = async () => {
setIsDeleting(true)
try {
await permanentDeleteNote(note.id)
toast.success(t('trash.notePermanentlyDeleted'))
} catch (error) {
console.error('Failed to permanently delete note:', error)
setIsDeleting(false)
}
}
const handleRemoveFusedBadge = async (e: React.MouseEvent) => {
e.stopPropagation() // Prevent opening the note editor
startTransition(async () => {
@@ -353,10 +355,11 @@ export const NoteCard = memo(function NoteCard({
data-testid="note-card"
data-draggable="true"
data-note-id={note.id}
data-size={optimisticNote.size}
style={{ minHeight: getMinHeight(optimisticNote.size) }}
draggable={true}
data-size={isUniformMode ? 'small' : optimisticNote.size}
style={{ minHeight: isUniformMode ? 'auto' : getMinHeight(optimisticNote.size) }}
draggable={!isTrashView}
onDragStart={(e) => {
if (isTrashView) return
e.dataTransfer.setData('text/plain', note.id)
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/html', '') // Prevent ghost image in some browsers
@@ -382,7 +385,8 @@ export const NoteCard = memo(function NoteCard({
}
}}
>
{/* Drag Handle - Only visible on mobile/touch devices */}
{/* Drag Handle - Only visible on mobile/touch devices, not in trash */}
{!isTrashView && (
<div
className="muuri-drag-handle absolute top-2 left-2 z-20 cursor-grab active:cursor-grabbing p-2 md:hidden"
aria-label={t('notes.dragToReorder') || 'Drag to reorder'}
@@ -390,8 +394,10 @@ export const NoteCard = memo(function NoteCard({
>
<GripVertical className="h-5 w-5 text-muted-foreground" />
</div>
)}
{/* Move to Notebook Dropdown Menu */}
{/* Move to Notebook Dropdown Menu - Hidden in trash */}
{!isTrashView && (
<div onClick={(e) => e.stopPropagation()} className="absolute top-2 right-2 z-20">
<DropdownMenu open={showNotebookMenu} onOpenChange={setShowNotebookMenu}>
<DropdownMenuTrigger asChild>
@@ -427,8 +433,10 @@ export const NoteCard = memo(function NoteCard({
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{/* Pin Button - Visible on hover or if pinned */}
{/* Pin Button - Visible on hover or if pinned, hidden in trash */}
{!isTrashView && (
<Button
variant="ghost"
size="sm"
@@ -446,6 +454,7 @@ export const NoteCard = memo(function NoteCard({
className={cn("h-4 w-4", optimisticNote.isPinned ? "fill-current text-primary" : "text-muted-foreground")}
/>
</Button>
)}
@@ -602,19 +611,22 @@ export const NoteCard = memo(function NoteCard({
</div>
)}
{/* Action Bar Component - Always show for now to fix regression */}
{true && (
{/* Action Bar Component - hide destructive actions for shared notes */}
{!isSharedNote && (
<NoteActions
isPinned={optimisticNote.isPinned}
isArchived={optimisticNote.isArchived}
currentColor={optimisticNote.color}
currentSize={optimisticNote.size as 'small' | 'medium' | 'large'}
currentSize={isUniformMode ? 'small' : (optimisticNote.size as 'small' | 'medium' | 'large')}
onTogglePin={handleTogglePin}
onToggleArchive={handleToggleArchive}
onColorChange={handleColorChange}
onSizeChange={handleSizeChange}
onSizeChange={isUniformMode ? undefined : handleSizeChange}
onDelete={() => setShowDeleteDialog(true)}
onShareCollaborators={() => setShowCollaboratorDialog(true)}
isTrashView={isTrashView}
onRestore={handleRestore}
onPermanentDelete={() => setShowPermanentDeleteDialog(true)}
className="absolute bottom-0 left-0 right-0 p-2 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity"
/>
)}
@@ -638,13 +650,25 @@ export const NoteCard = memo(function NoteCard({
isOpen={showConnectionsOverlay}
onClose={() => setShowConnectionsOverlay(false)}
noteId={note.id}
onOpenNote={(noteId) => {
// Find the note and open it
onEdit?.(note, false)
onOpenNote={(connNoteId) => {
const params = new URLSearchParams(searchParams.toString())
params.set('note', connNoteId)
router.push(`?${params.toString()}`)
}}
onCompareNotes={(noteIds) => {
setComparisonNotes(noteIds)
}}
onMergeNotes={async (noteIds) => {
const fetchedNotes = await Promise.all(noteIds.map(async (id) => {
try {
const res = await fetch(`/api/notes/${id}`)
if (!res.ok) return null
const data = await res.json()
return data.success && data.data ? data.data : null
} catch { return null }
}))
setFusionNotes(fetchedNotes.filter((n: any) => n !== null) as Array<Partial<Note>>)
}}
/>
</div>
@@ -665,6 +689,38 @@ export const NoteCard = memo(function NoteCard({
</div>
)}
{/* Fusion Modal */}
{fusionNotes && fusionNotes.length > 0 && (
<div onClick={(e) => e.stopPropagation()}>
<FusionModal
isOpen={!!fusionNotes}
onClose={() => setFusionNotes([])}
notes={fusionNotes}
onConfirmFusion={async ({ title, content }, options) => {
await createNote({
title,
content,
labels: options.keepAllTags
? [...new Set(fusionNotes.flatMap(n => n.labels || []))]
: fusionNotes[0].labels || [],
color: fusionNotes[0].color,
type: 'text',
isMarkdown: true,
autoGenerated: true,
notebookId: fusionNotes[0].notebookId ?? undefined
})
if (options.archiveOriginals) {
for (const n of fusionNotes) {
if (n.id) await updateNote(n.id, { isArchived: true })
}
}
toast.success(t('toast.notesFusionSuccess'))
triggerRefresh()
}}
/>
</div>
)}
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
@@ -682,6 +738,24 @@ export const NoteCard = memo(function NoteCard({
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Permanent Delete Confirmation Dialog (Trash view only) */}
<AlertDialog open={showPermanentDeleteDialog} onOpenChange={setShowPermanentDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('trash.permanentDelete')}</AlertDialogTitle>
<AlertDialogDescription>
{t('trash.permanentDeleteConfirm')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel') || 'Cancel'}</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={handlePermanentDelete}>
{t('trash.permanentDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Card>
)
})