167 lines
5.9 KiB
TypeScript
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>
|
|
)
|
|
}
|