fix: auto-save hashtags, plan-based platforms, archive with text preview
- Hashtag auto-save: debounced 500ms save on add/remove/edit, no manual button - Platform chips: Freemium sees only Instagram/Facebook, Pro sees all 4 - Platform badge: changed from tab-like to informative "per instagram" label - Add "Archivio →" link in content page header - Rewrite ContentArchive: show text_content preview (was showing only hashtags), add edit button, use Editorial Fresh design system, fix post.text → post.text_content Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,25 +1,13 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
|
||||
const statusLabels = {
|
||||
draft: 'Bozza',
|
||||
approved: 'Approvato',
|
||||
scheduled: 'Schedulato',
|
||||
published: 'Pubblicato',
|
||||
}
|
||||
|
||||
const statusLabels = { draft: 'Bozza', approved: 'Approvato', scheduled: 'Schedulato', published: 'Pubblicato' }
|
||||
const statusColors = {
|
||||
draft: 'bg-amber-50 text-amber-600',
|
||||
approved: 'bg-emerald-50 text-emerald-600',
|
||||
scheduled: 'bg-blue-50 text-blue-600',
|
||||
published: 'bg-violet-50 text-violet-600',
|
||||
}
|
||||
|
||||
const platformLabels = {
|
||||
instagram: 'Instagram',
|
||||
facebook: 'Facebook',
|
||||
youtube: 'YouTube',
|
||||
tiktok: 'TikTok',
|
||||
draft: { bg: '#FFFBEB', color: '#B45309' },
|
||||
approved: { bg: 'var(--success-light)', color: 'var(--success)' },
|
||||
scheduled: { bg: '#EFF6FF', color: '#1D4ED8' },
|
||||
published: { bg: '#F5F3FF', color: '#6D28D9' },
|
||||
}
|
||||
|
||||
export default function ContentArchive() {
|
||||
@@ -27,12 +15,12 @@ export default function ContentArchive() {
|
||||
const [characters, setCharacters] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expandedId, setExpandedId] = useState(null)
|
||||
const [editingId, setEditingId] = useState(null)
|
||||
const [editText, setEditText] = useState('')
|
||||
const [filterCharacter, setFilterCharacter] = useState('')
|
||||
const [filterStatus, setFilterStatus] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
useEffect(() => { loadData() }, [])
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
@@ -43,185 +31,177 @@ export default function ContentArchive() {
|
||||
])
|
||||
setPosts(postsData)
|
||||
setCharacters(charsData)
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
} catch { /* silent */ } finally { setLoading(false) }
|
||||
}
|
||||
|
||||
const getCharacterName = (id) => {
|
||||
const c = characters.find((ch) => ch.id === id)
|
||||
return c ? c.name : '—'
|
||||
}
|
||||
const getCharacterName = (id) => characters.find(c => c.id === id)?.name || '—'
|
||||
|
||||
const handleApprove = async (postId) => {
|
||||
try {
|
||||
await api.post(`/content/posts/${postId}/approve`)
|
||||
loadData()
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
try { await api.post(`/content/posts/${postId}/approve`); loadData() } catch {}
|
||||
}
|
||||
|
||||
const handleDelete = async (postId) => {
|
||||
if (!confirm('Eliminare questo contenuto?')) return
|
||||
try { await api.delete(`/content/posts/${postId}`); loadData() } catch {}
|
||||
}
|
||||
const handleSaveEdit = async (postId) => {
|
||||
try {
|
||||
await api.delete(`/content/posts/${postId}`)
|
||||
await api.put(`/content/posts/${postId}`, { text_content: editText })
|
||||
setEditingId(null)
|
||||
loadData()
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const filtered = posts.filter((p) => {
|
||||
const filtered = posts.filter(p => {
|
||||
if (filterCharacter && String(p.character_id) !== filterCharacter) return false
|
||||
if (filterStatus && p.status !== filterStatus) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '—'
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
}
|
||||
const formatDate = (d) => d ? new Date(d).toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' }) : '—'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div style={{ animation: 'fade-up 0.5s ease-out both' }}>
|
||||
<div style={{ marginBottom: '2rem', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-slate-800">Archivio Contenuti</h2>
|
||||
<p className="text-slate-500 mt-1 text-sm">
|
||||
Tutti i contenuti generati
|
||||
</p>
|
||||
<span className="editorial-tag">Archivio</span>
|
||||
<div className="editorial-line" />
|
||||
<h2 style={{ fontFamily: "'Fraunces', serif", fontSize: '1.75rem', fontWeight: 600, letterSpacing: '-0.02em', color: 'var(--ink)', margin: '0.5rem 0 0.3rem' }}>
|
||||
Archivio Contenuti
|
||||
</h2>
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--ink-muted)', margin: 0 }}>Tutti i contenuti generati</p>
|
||||
</div>
|
||||
<Link to="/content" style={{ fontSize: '0.8rem', color: 'var(--accent)', textDecoration: 'none', fontWeight: 600 }}>
|
||||
← Genera nuovo
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-3 mb-6">
|
||||
<select
|
||||
value={filterCharacter}
|
||||
onChange={(e) => setFilterCharacter(e.target.value)}
|
||||
className="px-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm bg-white"
|
||||
>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem', marginBottom: '1.5rem', alignItems: 'center' }}>
|
||||
<select value={filterCharacter} onChange={e => setFilterCharacter(e.target.value)} style={selectStyle}>
|
||||
<option value="">Tutti i personaggi</option>
|
||||
{characters.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
{characters.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="px-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm bg-white"
|
||||
>
|
||||
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)} style={selectStyle}>
|
||||
<option value="">Tutti gli stati</option>
|
||||
{Object.entries(statusLabels).map(([val, label]) => (
|
||||
<option key={val} value={val}>{label}</option>
|
||||
))}
|
||||
{Object.entries(statusLabels).map(([val, label]) => <option key={val} value={val}>{label}</option>)}
|
||||
</select>
|
||||
|
||||
<span className="flex items-center text-xs text-slate-400 ml-auto">
|
||||
<span style={{ fontSize: '0.78rem', color: 'var(--ink-muted)', marginLeft: 'auto' }}>
|
||||
{filtered.length} contenut{filtered.length === 1 ? 'o' : 'i'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-brand-500 border-t-transparent" />
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem 0' }}>
|
||||
<div style={{ width: 32, height: 32, border: '2px solid var(--border)', borderTopColor: 'var(--accent)', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="text-center py-16 bg-white rounded-xl border border-slate-200">
|
||||
<p className="text-4xl mb-3">✦</p>
|
||||
<p className="text-slate-500 font-medium">Nessun contenuto trovato</p>
|
||||
<p className="text-slate-400 text-sm mt-1">
|
||||
{posts.length === 0
|
||||
? 'Genera il tuo primo contenuto dalla pagina Contenuti'
|
||||
: 'Prova a cambiare i filtri'}
|
||||
<div style={{ textAlign: 'center', padding: '4rem 1rem', backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}>
|
||||
<div style={{ fontSize: '2.5rem', color: 'var(--border-strong)', marginBottom: '1rem' }}>✦</div>
|
||||
<p style={{ fontFamily: "'Fraunces', serif", fontSize: '1rem', color: 'var(--ink)', margin: '0 0 0.5rem' }}>Nessun contenuto trovato</p>
|
||||
<p style={{ fontSize: '0.82rem', color: 'var(--ink-muted)', margin: 0 }}>
|
||||
{posts.length === 0 ? 'Genera il tuo primo contenuto dalla pagina Contenuti' : 'Prova a cambiare i filtri'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filtered.map((post) => (
|
||||
<div
|
||||
key={post.id}
|
||||
className="bg-white rounded-xl border border-slate-200 hover:border-brand-300 transition-all overflow-hidden cursor-pointer"
|
||||
onClick={() => setExpandedId(expandedId === post.id ? null : post.id)}
|
||||
>
|
||||
<div className="p-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${statusColors[post.status] || 'bg-slate-100 text-slate-500'}`}>
|
||||
{statusLabels[post.status] || post.status}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-slate-100 text-slate-600">
|
||||
{platformLabels[post.platform] || post.platform}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Character name */}
|
||||
<p className="text-xs font-medium text-slate-500 mb-1">
|
||||
{getCharacterName(post.character_id)}
|
||||
</p>
|
||||
|
||||
{/* Text preview */}
|
||||
<p className={`text-sm text-slate-700 ${expandedId === post.id ? 'whitespace-pre-wrap' : 'line-clamp-3'}`}>
|
||||
{post.text}
|
||||
</p>
|
||||
|
||||
{/* Expanded details */}
|
||||
{expandedId === post.id && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{post.hashtags && post.hashtags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{post.hashtags.map((tag, i) => (
|
||||
<span key={i} className="text-xs px-1.5 py-0.5 bg-brand-50 text-brand-700 rounded">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between mt-3 pt-3 border-t border-slate-100">
|
||||
<span className="text-xs text-slate-400">
|
||||
{formatDate(post.created_at)}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{post.hashtags && (
|
||||
<span className="text-xs text-slate-400">
|
||||
{post.hashtags.length} hashtag
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: '1rem' }}>
|
||||
{filtered.map(post => {
|
||||
const sc = statusColors[post.status] || statusColors.draft
|
||||
const isExpanded = expandedId === post.id
|
||||
const isEditing = editingId === post.id
|
||||
return (
|
||||
<div key={post.id} style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)', borderTop: `3px solid ${sc.color}`, cursor: 'pointer', transition: 'border-color 0.15s' }}
|
||||
onClick={() => { if (!isEditing) setExpandedId(isExpanded ? null : post.id) }}>
|
||||
<div style={{ padding: '1.25rem' }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap', marginBottom: '0.75rem' }}>
|
||||
<span style={{ fontSize: '0.72rem', fontWeight: 700, padding: '0.15rem 0.5rem', backgroundColor: sc.bg, color: sc.color }}>
|
||||
{statusLabels[post.status] || post.status}
|
||||
</span>
|
||||
{post.platform_hint && (
|
||||
<span style={{ fontSize: '0.72rem', fontWeight: 500, padding: '0.15rem 0.5rem', backgroundColor: 'var(--cream-dark)', color: 'var(--ink-muted)' }}>
|
||||
{post.platform_hint}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-slate-100"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{post.status === 'draft' && (
|
||||
<button
|
||||
onClick={() => handleApprove(post.id)}
|
||||
className="text-xs px-3 py-1.5 bg-emerald-50 hover:bg-emerald-100 text-emerald-600 rounded-lg transition-colors font-medium"
|
||||
>
|
||||
Approva
|
||||
</button>
|
||||
<p style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--ink-light)', margin: '0 0 0.5rem' }}>
|
||||
{getCharacterName(post.character_id)}
|
||||
</p>
|
||||
|
||||
{/* Text content */}
|
||||
{isEditing ? (
|
||||
<div onClick={e => e.stopPropagation()}>
|
||||
<textarea value={editText} onChange={e => setEditText(e.target.value)} rows={6}
|
||||
style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.6, fontSize: '0.85rem' }} />
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}>
|
||||
<button onClick={() => handleSaveEdit(post.id)} style={btnPrimary}>Salva</button>
|
||||
<button onClick={() => setEditingId(null)} style={btnSecondary}>Annulla</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--ink)', lineHeight: 1.6, margin: 0, whiteSpace: isExpanded ? 'pre-wrap' : 'normal', overflow: isExpanded ? 'visible' : 'hidden', display: isExpanded ? 'block' : '-webkit-box', WebkitLineClamp: isExpanded ? 'unset' : 3, WebkitBoxOrient: 'vertical' }}>
|
||||
{post.text_content || '(nessun testo)'}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(post.id)}
|
||||
className="text-xs px-3 py-1.5 text-red-500 hover:bg-red-50 rounded-lg transition-colors ml-auto"
|
||||
>
|
||||
Elimina
|
||||
</button>
|
||||
|
||||
{/* Hashtags when expanded */}
|
||||
{isExpanded && !isEditing && post.hashtags?.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem', marginTop: '0.75rem' }}>
|
||||
{post.hashtags.map((tag, i) => (
|
||||
<span key={i} style={{ fontSize: '0.72rem', padding: '0.1rem 0.4rem', backgroundColor: 'var(--accent-light)', color: 'var(--accent)' }}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: '0.75rem', paddingTop: '0.75rem', borderTop: '1px solid var(--border)' }}>
|
||||
<span style={{ fontSize: '0.72rem', color: 'var(--ink-muted)' }}>{formatDate(post.created_at)}</span>
|
||||
{post.hashtags?.length > 0 && (
|
||||
<span style={{ fontSize: '0.72rem', color: 'var(--ink-muted)' }}>{post.hashtags.length} hashtag</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.75rem', paddingTop: '0.75rem', borderTop: '1px solid var(--border)' }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
{post.status === 'draft' && (
|
||||
<button onClick={() => handleApprove(post.id)} style={{ ...btnSmall, backgroundColor: 'var(--success-light)', color: 'var(--success)' }}>Approva</button>
|
||||
)}
|
||||
<button onClick={() => { setEditingId(post.id); setEditText(post.text_content || ''); setExpandedId(post.id) }}
|
||||
style={{ ...btnSmall, backgroundColor: 'var(--cream-dark)', color: 'var(--ink-light)' }}>Modifica</button>
|
||||
<button onClick={() => handleDelete(post.id)}
|
||||
style={{ ...btnSmall, color: 'var(--error)', backgroundColor: 'transparent', marginLeft: 'auto' }}>Elimina</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const selectStyle = {
|
||||
padding: '0.5rem 0.75rem', border: '1px solid var(--border)', fontSize: '0.82rem',
|
||||
fontFamily: "'DM Sans', sans-serif", backgroundColor: 'var(--surface)', outline: 'none', cursor: 'pointer',
|
||||
}
|
||||
const inputStyle = {
|
||||
width: '100%', padding: '0.625rem 0.875rem', border: '1px solid var(--border)',
|
||||
fontSize: '0.875rem', color: 'var(--ink)', backgroundColor: 'var(--surface)',
|
||||
outline: 'none', boxSizing: 'border-box', fontFamily: "'DM Sans', sans-serif",
|
||||
}
|
||||
const btnPrimary = {
|
||||
padding: '0.4rem 0.8rem', backgroundColor: 'var(--ink)', color: 'white',
|
||||
fontFamily: "'DM Sans', sans-serif", fontWeight: 600, fontSize: '0.8rem', border: 'none', cursor: 'pointer',
|
||||
}
|
||||
const btnSecondary = {
|
||||
...btnPrimary, backgroundColor: 'var(--cream-dark)', color: 'var(--ink-light)',
|
||||
}
|
||||
const btnSmall = {
|
||||
padding: '0.3rem 0.6rem', fontSize: '0.75rem', fontWeight: 600, border: 'none',
|
||||
cursor: 'pointer', fontFamily: "'DM Sans', sans-serif",
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import { useAuth } from '../AuthContext'
|
||||
|
||||
const PLATFORMS = [
|
||||
{ value: 'instagram', label: 'Instagram' },
|
||||
@@ -35,6 +36,8 @@ const STATUS_COLORS = {
|
||||
}
|
||||
|
||||
export default function ContentPage() {
|
||||
const { isPro } = useAuth()
|
||||
const availablePlatforms = isPro ? PLATFORMS : PLATFORMS.filter(p => ['instagram', 'facebook'].includes(p.value))
|
||||
const [characters, setCharacters] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [charsLoading, setCharsLoading] = useState(true)
|
||||
@@ -129,9 +132,14 @@ export default function ContentPage() {
|
||||
<h2 style={{ fontFamily: "'Fraunces', serif", fontSize: '1.75rem', fontWeight: 600, letterSpacing: '-0.02em', color: 'var(--ink)', margin: '0.5rem 0 0.3rem' }}>
|
||||
Genera Contenuti
|
||||
</h2>
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--ink-muted)', margin: 0 }}>
|
||||
Definisci un brief editoriale, scegli piattaforma e tipo, poi genera. L'AI terrà conto del tono e dei topic del personaggio selezionato.
|
||||
</p>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem' }}>
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--ink-muted)', margin: 0 }}>
|
||||
Definisci un brief editoriale, scegli piattaforma e tipo, poi genera. L'AI terrà conto del tono e dei topic del personaggio selezionato.
|
||||
</p>
|
||||
<Link to="/content/archive" style={{ fontSize: '0.8rem', color: 'var(--accent)', whiteSpace: 'nowrap', textDecoration: 'none', fontWeight: 600 }}>
|
||||
Archivio →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* No characters → gate */}
|
||||
@@ -224,7 +232,7 @@ export default function ContentPage() {
|
||||
<div>
|
||||
<label style={labelStyle}>Piattaforme <span style={{ fontWeight: 400, textTransform: 'none', letterSpacing: 0, color: 'var(--ink-muted)', fontSize: '0.75rem' }}>(seleziona una o più)</span></label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginTop: '0.4rem' }}>
|
||||
{PLATFORMS.map(p => {
|
||||
{availablePlatforms.map(p => {
|
||||
const active = form.platforms.includes(p.value)
|
||||
return (
|
||||
<button key={p.value} type="button" onClick={() => toggleChip('platforms', p.value)} style={{
|
||||
@@ -321,8 +329,8 @@ export default function ContentPage() {
|
||||
</span>
|
||||
)})()}
|
||||
{generated.platform_hint && (
|
||||
<span style={{ fontSize: '0.72rem', fontWeight: 600, padding: '0.2rem 0.5rem', backgroundColor: 'var(--cream-dark)', color: 'var(--ink-muted)' }}>
|
||||
{generated.platform_hint}
|
||||
<span style={{ fontSize: '0.72rem', fontWeight: 500, padding: '0.2rem 0.5rem', backgroundColor: 'var(--cream-dark)', color: 'var(--ink-muted)', borderLeft: '2px solid var(--border)' }}>
|
||||
per {generated.platform_hint}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -381,11 +389,22 @@ function HashtagEditor({ hashtags, onChange, postId }) {
|
||||
const [newTag, setNewTag] = useState('')
|
||||
const [editIdx, setEditIdx] = useState(null)
|
||||
const [editValue, setEditValue] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const saveTimer = useRef(null)
|
||||
|
||||
const persistHashtags = (tags) => {
|
||||
clearTimeout(saveTimer.current)
|
||||
saveTimer.current = setTimeout(() => {
|
||||
api.put(`/content/posts/${postId}`, { hashtags: tags }).catch(() => {})
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const updateAndSave = (newTags) => {
|
||||
onChange(newTags)
|
||||
persistHashtags(newTags)
|
||||
}
|
||||
|
||||
const removeTag = (idx) => {
|
||||
const updated = hashtags.filter((_, i) => i !== idx)
|
||||
onChange(updated)
|
||||
updateAndSave(hashtags.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
const addTag = () => {
|
||||
@@ -393,7 +412,7 @@ function HashtagEditor({ hashtags, onChange, postId }) {
|
||||
if (!tag) return
|
||||
if (!tag.startsWith('#')) tag = `#${tag}`
|
||||
if (!hashtags.includes(tag)) {
|
||||
onChange([...hashtags, tag])
|
||||
updateAndSave([...hashtags, tag])
|
||||
}
|
||||
setNewTag('')
|
||||
}
|
||||
@@ -410,18 +429,10 @@ function HashtagEditor({ hashtags, onChange, postId }) {
|
||||
if (!tag.startsWith('#')) tag = `#${tag}`
|
||||
const updated = [...hashtags]
|
||||
updated[editIdx] = tag
|
||||
onChange(updated)
|
||||
updateAndSave(updated)
|
||||
setEditIdx(null)
|
||||
}
|
||||
|
||||
const saveHashtags = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.put(`/content/posts/${postId}`, { hashtags })
|
||||
} catch (e) { /* silent */ }
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<span style={{ ...labelStyle, display: 'block', marginBottom: '0.4rem' }}>Hashtag</span>
|
||||
@@ -447,10 +458,6 @@ function HashtagEditor({ hashtags, onChange, postId }) {
|
||||
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
|
||||
<button type="button" onClick={addTag} style={{ fontSize: '0.78rem', padding: '0.3rem 0.6rem', backgroundColor: 'var(--cream-dark)', border: 'none', cursor: 'pointer', color: 'var(--ink-light)' }}>+</button>
|
||||
</div>
|
||||
<button type="button" onClick={saveHashtags} disabled={saving}
|
||||
style={{ fontSize: '0.72rem', marginTop: '0.35rem', padding: '0.2rem 0.5rem', backgroundColor: 'transparent', border: '1px solid var(--border)', cursor: 'pointer', color: 'var(--ink-muted)' }}>
|
||||
{saving ? 'Salvataggio…' : 'Salva hashtag'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user