feat(ui): panneau repliable, carnets plus lisibles et accueil plus clair
Harmonise la liste des notes et l’éditeur (titres, dates, retour), rend la saisie rapide et les raccourcis de l’accueil utilisables sur téléphone, et conserve le panneau latéral repliable.
This commit is contained in:
@@ -26,9 +26,7 @@ import { toast } from 'sonner'
|
||||
import { ConfirmDeleteNoteDialog } from '@/components/confirm-delete-note-dialog'
|
||||
import { showNoteTrashedToast } from '@/lib/notes/trash-toast'
|
||||
import { NoteSelectCheckbox } from '@/components/note-select-checkbox'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
|
||||
import { dateTextDirection, formatNoteCalendarDate } from '@/lib/utils/format-localized-date'
|
||||
import { sanitizeIllustrationSvg } from '@/lib/sanitize-content'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useHydrated } from '@/lib/use-hydrated'
|
||||
@@ -45,15 +43,7 @@ type NotesEditorialViewProps = {
|
||||
} & NoteCollectionActions
|
||||
|
||||
function formatNoteDate(date: Date | string, language: string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
const locale = language === 'fr' ? fr : enUS
|
||||
if (language === 'fa') {
|
||||
return formatAbsoluteDateLocalized(d, language, 'd MMM yyyy', locale)
|
||||
}
|
||||
const month = d.toLocaleDateString('en-US', { month: 'short', timeZone: 'UTC' })
|
||||
const day = d.getUTCDate()
|
||||
const year = d.getUTCFullYear()
|
||||
return `${month.toUpperCase()} ${day}, ${year}`
|
||||
return formatNoteCalendarDate(date, language)
|
||||
}
|
||||
|
||||
export function EditorialNoteMenu({
|
||||
@@ -251,13 +241,6 @@ export function EditorialNoteMenu({
|
||||
)
|
||||
}
|
||||
|
||||
/** Deterministic hue from a string — consistent per note */
|
||||
function stringToHue(s: string): number {
|
||||
let h = 0
|
||||
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) & 0xffff
|
||||
return h % 360
|
||||
}
|
||||
|
||||
function EditorialThumbnail({
|
||||
note,
|
||||
title,
|
||||
@@ -291,12 +274,12 @@ function EditorialThumbnail({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full md:w-56 aspect-[4/3] bg-card/80 border border-border overflow-hidden rounded shadow-sm flex-shrink-0 group/thumb">
|
||||
<div className={cn('relative shrink-0', img || note.illustrationSvg ? 'w-24 sm:w-32 aspect-[4/3] overflow-hidden rounded-md bg-muted/30' : 'flex w-11 flex-col items-center gap-1')}>
|
||||
{img ? (
|
||||
<img
|
||||
src={img}
|
||||
alt=""
|
||||
className="w-full h-full object-cover mix-blend-multiply opacity-80 grayscale contrast-125 hover:grayscale-0 hover:opacity-100 transition-all duration-500"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : note.illustrationSvg ? (
|
||||
<div
|
||||
@@ -307,13 +290,15 @@ function EditorialThumbnail({
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<NoteThumbnailPlaceholder noteId={note.id} />
|
||||
<span className="flex h-11 w-11 items-center justify-center rounded-md bg-brand-accent/10 text-brand-accent" aria-hidden>
|
||||
<FileText size={22} strokeWidth={1.5} />
|
||||
</span>
|
||||
{aiIllustrationEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('notes.generateIllustration') || 'Générer une illustration IA'}
|
||||
title={t('notes.generateIllustration') || 'Générer une illustration IA'}
|
||||
className="absolute bottom-2 end-2 flex h-9 w-9 items-center justify-center rounded-full border border-border bg-background/95 text-foreground shadow-card-rest backdrop-blur-sm transition-colors hover:bg-accent z-10 opacity-0 group-hover/thumb:opacity-100 md:opacity-100 focus-visible:opacity-100"
|
||||
className="flex h-11 w-11 items-center justify-center rounded-md text-brand-accent transition-colors hover:bg-brand-accent/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-accent"
|
||||
onClick={handleGenerateSvg}
|
||||
disabled={busy}
|
||||
>
|
||||
@@ -326,38 +311,6 @@ function EditorialThumbnail({
|
||||
)
|
||||
}
|
||||
|
||||
/** SVG thumbnail for notes without an image — icône document (ref. architectural-grid), pas initiale */
|
||||
function NoteThumbnailPlaceholder({ noteId }: { noteId: string }) {
|
||||
const hue = stringToHue(noteId)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full w-full flex items-center justify-center relative overflow-hidden"
|
||||
style={{ background: `linear-gradient(145deg, hsl(${hue} 25% var(--thumb-lightness-1, 94%)) 0%, hsl(${hue} 18% var(--thumb-lightness-2, 87%)) 100%)` }}
|
||||
>
|
||||
<svg
|
||||
className="absolute inset-0 w-full h-full"
|
||||
viewBox="0 0 224 168"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
style={{ color: `hsl(${hue} 30% 60%)` }}
|
||||
>
|
||||
<circle cx="112" cy="84" r="90" stroke="currentColor" strokeWidth="0.6" opacity="0.25" />
|
||||
<circle cx="112" cy="84" r="64" stroke="currentColor" strokeWidth="0.6" opacity="0.2" />
|
||||
<circle cx="112" cy="84" r="38" stroke="currentColor" strokeWidth="0.6" opacity="0.15" />
|
||||
<line x1="22" y1="84" x2="202" y2="84" stroke="currentColor" strokeWidth="0.4" opacity="0.15" />
|
||||
<line x1="112" y1="4" x2="112" y2="164" stroke="currentColor" strokeWidth="0.4" opacity="0.15" />
|
||||
</svg>
|
||||
<FileText
|
||||
size={40}
|
||||
strokeWidth={1.25}
|
||||
className="relative text-muted-foreground/45"
|
||||
style={{ color: `hsl(${hue} 25% 45%)` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NoteTag({ labelName, allLabels }: { labelName: string; allLabels: any[] }) {
|
||||
const labelDef = allLabels?.find(l => l.name === labelName)
|
||||
const isAI = labelDef?.type === 'ai'
|
||||
@@ -400,13 +353,15 @@ export function NotesEditorialView({
|
||||
}, [session?.user?.id])
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-3xl space-y-8">
|
||||
<div className="mx-auto w-full min-w-0 max-w-3xl divide-y divide-border/50">
|
||||
<AnimatePresence>
|
||||
{notes.map((note: Note, index: number) => {
|
||||
const title = getNoteDisplayTitle(note, t('notes.untitled') || 'Untitled')
|
||||
const excerpt = getNotePlainExcerpt(note)
|
||||
const dateStr = formatNoteDate(note.createdAt, language)
|
||||
const editorialRtl = language === 'fa' || language === 'ar'
|
||||
const dateDir = dateTextDirection(language)
|
||||
const dateLang = language === 'fa' ? 'fa' : language === 'ar' ? 'ar' : undefined
|
||||
|
||||
return (
|
||||
<motion.article
|
||||
@@ -414,7 +369,7 @@ export function NotesEditorialView({
|
||||
initial={hydrated ? { opacity: 0, y: 20 } : false}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={hydrated ? { delay: 0.05 * index, duration: 0.6 } : { duration: 0 }}
|
||||
className="space-y-4 group cursor-pointer relative pb-8"
|
||||
className="min-w-0 space-y-3 group cursor-pointer relative py-5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-accent"
|
||||
onClick={() => onOpen(note)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') onOpen(note) }}
|
||||
tabIndex={0}
|
||||
@@ -422,21 +377,21 @@ export function NotesEditorialView({
|
||||
>
|
||||
{/* Date / breadcrumb — isolated bidi so Latin notebook name + Jalali date don’t reorder wrongly */}
|
||||
<div
|
||||
className={cn('note-date-badge', editorialRtl && 'note-date-badge--locale-rtl')}
|
||||
className="pe-12 text-xs font-medium text-muted-foreground"
|
||||
dir={editorialRtl ? 'rtl' : 'ltr'}
|
||||
>
|
||||
{notebookName ? (
|
||||
<>
|
||||
<bdi className={cn(editorialRtl && 'uppercase tracking-[0.2em]')}>{notebookName}</bdi>
|
||||
<bdi className={cn(editorialRtl && 'uppercase tracking-[0.2em]')} dir="auto">{notebookName}</bdi>
|
||||
<span className="mx-1.5 select-none text-muted-foreground/80" aria-hidden>
|
||||
—
|
||||
</span>
|
||||
<bdi dir="rtl" lang={editorialRtl ? 'fa' : undefined}>
|
||||
<bdi dir={dateDir} lang={dateLang}>
|
||||
{dateStr}
|
||||
</bdi>
|
||||
</>
|
||||
) : (
|
||||
<bdi dir={editorialRtl ? 'rtl' : 'ltr'} lang={editorialRtl ? 'fa' : undefined}>
|
||||
<bdi dir={dateDir} lang={dateLang}>
|
||||
{dateStr}
|
||||
</bdi>
|
||||
)}
|
||||
@@ -444,7 +399,7 @@ export function NotesEditorialView({
|
||||
|
||||
{/* Actions menu — absolutely positioned at top-right */}
|
||||
<div
|
||||
className="absolute top-0 end-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
className="absolute top-3 end-0 opacity-100 md:opacity-0 md:group-hover:opacity-100 focus-within:opacity-100 transition-opacity"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<EditorialNoteMenu
|
||||
@@ -459,8 +414,8 @@ export function NotesEditorialView({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 className="font-memento-serif text-2xl font-medium text-foreground flex items-center gap-2 justify-between">
|
||||
<span className="inline-flex items-center gap-2 truncate min-w-0">
|
||||
<h2 className="font-memento-serif text-lg sm:text-xl leading-snug font-medium text-foreground flex items-start gap-2">
|
||||
<span className="inline-flex items-start gap-2 min-w-0 flex-1">
|
||||
{selection && (
|
||||
<NoteSelectCheckbox
|
||||
checked={selection.selectedIds.has(note.id)}
|
||||
@@ -470,21 +425,21 @@ export function NotesEditorialView({
|
||||
)}
|
||||
{note.isPinned && <Pin size={14} className="text-amber-500 fill-amber-500 shrink-0" />}
|
||||
{note.historyEnabled && <History size={14} className="text-emerald-500 shrink-0" />}
|
||||
{title}
|
||||
<span className="min-w-0 break-words [overflow-wrap:anywhere]">{title}</span>
|
||||
</span>
|
||||
<span className="opacity-0 group-hover:opacity-30 transition-opacity shrink-0">
|
||||
<ChevronRight size={20} />
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-8 items-start">
|
||||
<div className="flex gap-3 sm:gap-4 items-start min-w-0">
|
||||
<EditorialThumbnail
|
||||
note={note}
|
||||
title={title}
|
||||
aiIllustrationEnabled={aiIllustrationEnabled}
|
||||
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
|
||||
/>
|
||||
<div className="space-y-3 flex-1">
|
||||
<div className="min-w-0 space-y-2 flex-1">
|
||||
{note.labels && note.labels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{note.labels.slice(0, 2).map((labelName) => (
|
||||
@@ -493,13 +448,13 @@ export function NotesEditorialView({
|
||||
</div>
|
||||
)}
|
||||
{excerpt ? (
|
||||
<p className="text-[14px] leading-relaxed text-foreground/80 font-light max-w-lg line-clamp-4">
|
||||
<p className="text-sm leading-relaxed text-foreground/80 line-clamp-2 break-words [overflow-wrap:anywhere]">
|
||||
{excerpt}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm italic text-muted-foreground/50">{t('notes.noContent')}</p>
|
||||
<p className="text-sm italic text-muted-foreground">{t('notes.noContent')}</p>
|
||||
)}
|
||||
<span className="text-[11px] text-muted-foreground uppercase tracking-widest font-medium inline-flex items-center gap-1 group-hover:gap-2 transition-all">
|
||||
<span className="text-xs text-brand-accent font-medium inline-flex items-center gap-1">
|
||||
{t('notes.readMore') || 'Read more'}
|
||||
<ChevronRight size={10} />
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user