Files
Momento/memento-note/components/note-editor/note-cover-hero.tsx
Antigravity 45297da333 fix: images, couverture, slash, agents, wizard, Stripe et admin
- Collage/accès images: ownership path userId + meta legacy, 403 non caché
- Couverture note: explicite (choix/aucune), plus d’auto depuis le corps
- Éditeur: suppression image TipTap, sync immédiate, toolbar réordonnée
- Slash: listes par titre (plus d’index), keywords nettoyés
- Agents: nextRun à la réactivation, cron sans stampede null
- Wizard: titres courts + mode Expert plus robuste
- Stripe checkout hosted fiable; admin métriques live; dark mode IA/settings
- Indexation notes courtes + test unit aligné
2026-07-16 16:58:07 +00:00

193 lines
6.6 KiB
TypeScript

'use client'
/**
* Couverture sous le titre (full-page).
* - Affichée uniquement si une image de couverture est **explicite** (pas auto depuis le corps).
* - Permet de retirer la couverture sans supprimer les images du contenu.
* - Permet de choisir parmi les images de la note (corps + galerie).
*/
import { useState } from 'react'
import { ImageIcon, X, Check, Images } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
interface NoteCoverHeroProps {
/** URL de couverture explicite (note.images[0]), null = pas de couverture */
coverUrl: string | null
/** Candidats (images du contenu + galerie) pour le sélecteur */
candidates: string[]
readOnly?: boolean
title?: string
onSetCover: (url: string) => void
onClearCover: () => void
}
export function NoteCoverHero({
coverUrl,
candidates,
readOnly,
title,
onSetCover,
onClearCover,
}: NoteCoverHeroProps) {
const { t } = useLanguage()
const [pickerOpen, setPickerOpen] = useState(false)
const uniqueCandidates = Array.from(new Set(candidates.filter(Boolean)))
const hasCover = Boolean(coverUrl)
const canPick = !readOnly && uniqueCandidates.length > 0
// Rien à montrer : pas de cover et pas de candidats (ou lecture seule sans cover)
if (!hasCover && (readOnly || uniqueCandidates.length === 0)) {
return null
}
// Pas de cover mais des images dans la note → barre discrète pour en choisir une
if (!hasCover && canPick) {
return (
<div className="rounded-xl border border-dashed border-border/60 bg-muted/30 px-4 py-3 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
<ImageIcon className="h-4 w-4 shrink-0 opacity-70" />
<span className="truncate">
{t('notes.coverNoneHint') || 'Aucune image de couverture — choisissez une image de la note ou laissez vide.'}
</span>
</div>
<button
type="button"
onClick={() => setPickerOpen((v) => !v)}
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg border border-border bg-background hover:bg-muted transition-colors shrink-0"
>
<Images className="h-3.5 w-3.5" />
{t('notes.coverChoose') || 'Choisir une couverture'}
</button>
{pickerOpen && (
<CoverPicker
candidates={uniqueCandidates}
selected={null}
onSelect={(url) => {
onSetCover(url)
setPickerOpen(false)
}}
onClose={() => setPickerOpen(false)}
t={t}
/>
)}
</div>
)
}
if (!hasCover || !coverUrl) return null
return (
<div className="group/cover relative aspect-[16/9] w-full bg-slate-100 dark:bg-zinc-900 rounded-xl overflow-hidden shadow-xl">
<img
src={coverUrl}
alt={title || ''}
className="w-full h-full object-cover grayscale contrast-110 group-hover/cover:grayscale-0 transition-all duration-500"
/>
{!readOnly && (
<div className="absolute inset-x-0 top-0 p-3 flex items-start justify-end gap-2 opacity-0 group-hover/cover:opacity-100 focus-within:opacity-100 transition-opacity">
{uniqueCandidates.length > 1 && (
<button
type="button"
onClick={() => setPickerOpen((v) => !v)}
className="inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1.5 rounded-lg bg-black/60 text-white hover:bg-black/75 backdrop-blur-sm transition-colors"
title={t('notes.coverChoose') || 'Changer la couverture'}
>
<Images className="h-3.5 w-3.5" />
{t('notes.coverChange') || 'Changer'}
</button>
)}
<button
type="button"
onClick={onClearCover}
className="inline-flex items-center justify-center h-8 w-8 rounded-lg bg-black/60 text-white hover:bg-red-600/90 backdrop-blur-sm transition-colors"
title={t('notes.coverRemove') || 'Retirer la couverture'}
aria-label={t('notes.coverRemove') || 'Retirer la couverture'}
>
<X className="h-4 w-4" />
</button>
</div>
)}
{pickerOpen && !readOnly && (
<div className="absolute inset-x-0 bottom-0 p-3 bg-gradient-to-t from-black/70 to-transparent">
<CoverPicker
candidates={uniqueCandidates}
selected={coverUrl}
onSelect={(url) => {
onSetCover(url)
setPickerOpen(false)
}}
onClose={() => setPickerOpen(false)}
t={t}
dark
/>
</div>
)}
</div>
)
}
function CoverPicker({
candidates,
selected,
onSelect,
onClose,
t,
dark,
}: {
candidates: string[]
selected: string | null
onSelect: (url: string) => void
onClose: () => void
t: (key: string) => string
dark?: boolean
}) {
return (
<div className={cn('w-full', dark ? '' : 'col-span-full pt-1')}>
<div className="flex items-center justify-between gap-2 mb-2">
<p className={cn('text-[11px] font-medium', dark ? 'text-white/80' : 'text-muted-foreground')}>
{t('notes.coverPickLabel') || 'Image de couverture'}
</p>
<button
type="button"
onClick={onClose}
className={cn('text-[11px] underline-offset-2 hover:underline', dark ? 'text-white/70' : 'text-muted-foreground')}
>
{t('notes.close') || 'Fermer'}
</button>
</div>
<div className="flex gap-2 overflow-x-auto pb-1">
{candidates.map((url) => {
const isSelected = selected === url
return (
<button
key={url}
type="button"
onClick={() => onSelect(url)}
className={cn(
'relative shrink-0 h-16 w-24 rounded-lg overflow-hidden border-2 transition-all',
isSelected
? 'border-brand-accent ring-2 ring-brand-accent/40'
: dark
? 'border-white/20 hover:border-white/50'
: 'border-border hover:border-foreground/30',
)}
>
<img src={url} alt="" className="h-full w-full object-cover" />
{isSelected && (
<span className="absolute inset-0 flex items-center justify-center bg-black/30">
<Check className="h-5 w-5 text-white drop-shadow" />
</span>
)}
</button>
)
})}
</div>
</div>
)
}