Files
Momento/memento-note/components/notes-list-view.tsx

167 lines
5.9 KiB
TypeScript

'use client'
import { useMemo } from 'react'
import { Note } from '@/lib/types'
import { cn } from '@/lib/utils'
import { formatDistanceToNow, Locale } from 'date-fns'
import { enUS } from 'date-fns/locale/en-US'
import { fr } from 'date-fns/locale/fr'
import { useLanguage } from '@/lib/i18n'
import { Users, FileText, ImageIcon } from 'lucide-react'
import { useSession } from 'next-auth/react'
const localeMap: Record<string, Locale> = {
en: enUS,
fr,
}
function stripHtml(html: string): string {
return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
}
function firstImageFromContent(html: string): string | null {
const m = html.match(/<img[^>]+src=["']([^"']+)["']/i)
return m ? m[1] : null
}
function previewText(note: Note): string {
if (note.type === 'richtext') {
return stripHtml(note.content || '').slice(0, 280)
}
if (note.type === 'markdown') {
return (note.content || '')
.replace(/^#{1,6}\s+/gm, '')
.replace(/[*`_~]/g, '')
.replace(/\[(.*?)\]\([^)]*\)/g, '$1')
.slice(0, 280)
}
if (note.type === 'checklist') {
const items = (note.checkItems || []).map((i) => i.text).join(' · ')
return items.slice(0, 280)
}
return (note.content || '').slice(0, 280)
}
function thumbUrl(note: Note): string | null {
if (note.images?.length) return note.images[0]!
if (note.type === 'richtext' && note.content) {
return firstImageFromContent(note.content)
}
return null
}
interface NotesListViewProps {
notes: Note[]
onEdit?: (note: Note, readOnly?: boolean) => void
}
export function NotesListView({
notes,
onEdit,
}: NotesListViewProps) {
const { t, language } = useLanguage()
const { data: session } = useSession()
const currentUserId = session?.user?.id
const locale = localeMap[language] || enUS
const sorted = useMemo(
() =>
[...notes].sort(
(a, b) =>
new Date(b.contentUpdatedAt || b.updatedAt).getTime() -
new Date(a.contentUpdatedAt || a.updatedAt).getTime()
),
[notes]
)
if (sorted.length === 0) {
return (
<p className="py-12 text-center text-sm text-muted-foreground" data-testid="notes-list-empty">
{t('notes.emptyState')}
</p>
)
}
return (
<div className="flex flex-col gap-3" data-testid="notes-list-view">
{sorted.map((note) => {
const thumb = thumbUrl(note)
const preview = previewText(note)
const title = note.title?.trim() || t('notes.untitled')
const edited = new Date(note.contentUpdatedAt || note.updatedAt)
const sharedCount = note.sharedWith?.length ?? 0
const isShared = sharedCount > 0 || (note as any)._isShared
const isSharedNote = !!(currentUserId && note.userId && currentUserId !== note.userId)
return (
<button
key={note.id}
type="button"
onClick={() => onEdit?.(note, !!isSharedNote)}
className={cn(
'group flex w-full gap-5 rounded-2xl border border-border/50 bg-card/90 p-4 text-start shadow-sm transition-all duration-200',
'hover:border-primary/25 hover:shadow-[0_8px_30px_-12px_color-mix(in_oklab, var(--foreground) 14%, transparent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30',
'md:gap-6 md:p-5'
)}
>
<div
className="relative size-[4.5rem] shrink-0 overflow-hidden rounded-xl border border-border/40 bg-muted/40 md:size-24"
aria-hidden
>
{thumb ? (
<img src={thumb} alt="" className="h-full w-full object-cover" loading="lazy" />
) : (
<div className="flex h-full w-full items-center justify-center text-muted-foreground/50">
<FileText className="size-8 stroke-[1.25]" />
</div>
)}
</div>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex flex-wrap items-baseline justify-between gap-2 gap-y-1">
<h3 className="font-memento-serif text-lg font-normal leading-snug tracking-tight text-foreground md:text-xl">
{title}
</h3>
<time
dateTime={edited.toISOString()}
className="shrink-0 text-[11px] font-medium uppercase tracking-wider text-muted-foreground tabular-nums"
>
{formatDistanceToNow(edited, { addSuffix: true, locale })}
</time>
</div>
{preview ? (
<p className="line-clamp-2 text-sm leading-relaxed text-muted-foreground md:line-clamp-3">{preview}</p>
) : null}
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
{(note.labels?.length ?? 0) > 0 && (
<div className="flex flex-wrap gap-1.5">
{note.labels!.slice(0, 6).map((label) => (
<span
key={label}
className="rounded-md border border-border/60 bg-muted/30 px-2 py-0.5 text-[11px] font-medium text-foreground/80"
>
{label}
</span>
))}
{note.labels!.length > 6 && (
<span className="text-[11px] text-muted-foreground">+{note.labels!.length - 6}</span>
)}
</div>
)}
{isShared && (
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-primary">
<Users className="size-3.5 opacity-80" />
{sharedCount > 0 ? `${sharedCount}` : t('notes.sharedShort') || 'Partagé'}
</span>
)}
</div>
</div>
</button>
)
})}
</div>
)
}