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é
This commit is contained in:
192
memento-note/components/note-editor/note-cover-hero.tsx
Normal file
192
memento-note/components/note-editor/note-cover-hero.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user