- 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é
193 lines
6.6 KiB
TypeScript
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>
|
|
)
|
|
}
|