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:
Antigravity
2026-09-05 20:53:48 +00:00
parent a7b16870a4
commit 3bdbec575a
37 changed files with 1138 additions and 482 deletions

View File

@@ -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 dont 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>