feat(notes): 3 états de tuile + renderer pseudo-markdown
Tuile note : semi (défaut, 3 lignes tronquées) / expanded (markdown complet + médias) / collapsed (titre + date uniquement). Bouton toggle fa-chevron en haut à droite qui cycle entre les états. Renderer pseudo-markdown inline : # ## ###, - * listes, 1. numérotées, > citations, --- séparateur, **gras** *italique* `code`, ``` blocs. Méta de tuile : icônes fa-image / fa-microphone / fa-video / fa-location-dot visibles en état semi et expanded. v0.5.5 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "homehub-frontend",
|
||||
"private": true,
|
||||
"version": "0.5.4",
|
||||
"version": "0.5.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+257
-201
@@ -17,10 +17,102 @@ const inputStyle: React.CSSProperties = {
|
||||
fontSize: 13,
|
||||
}
|
||||
|
||||
const actionBtnStyle: React.CSSProperties = {
|
||||
padding: '5px 10px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--bg-5)',
|
||||
background: 'var(--bg-4)',
|
||||
color: 'var(--ink-3)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
minHeight: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
// Formate le texte inline : **gras**, *italique*, `code`
|
||||
function inlineFmt(text: string): React.ReactNode {
|
||||
const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/)
|
||||
return (
|
||||
<>
|
||||
{parts.map((p, i) => {
|
||||
if (p.startsWith('**') && p.endsWith('**')) return <strong key={i}>{p.slice(2, -2)}</strong>
|
||||
if (p.startsWith('*') && p.endsWith('*')) return <em key={i} style={{ color: 'var(--ink-3)' }}>{p.slice(1, -1)}</em>
|
||||
if (p.startsWith('`') && p.endsWith('`')) return <code key={i} style={{ background: 'var(--bg-4)', borderRadius: 3, padding: '0 4px', fontFamily: 'var(--font-mono)', fontSize: '0.88em' }}>{p.slice(1, -1)}</code>
|
||||
return p || null
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Renderer pseudo-markdown ligne par ligne
|
||||
function renderMarkdown(text: string): React.ReactNode {
|
||||
const lines = text.split('\n')
|
||||
const nodes: React.ReactNode[] = []
|
||||
let i = 0
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
if (line.startsWith('```')) {
|
||||
const lang = line.slice(3).trim()
|
||||
const code: string[] = []
|
||||
i++
|
||||
while (i < lines.length && !lines[i].startsWith('```')) { code.push(lines[i]); i++ }
|
||||
nodes.push(
|
||||
<pre key={i} style={{ background: 'var(--bg-4)', border: '1px solid var(--bg-5)', borderRadius: 6, padding: '8px 12px', margin: '6px 0', fontFamily: 'var(--font-mono)', fontSize: 12, overflowX: 'auto', color: 'var(--ink-1)', whiteSpace: 'pre' }}>
|
||||
{lang && <div style={{ color: 'var(--ink-4)', fontSize: 10, marginBottom: 4, textTransform: 'uppercase', letterSpacing: 0.5 }}>{lang}</div>}
|
||||
{code.join('\n')}
|
||||
</pre>
|
||||
)
|
||||
} else if (line.startsWith('# ')) {
|
||||
nodes.push(<div key={i} style={{ fontWeight: 700, fontSize: 15, color: 'var(--accent)', marginTop: 10, marginBottom: 2, fontFamily: 'var(--font-ui)' }}>{inlineFmt(line.slice(2))}</div>)
|
||||
} else if (line.startsWith('## ')) {
|
||||
nodes.push(<div key={i} style={{ fontWeight: 700, fontSize: 14, color: 'var(--ink-1)', marginTop: 8, marginBottom: 2, paddingBottom: 3, borderBottom: '1px solid var(--bg-5)', fontFamily: 'var(--font-ui)' }}>{inlineFmt(line.slice(3))}</div>)
|
||||
} else if (line.startsWith('### ')) {
|
||||
nodes.push(<div key={i} style={{ fontWeight: 600, fontSize: 13, color: 'var(--ink-1)', marginTop: 6, marginBottom: 1, fontFamily: 'var(--font-ui)' }}>{inlineFmt(line.slice(4))}</div>)
|
||||
} else if (line.startsWith('- ') || line.startsWith('* ')) {
|
||||
nodes.push(
|
||||
<div key={i} style={{ display: 'flex', gap: 6, color: 'var(--ink-2)', lineHeight: 1.6, fontFamily: 'var(--font-ui)', fontSize: 13 }}>
|
||||
<span style={{ color: 'var(--accent)', flexShrink: 0 }}>•</span>
|
||||
<span>{inlineFmt(line.slice(2))}</span>
|
||||
</div>
|
||||
)
|
||||
} else if (/^\d+\.\s/.test(line)) {
|
||||
const m = line.match(/^(\d+)\.\s(.*)/)
|
||||
nodes.push(
|
||||
<div key={i} style={{ display: 'flex', gap: 6, color: 'var(--ink-2)', lineHeight: 1.6, fontFamily: 'var(--font-ui)', fontSize: 13 }}>
|
||||
<span style={{ color: 'var(--accent)', flexShrink: 0, minWidth: 18, textAlign: 'right' }}>{m?.[1]}.</span>
|
||||
<span>{inlineFmt(m?.[2] ?? '')}</span>
|
||||
</div>
|
||||
)
|
||||
} else if (line.startsWith('> ')) {
|
||||
nodes.push(
|
||||
<div key={i} style={{ borderLeft: '3px solid var(--accent)', paddingLeft: 10, color: 'var(--ink-3)', fontStyle: 'italic', margin: '4px 0', lineHeight: 1.6, fontFamily: 'var(--font-ui)', fontSize: 13 }}>
|
||||
{inlineFmt(line.slice(2))}
|
||||
</div>
|
||||
)
|
||||
} else if (line === '---' || line === '***') {
|
||||
nodes.push(<div key={i} style={{ borderBottom: '1px solid var(--bg-5)', margin: '8px 0' }} />)
|
||||
} else if (line.trim() === '') {
|
||||
nodes.push(<div key={i} style={{ height: 5 }} />)
|
||||
} else {
|
||||
nodes.push(
|
||||
<div key={i} style={{ color: 'var(--ink-2)', lineHeight: 1.6, fontFamily: 'var(--font-ui)', fontSize: 13 }}>
|
||||
{inlineFmt(line)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
i++
|
||||
}
|
||||
return <>{nodes}</>
|
||||
}
|
||||
|
||||
type NoteState = 'semi' | 'expanded' | 'collapsed'
|
||||
|
||||
function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onAddVideo, onDeleteAtt }: {
|
||||
note: Note
|
||||
onEdit: () => void
|
||||
@@ -30,6 +122,7 @@ function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onAddVideo,
|
||||
onAddVideo: (file: File) => void
|
||||
onDeleteAtt: (attId: string) => void
|
||||
}) {
|
||||
const [state, setState] = useState<NoteState>('semi')
|
||||
const photoRef = useRef<HTMLInputElement>(null)
|
||||
const audioRef = useRef<HTMLInputElement>(null)
|
||||
const videoRef = useRef<HTMLInputElement>(null)
|
||||
@@ -41,10 +134,16 @@ function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onAddVideo,
|
||||
const audios = note.attachments.filter(a => a.file_type === 'audio')
|
||||
const videos = note.attachments.filter(a => a.file_type === 'video')
|
||||
|
||||
function cycleState() {
|
||||
setState(s => s === 'semi' ? 'expanded' : s === 'expanded' ? 'collapsed' : 'semi')
|
||||
}
|
||||
|
||||
const stateIcon = state === 'semi' ? 'fa-chevron-down' : state === 'expanded' ? 'fa-minus' : 'fa-chevron-right'
|
||||
const stateTitle = state === 'semi' ? 'Tout afficher' : state === 'expanded' ? 'Réduire' : 'Développer'
|
||||
|
||||
async function startRecord() {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
// Choisir le format supporté par le navigateur (Safari → mp4, Chrome/Firefox → webm)
|
||||
const mimeType = ['audio/webm', 'audio/mp4', 'audio/ogg'].find(t => MediaRecorder.isTypeSupported(t)) ?? ''
|
||||
const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream)
|
||||
chunksRef.current = []
|
||||
@@ -59,9 +158,7 @@ function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onAddVideo,
|
||||
recorder.start()
|
||||
recorderRef.current = recorder
|
||||
setRecording(true)
|
||||
} catch {
|
||||
// micro non disponible ou permission refusée
|
||||
}
|
||||
} catch { /* micro non disponible */ }
|
||||
}
|
||||
|
||||
function stopRecord() {
|
||||
@@ -69,130 +166,154 @@ function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onAddVideo,
|
||||
setRecording(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass" style={{ borderRadius: 10, padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{/* En-tête */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8, ...noSelect }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{note.title && (
|
||||
<div style={{ color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontWeight: 600, fontSize: 14, marginBottom: 2 }}>
|
||||
{note.title}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ color: 'var(--ink-2)', fontFamily: 'var(--font-ui)', fontSize: 13, lineHeight: 1.5, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{note.content.length > 200 ? note.content.slice(0, 200) + '…' : note.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
const toggleBtn = (
|
||||
<button
|
||||
onClick={cycleState}
|
||||
title={stateTitle}
|
||||
style={{ background: 'transparent', border: 'none', color: 'var(--ink-4)', cursor: 'pointer', padding: '2px 6px', borderRadius: 4, fontSize: 13, flexShrink: 0, ...noSelect }}
|
||||
>
|
||||
<i className={`fa-solid ${stateIcon}`} />
|
||||
</button>
|
||||
)
|
||||
|
||||
{/* Photos */}
|
||||
const metaLine = (
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center', ...noSelect }}>
|
||||
<span style={{ color: 'var(--ink-4)', fontSize: 11, fontFamily: 'var(--font-mono)' }}>{formatDate(note.created_at)}</span>
|
||||
{note.category && <span style={{ background: 'var(--bg-4)', color: 'var(--info)', fontSize: 11, fontFamily: 'var(--font-ui)', borderRadius: 999, padding: '1px 7px' }}>{note.category}</span>}
|
||||
{note.tags.map(t => <span key={t} style={{ background: 'var(--bg-5)', color: 'var(--ink-3)', fontSize: 11, fontFamily: 'var(--font-ui)', borderRadius: 999, padding: '1px 7px' }}>{t}</span>)}
|
||||
{images.length > 0 && <i className="fa-solid fa-image" style={{ color: 'var(--ink-4)', fontSize: 11 }} title={`${images.length} photo(s)`} />}
|
||||
{audios.length > 0 && <i className="fa-solid fa-microphone" style={{ color: 'var(--ink-4)', fontSize: 11 }} title={`${audios.length} audio(s)`} />}
|
||||
{videos.length > 0 && <i className="fa-solid fa-video" style={{ color: 'var(--ink-4)', fontSize: 11 }} title={`${videos.length} vidéo(s)`} />}
|
||||
{note.gps_lat != null && (
|
||||
<i className="fa-solid fa-location-dot" style={{ color: 'var(--ok)', fontSize: 12 }} title={`${note.gps_lat.toFixed(4)}, ${note.gps_lon?.toFixed(4)}`} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const mediaSection = (
|
||||
<>
|
||||
{images.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{images.map(img => (
|
||||
<div key={img.id} style={{ position: 'relative' }}>
|
||||
<img
|
||||
src={`/media/${img.thumbnail_path ?? img.file_path}`}
|
||||
alt=""
|
||||
style={{ width: 72, height: 72, objectFit: 'cover', borderRadius: 6 }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => onDeleteAtt(img.id)}
|
||||
style={{ position: 'absolute', top: 2, right: 2, background: 'rgba(0,0,0,0.6)', border: 'none', color: '#fff', borderRadius: '50%', width: 18, height: 18, cursor: 'pointer', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}
|
||||
>✕</button>
|
||||
<img src={`/media/${img.thumbnail_path ?? img.file_path}`} alt="" style={{ width: 80, height: 80, objectFit: 'cover', borderRadius: 6 }} />
|
||||
<button onClick={() => onDeleteAtt(img.id)} style={{ position: 'absolute', top: 2, right: 2, background: 'rgba(0,0,0,0.65)', border: 'none', color: '#fff', borderRadius: '50%', width: 20, height: 20, cursor: 'pointer', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audios */}
|
||||
{audios.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{audios.map(aud => (
|
||||
<div key={aud.id} style={{ display: 'flex', alignItems: 'center', gap: 6, background: 'var(--bg-4)', borderRadius: 6, padding: '4px 8px' }}>
|
||||
<audio src={`/media/${aud.file_path}`} controls style={{ height: 28, flex: 1 }} />
|
||||
<button
|
||||
onClick={() => onDeleteAtt(aud.id)}
|
||||
style={{ background: 'transparent', border: 'none', color: 'var(--err)', cursor: 'pointer', fontSize: 14, flexShrink: 0 }}
|
||||
>✕</button>
|
||||
<button onClick={() => onDeleteAtt(aud.id)} style={{ background: 'transparent', border: 'none', color: 'var(--err)', cursor: 'pointer', fontSize: 14, flexShrink: 0 }}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vidéos */}
|
||||
{videos.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{videos.map(vid => (
|
||||
<div key={vid.id} style={{ position: 'relative' }}>
|
||||
<video
|
||||
src={`/media/${vid.file_path}`}
|
||||
controls
|
||||
playsInline
|
||||
style={{ width: '100%', maxHeight: 220, borderRadius: 6, background: '#000', display: 'block' }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => onDeleteAtt(vid.id)}
|
||||
style={{ position: 'absolute', top: 6, right: 6, background: 'rgba(0,0,0,0.65)', border: 'none', color: '#fff', borderRadius: '50%', width: 22, height: 22, cursor: 'pointer', fontSize: 11, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}
|
||||
>✕</button>
|
||||
<video src={`/media/${vid.file_path}`} controls playsInline style={{ width: '100%', maxHeight: 220, borderRadius: 6, background: '#000', display: 'block' }} />
|
||||
<button onClick={() => onDeleteAtt(vid.id)} style={{ position: 'absolute', top: 6, right: 6, background: 'rgba(0,0,0,0.65)', border: 'none', color: '#fff', borderRadius: '50%', width: 22, height: 22, cursor: 'pointer', fontSize: 11, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
{/* Méta */}
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center', ...noSelect }}>
|
||||
<span style={{ color: 'var(--ink-4)', fontSize: 11, fontFamily: 'var(--font-mono)' }}>{formatDate(note.created_at)}</span>
|
||||
{note.category && (
|
||||
<span style={{ background: 'var(--bg-4)', color: 'var(--info)', fontSize: 11, fontFamily: 'var(--font-ui)', borderRadius: 999, padding: '1px 7px' }}>{note.category}</span>
|
||||
)}
|
||||
{note.tags.map(t => (
|
||||
<span key={t} style={{ background: 'var(--bg-5)', color: 'var(--ink-3)', fontSize: 11, fontFamily: 'var(--font-ui)', borderRadius: 999, padding: '1px 7px' }}>{t}</span>
|
||||
))}
|
||||
{images.length > 0 && <i className="fa-solid fa-image" style={{ color: 'var(--ink-4)', fontSize: 11 }} title={`${images.length} photo(s)`} />}
|
||||
{audios.length > 0 && <i className="fa-solid fa-microphone" style={{ color: 'var(--ink-4)', fontSize: 11 }} title={`${audios.length} audio(s)`} />}
|
||||
{videos.length > 0 && <i className="fa-solid fa-video" style={{ color: 'var(--ink-4)', fontSize: 11 }} title={`${videos.length} vidéo(s)`} />}
|
||||
{note.gps_lat != null && (
|
||||
<i className="fa-solid fa-location-dot" style={{ color: 'var(--ok)', fontSize: 12 }} title={`${note.gps_lat.toFixed(4)}, ${note.gps_lon?.toFixed(4)}`} />
|
||||
)}
|
||||
const actionButtons = (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<input ref={photoRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={e => { const f = e.target.files?.[0]; if (f) onAddPhoto(f); e.target.value = '' }} />
|
||||
<button onClick={() => photoRef.current?.click()} title="Photo" style={actionBtnStyle}><i className="fa-solid fa-camera" /></button>
|
||||
|
||||
<input ref={audioRef} type="file" accept="audio/*" style={{ display: 'none' }} onChange={e => { const f = e.target.files?.[0]; if (f) onAddAudio(f); e.target.value = '' }} />
|
||||
<button
|
||||
onClick={recording ? stopRecord : startRecord}
|
||||
title={recording ? "Arrêter" : "Enregistrer"}
|
||||
style={{ ...actionBtnStyle, background: recording ? 'var(--err)' : actionBtnStyle.background, color: recording ? '#fff' : actionBtnStyle.color, border: recording ? 'none' : actionBtnStyle.border }}
|
||||
><i className={`fa-solid fa-${recording ? 'stop' : 'microphone'}`} /></button>
|
||||
|
||||
<input ref={videoRef} type="file" accept="video/*" style={{ display: 'none' }} onChange={e => { const f = e.target.files?.[0]; if (f) onAddVideo(f); e.target.value = '' }} />
|
||||
<button onClick={() => videoRef.current?.click()} title="Vidéo" style={actionBtnStyle}><i className="fa-solid fa-video" /></button>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
<button onClick={onEdit} title="Éditer" style={{ ...actionBtnStyle, background: 'var(--bg-5)', border: 'none' }}><i className="fa-solid fa-pen" /></button>
|
||||
<button onClick={onDelete} title="Supprimer" style={{ ...actionBtnStyle, background: 'transparent', color: 'var(--err)' }}><i className="fa-solid fa-xmark" /></button>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ─── COLLAPSED ───────────────────────────────────────────────────────────────
|
||||
if (state === 'collapsed') {
|
||||
return (
|
||||
<div className="glass" style={{ borderRadius: 10, padding: '8px 14px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0, ...noSelect }}>
|
||||
<span style={{ color: 'var(--ink-2)', fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: note.title ? 600 : 400 }}>
|
||||
{note.title || note.content.slice(0, 60).replace(/\n/g, ' ')}
|
||||
</span>
|
||||
<span style={{ color: 'var(--ink-4)', fontFamily: 'var(--font-mono)', fontSize: 11, marginLeft: 8 }}>
|
||||
{formatDate(note.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
{toggleBtn}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<input ref={photoRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={e => { const f = e.target.files?.[0]; if (f) onAddPhoto(f); e.target.value = '' }} />
|
||||
<button
|
||||
onClick={() => photoRef.current?.click()}
|
||||
title="Ajouter une photo"
|
||||
style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid var(--bg-5)', background: 'var(--bg-4)', color: 'var(--ink-3)', cursor: 'pointer', fontSize: 13 }}
|
||||
><i className="fa-solid fa-camera" /></button>
|
||||
|
||||
<input ref={audioRef} type="file" accept="audio/*" style={{ display: 'none' }} onChange={e => { const f = e.target.files?.[0]; if (f) onAddAudio(f); e.target.value = '' }} />
|
||||
<button
|
||||
onClick={recording ? stopRecord : startRecord}
|
||||
title={recording ? "Arrêter l'enregistrement" : 'Enregistrer un audio'}
|
||||
style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid var(--bg-5)', background: recording ? 'var(--err)' : 'var(--bg-4)', color: recording ? '#fff' : 'var(--ink-3)', cursor: 'pointer', fontSize: 13 }}
|
||||
><i className={`fa-solid fa-${recording ? 'stop' : 'microphone'}`} /></button>
|
||||
|
||||
<input ref={videoRef} type="file" accept="video/*" style={{ display: 'none' }} onChange={e => { const f = e.target.files?.[0]; if (f) onAddVideo(f); e.target.value = '' }} />
|
||||
<button
|
||||
onClick={() => videoRef.current?.click()}
|
||||
title="Ajouter une vidéo"
|
||||
style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid var(--bg-5)', background: 'var(--bg-4)', color: 'var(--ink-3)', cursor: 'pointer', fontSize: 13 }}
|
||||
><i className="fa-solid fa-video" /></button>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
<button
|
||||
onClick={onEdit}
|
||||
style={{ padding: '4px 10px', borderRadius: 6, border: 'none', background: 'var(--bg-5)', color: 'var(--ink-2)', cursor: 'pointer', fontSize: 13 }}
|
||||
><i className="fa-solid fa-pen" /></button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
style={{ padding: '4px 10px', borderRadius: 6, border: 'none', background: 'transparent', color: 'var(--err)', cursor: 'pointer', fontSize: 14 }}
|
||||
><i className="fa-solid fa-xmark" /></button>
|
||||
// ─── SEMI (défaut) ───────────────────────────────────────────────────────────
|
||||
if (state === 'semi') {
|
||||
return (
|
||||
<div className="glass" style={{ borderRadius: 10, padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0, ...noSelect }}>
|
||||
{note.title && (
|
||||
<div style={{ color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontWeight: 600, fontSize: 14, marginBottom: 4 }}>
|
||||
{note.title}
|
||||
</div>
|
||||
)}
|
||||
<div style={{
|
||||
color: 'var(--ink-2)', fontFamily: 'var(--font-ui)', fontSize: 13, lineHeight: 1.5,
|
||||
display: '-webkit-box', WebkitLineClamp: 3, WebkitBoxOrient: 'vertical', overflow: 'hidden',
|
||||
} as React.CSSProperties}>
|
||||
{note.content}
|
||||
</div>
|
||||
</div>
|
||||
{toggleBtn}
|
||||
</div>
|
||||
{metaLine}
|
||||
{actionButtons}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── EXPANDED ────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="glass" style={{ borderRadius: 10, padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8, ...noSelect }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{note.title && (
|
||||
<div style={{ color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontWeight: 700, fontSize: 15 }}>
|
||||
{note.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{toggleBtn}
|
||||
</div>
|
||||
<div>{renderMarkdown(note.content)}</div>
|
||||
{mediaSection}
|
||||
{metaLine}
|
||||
{actionButtons}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── PAGE ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function NotesPage() {
|
||||
const [notes, setNotes] = useState<Note[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -209,13 +330,7 @@ export default function NotesPage() {
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
aria-label="Nouvelle note"
|
||||
style={{
|
||||
width: 56, height: 56, borderRadius: '50%',
|
||||
background: 'var(--accent)', color: '#1d2021', border: 'none',
|
||||
fontSize: 24, cursor: 'pointer',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
style={{ width: 56, height: 56, borderRadius: '50%', background: 'var(--accent)', color: '#1d2021', border: 'none', fontSize: 24, cursor: 'pointer', boxShadow: '0 4px 16px rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
>+</button>
|
||||
)
|
||||
return () => setActionButton(null)
|
||||
@@ -257,62 +372,56 @@ export default function NotesPage() {
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm('Supprimer cette note ?')) return
|
||||
try {
|
||||
await deleteNote(id)
|
||||
void load()
|
||||
} catch {
|
||||
setError('Erreur lors de la suppression')
|
||||
}
|
||||
try { await deleteNote(id); void load() }
|
||||
catch { setError('Erreur lors de la suppression') }
|
||||
}
|
||||
|
||||
async function handleAddPhoto(noteId: string, file: File) {
|
||||
try {
|
||||
await addAttachment(noteId, file)
|
||||
void load()
|
||||
} catch {
|
||||
setError('Erreur upload photo')
|
||||
}
|
||||
try { await addAttachment(noteId, file); void load() }
|
||||
catch { setError('Erreur upload photo') }
|
||||
}
|
||||
|
||||
async function handleAddAudio(noteId: string, file: File) {
|
||||
try {
|
||||
await addAttachment(noteId, file)
|
||||
void load()
|
||||
} catch {
|
||||
setError('Erreur upload audio')
|
||||
}
|
||||
try { await addAttachment(noteId, file); void load() }
|
||||
catch { setError('Erreur upload audio') }
|
||||
}
|
||||
|
||||
async function handleAddVideo(noteId: string, file: File) {
|
||||
try {
|
||||
await addAttachment(noteId, file)
|
||||
void load()
|
||||
} catch {
|
||||
setError('Erreur upload vidéo')
|
||||
}
|
||||
try { await addAttachment(noteId, file); void load() }
|
||||
catch { setError('Erreur upload vidéo') }
|
||||
}
|
||||
|
||||
async function handleDeleteAtt(noteId: string, attId: string) {
|
||||
try {
|
||||
await deleteAttachment(noteId, attId)
|
||||
void load()
|
||||
} catch {
|
||||
setError('Erreur suppression pièce jointe')
|
||||
}
|
||||
try { await deleteAttachment(noteId, attId); void load() }
|
||||
catch { setError('Erreur suppression pièce jointe') }
|
||||
}
|
||||
|
||||
const hasActiveFilters = filters.has_photo || filters.has_audio || filters.has_video || filters.has_gps
|
||||
|
||||
const noteGrid = (cols: string) => (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: cols, gap: 10 }}>
|
||||
{notes.map(note => (
|
||||
<NoteCard
|
||||
key={note.id}
|
||||
note={note}
|
||||
onEdit={() => setEditingNote(note)}
|
||||
onDelete={() => void handleDelete(note.id)}
|
||||
onAddPhoto={f => void handleAddPhoto(note.id, f)}
|
||||
onAddAudio={f => void handleAddAudio(note.id, f)}
|
||||
onAddVideo={f => void handleAddVideo(note.id, f)}
|
||||
onDeleteAtt={attId => void handleDeleteAtt(note.id, attId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
{/* En-tête */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<h1 style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', margin: 0, flex: 1, minWidth: 100, ...noSelect }}>
|
||||
Notes
|
||||
</h1>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
|
||||
<h1 style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', margin: 0, flex: 1, ...noSelect }}>Notes</h1>
|
||||
</div>
|
||||
|
||||
{/* Barre de recherche + filtres */}
|
||||
{/* Barre recherche + filtres */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<input
|
||||
style={{ ...inputStyle, flex: 1, minWidth: 160 }}
|
||||
@@ -320,7 +429,6 @@ export default function NotesPage() {
|
||||
value={searchInput}
|
||||
onChange={e => handleSearchChange(e.target.value)}
|
||||
/>
|
||||
{/* Filtres rapides */}
|
||||
{([
|
||||
{ key: 'has_photo', icon: 'fa-image', label: 'Photo' },
|
||||
{ key: 'has_audio', icon: 'fa-microphone', label: 'Audio' },
|
||||
@@ -332,17 +440,9 @@ export default function NotesPage() {
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setFilters(f => ({ ...f, [key]: active ? undefined : true }))}
|
||||
style={{
|
||||
padding: '5px 10px', borderRadius: 999, border: 'none',
|
||||
background: active ? 'var(--accent)' : 'var(--bg-3)',
|
||||
color: active ? '#1d2021' : 'var(--ink-3)',
|
||||
cursor: 'pointer', fontFamily: 'var(--font-ui)', fontSize: 12,
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
...noSelect,
|
||||
}}
|
||||
style={{ padding: '5px 10px', borderRadius: 999, border: 'none', background: active ? 'var(--accent)' : 'var(--bg-3)', color: active ? '#1d2021' : 'var(--ink-3)', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontSize: 12, display: 'flex', alignItems: 'center', gap: 5, ...noSelect }}
|
||||
>
|
||||
<i className={`fa-solid ${icon}`} style={{ fontSize: 11 }} />
|
||||
{label}
|
||||
<i className={`fa-solid ${icon}`} style={{ fontSize: 11 }} />{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
@@ -355,76 +455,32 @@ export default function NotesPage() {
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p style={{ color: 'var(--err)', background: 'var(--bg-3)', borderRadius: 8, padding: '8px 12px', marginBottom: 12, fontFamily: 'var(--font-ui)', fontSize: 13 }}>
|
||||
{error}
|
||||
</p>
|
||||
<p style={{ color: 'var(--err)', background: 'var(--bg-3)', borderRadius: 8, padding: '8px 12px', marginBottom: 12, fontFamily: 'var(--font-ui)', fontSize: 13 }}>{error}</p>
|
||||
)}
|
||||
|
||||
{/* Modal création */}
|
||||
{showForm && (
|
||||
<Modal title="Nouvelle note" onClose={() => setShowForm(false)}>
|
||||
<NoteForm onSubmit={handleCreate} onCancel={() => setShowForm(false)} />
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Modal édition */}
|
||||
{editingNote && (
|
||||
<Modal title="Modifier la note" onClose={() => setEditingNote(null)}>
|
||||
<NoteForm
|
||||
initialValues={editingNote}
|
||||
onSubmit={data => handleUpdate(editingNote.id, data)}
|
||||
onCancel={() => setEditingNote(null)}
|
||||
submitLabel="Enregistrer"
|
||||
/>
|
||||
<NoteForm initialValues={editingNote} onSubmit={data => handleUpdate(editingNote.id, data)} onCancel={() => setEditingNote(null)} submitLabel="Enregistrer" />
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<p style={{ color: 'var(--ink-3)', textAlign: 'center', padding: 24, ...noSelect }}>Chargement…</p>
|
||||
{loading && <p style={{ color: 'var(--ink-3)', textAlign: 'center', padding: 24, ...noSelect }}>Chargement…</p>}
|
||||
|
||||
{!loading && notes.length === 0 && (
|
||||
<p style={{ color: 'var(--ink-3)', textAlign: 'center', marginTop: 40, ...noSelect }}>Aucune note</p>
|
||||
)}
|
||||
|
||||
{/* Mobile — liste chronologique */}
|
||||
<div className="block lg:hidden">
|
||||
{!loading && notes.length === 0 && (
|
||||
<p style={{ color: 'var(--ink-3)', textAlign: 'center', marginTop: 40, ...noSelect }}>Aucune note</p>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{notes.map(note => (
|
||||
<NoteCard
|
||||
key={note.id}
|
||||
note={note}
|
||||
onEdit={() => setEditingNote(note)}
|
||||
onDelete={() => void handleDelete(note.id)}
|
||||
onAddPhoto={f => void handleAddPhoto(note.id, f)}
|
||||
onAddAudio={f => void handleAddAudio(note.id, f)}
|
||||
onAddVideo={f => void handleAddVideo(note.id, f)}
|
||||
onDeleteAtt={attId => void handleDeleteAtt(note.id, attId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{noteGrid('1fr')}
|
||||
</div>
|
||||
|
||||
{/* Laptop — grille */}
|
||||
<div className="hidden lg:block">
|
||||
{!loading && notes.length === 0 && (
|
||||
<p style={{ color: 'var(--ink-3)', textAlign: 'center', marginTop: 40, ...noSelect }}>Aucune note</p>
|
||||
)}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 12 }}>
|
||||
{notes.map(note => (
|
||||
<NoteCard
|
||||
key={note.id}
|
||||
note={note}
|
||||
onEdit={() => setEditingNote(note)}
|
||||
onDelete={() => void handleDelete(note.id)}
|
||||
onAddPhoto={f => void handleAddPhoto(note.id, f)}
|
||||
onAddAudio={f => void handleAddAudio(note.id, f)}
|
||||
onAddVideo={f => void handleAddVideo(note.id, f)}
|
||||
onDeleteAtt={attId => void handleDeleteAtt(note.id, attId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{noteGrid('repeat(auto-fill, minmax(320px, 1fr))')}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user