feat: rich character profiles — brand voice, target, rules, hashtag profiles
Backend: - Character model: add brand_voice, target_audience, business_goals, products_services, content_rules (JSON do/dont), hashtag_profiles (JSON) - Content generation: inject full character context into LLM system prompt (voice, audience, goals, products, rules) - Hashtag generation: merge always-on tags from profile with AI-generated tags - Schema: update CharacterBase and CharacterUpdate with new fields Frontend: - CharacterForm: new sections "Identità e Voce", "Regole Contenuti", "Profili Hashtag" with dedicated editors - RulesEditor: do/don't list with add/remove - HashtagProfileEditor: per-platform tabs, fixed hashtags + max generated count - All fields loaded on edit, saved on submit DB migration: 6 new columns added to characters table Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,13 @@ class Character(Base):
|
|||||||
niche = Column(String(200), nullable=False)
|
niche = Column(String(200), nullable=False)
|
||||||
topics = Column(JSON, default=list)
|
topics = Column(JSON, default=list)
|
||||||
tone = Column(Text)
|
tone = Column(Text)
|
||||||
|
# Rich profile fields
|
||||||
|
brand_voice = Column(Text, nullable=True) # how the character communicates (long description)
|
||||||
|
target_audience = Column(Text, nullable=True) # who reads the content
|
||||||
|
business_goals = Column(Text, nullable=True) # why they create content
|
||||||
|
products_services = Column(Text, nullable=True) # what they offer
|
||||||
|
content_rules = Column(JSON, default=dict) # {"do": [...], "dont": [...]}
|
||||||
|
hashtag_profiles = Column(JSON, default=dict) # per-platform hashtag config
|
||||||
visual_style = Column(JSON, default=dict)
|
visual_style = Column(JSON, default=dict)
|
||||||
social_accounts = Column(JSON, default=dict)
|
social_accounts = Column(JSON, default=dict)
|
||||||
affiliate_links = Column(JSON, default=list)
|
affiliate_links = Column(JSON, default=list)
|
||||||
|
|||||||
@@ -111,6 +111,12 @@ def generate_content(
|
|||||||
"niche": character.niche,
|
"niche": character.niche,
|
||||||
"topics": character.topics or [],
|
"topics": character.topics or [],
|
||||||
"tone": character.tone or "professional",
|
"tone": character.tone or "professional",
|
||||||
|
"brand_voice": character.brand_voice,
|
||||||
|
"target_audience": character.target_audience,
|
||||||
|
"business_goals": character.business_goals,
|
||||||
|
"products_services": character.products_services,
|
||||||
|
"content_rules": character.content_rules or {},
|
||||||
|
"hashtag_profiles": character.hashtag_profiles or {},
|
||||||
}
|
}
|
||||||
|
|
||||||
base_url = _get_setting(db, "llm_base_url", current_user.id)
|
base_url = _get_setting(db, "llm_base_url", current_user.id)
|
||||||
@@ -145,7 +151,19 @@ def generate_content(
|
|||||||
brief=request.brief,
|
brief=request.brief,
|
||||||
)
|
)
|
||||||
|
|
||||||
hashtags = generate_hashtags(text, llm, platform)
|
# Hashtag generation with profile support
|
||||||
|
ht_profile = char_dict.get("hashtag_profiles", {}).get(platform, {})
|
||||||
|
always_tags = ht_profile.get("always", [])
|
||||||
|
max_generated = ht_profile.get("max_generated", 12)
|
||||||
|
hashtags_generated = generate_hashtags(text, llm, platform, count=max_generated)
|
||||||
|
# Merge: always tags first, then generated (no duplicates)
|
||||||
|
seen = set()
|
||||||
|
hashtags = []
|
||||||
|
for tag in always_tags + hashtags_generated:
|
||||||
|
normalized = tag.lower()
|
||||||
|
if normalized not in seen:
|
||||||
|
seen.add(normalized)
|
||||||
|
hashtags.append(tag)
|
||||||
|
|
||||||
affiliate_links_used: list[dict] = []
|
affiliate_links_used: list[dict] = []
|
||||||
if affiliate_link_dicts:
|
if affiliate_link_dicts:
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ class CharacterBase(BaseModel):
|
|||||||
niche: str
|
niche: str
|
||||||
topics: list[str] = []
|
topics: list[str] = []
|
||||||
tone: Optional[str] = None
|
tone: Optional[str] = None
|
||||||
|
brand_voice: Optional[str] = None
|
||||||
|
target_audience: Optional[str] = None
|
||||||
|
business_goals: Optional[str] = None
|
||||||
|
products_services: Optional[str] = None
|
||||||
|
content_rules: dict = {} # {"do": [...], "dont": [...]}
|
||||||
|
hashtag_profiles: dict = {} # per-platform: {"instagram": {"always": [], "pool": [], ...}}
|
||||||
visual_style: dict = {}
|
visual_style: dict = {}
|
||||||
social_accounts: dict = {}
|
social_accounts: dict = {}
|
||||||
affiliate_links: list[dict] = []
|
affiliate_links: list[dict] = []
|
||||||
@@ -40,6 +46,12 @@ class CharacterUpdate(BaseModel):
|
|||||||
niche: Optional[str] = None
|
niche: Optional[str] = None
|
||||||
topics: Optional[list[str]] = None
|
topics: Optional[list[str]] = None
|
||||||
tone: Optional[str] = None
|
tone: Optional[str] = None
|
||||||
|
brand_voice: Optional[str] = None
|
||||||
|
target_audience: Optional[str] = None
|
||||||
|
business_goals: Optional[str] = None
|
||||||
|
products_services: Optional[str] = None
|
||||||
|
content_rules: Optional[dict] = None
|
||||||
|
hashtag_profiles: Optional[dict] = None
|
||||||
visual_style: Optional[dict] = None
|
visual_style: Optional[dict] = None
|
||||||
social_accounts: Optional[dict] = None
|
social_accounts: Optional[dict] = None
|
||||||
affiliate_links: Optional[list[dict]] = None
|
affiliate_links: Optional[list[dict]] = None
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ def generate_post_text(
|
|||||||
"""Generate social media post text based on a character profile.
|
"""Generate social media post text based on a character profile.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
character: Dict with keys: name, niche, topics (list), tone (str).
|
character: Dict with keys: name, niche, topics (list), tone (str),
|
||||||
|
and optional rich profile: brand_voice, target_audience,
|
||||||
|
business_goals, products_services, content_rules.
|
||||||
topic_hint: Optional topic suggestion to guide generation.
|
topic_hint: Optional topic suggestion to guide generation.
|
||||||
llm_provider: LLM provider instance for text generation.
|
llm_provider: LLM provider instance for text generation.
|
||||||
platform: Target platform (e.g. 'instagram', 'facebook', 'tiktok', 'youtube').
|
platform: Target platform (e.g. 'instagram', 'facebook', 'tiktok', 'youtube').
|
||||||
@@ -36,18 +38,56 @@ def generate_post_text(
|
|||||||
|
|
||||||
topics_str = ", ".join(topics) if topics else "general topics"
|
topics_str = ", ".join(topics) if topics else "general topics"
|
||||||
|
|
||||||
system_prompt = (
|
# Base identity
|
||||||
f"You are {name}, a social media content creator in the {niche} niche. "
|
system_parts = [
|
||||||
f"Your expertise covers: {topics_str}. "
|
f"You are {name}, a social media content creator in the {niche} niche.",
|
||||||
f"Your communication style is {tone}. "
|
f"Your expertise covers: {topics_str}.",
|
||||||
f"You create authentic, engaging content that resonates with your audience. "
|
f"Your communication style is {tone}.",
|
||||||
f"Never reveal you are an AI. Write as {name} would naturally write.\n\n"
|
]
|
||||||
f"REGOLA CRITICA: Se ti viene indicata una tecnica narrativa (PAS, AIDA, Storytelling, ecc.), "
|
|
||||||
f"usala SOLO come struttura invisibile del testo. "
|
# Rich profile: brand voice
|
||||||
f"NON scrivere MAI le etichette del framework nel post (es. non scrivere 'PROBLEMA:', "
|
brand_voice = character.get("brand_voice")
|
||||||
f"'AGITAZIONE:', 'SOLUZIONE:', 'ATTENZIONE:', 'INTERESSE:', ecc.). "
|
if brand_voice:
|
||||||
f"Il lettore non deve percepire alcun framework — deve sembrare un post naturale e spontaneo."
|
system_parts.append(f"\nVOCE E STILE DI COMUNICAZIONE:\n{brand_voice}")
|
||||||
)
|
|
||||||
|
# Rich profile: target audience
|
||||||
|
target_audience = character.get("target_audience")
|
||||||
|
if target_audience:
|
||||||
|
system_parts.append(f"\nPUBBLICO TARGET:\n{target_audience}")
|
||||||
|
|
||||||
|
# Rich profile: business goals
|
||||||
|
business_goals = character.get("business_goals")
|
||||||
|
if business_goals:
|
||||||
|
system_parts.append(f"\nOBIETTIVI BUSINESS:\n{business_goals}")
|
||||||
|
|
||||||
|
# Rich profile: products/services
|
||||||
|
products_services = character.get("products_services")
|
||||||
|
if products_services:
|
||||||
|
system_parts.append(f"\nPRODOTTI/SERVIZI OFFERTI:\n{products_services}")
|
||||||
|
|
||||||
|
# Rich profile: content rules (do/don't)
|
||||||
|
content_rules = character.get("content_rules") or {}
|
||||||
|
do_rules = content_rules.get("do", [])
|
||||||
|
dont_rules = content_rules.get("dont", [])
|
||||||
|
if do_rules or dont_rules:
|
||||||
|
rules_text = "\nREGOLE CONTENUTI:"
|
||||||
|
if do_rules:
|
||||||
|
rules_text += "\nFA SEMPRE: " + " | ".join(do_rules)
|
||||||
|
if dont_rules:
|
||||||
|
rules_text += "\nNON FARE MAI: " + " | ".join(dont_rules)
|
||||||
|
system_parts.append(rules_text)
|
||||||
|
|
||||||
|
system_parts.extend([
|
||||||
|
"\nYou create authentic, engaging content that resonates with your audience.",
|
||||||
|
"Never reveal you are an AI. Write as {name} would naturally write.",
|
||||||
|
"\nREGOLA CRITICA: Se ti viene indicata una tecnica narrativa (PAS, AIDA, Storytelling, ecc.), "
|
||||||
|
"usala SOLO come struttura invisibile del testo. "
|
||||||
|
"NON scrivere MAI le etichette del framework nel post (es. non scrivere 'PROBLEMA:', "
|
||||||
|
"'AGITAZIONE:', 'SOLUZIONE:', 'ATTENZIONE:', 'INTERESSE:', ecc.). "
|
||||||
|
"Il lettore non deve percepire alcun framework — deve sembrare un post naturale e spontaneo.",
|
||||||
|
])
|
||||||
|
|
||||||
|
system_prompt = "\n".join(system_parts)
|
||||||
|
|
||||||
# Platform-specific instructions
|
# Platform-specific instructions
|
||||||
platform_guidance = {
|
platform_guidance = {
|
||||||
|
|||||||
@@ -7,10 +7,18 @@ const EMPTY_FORM = {
|
|||||||
niche: '',
|
niche: '',
|
||||||
topics: [],
|
topics: [],
|
||||||
tone: '',
|
tone: '',
|
||||||
|
brand_voice: '',
|
||||||
|
target_audience: '',
|
||||||
|
business_goals: '',
|
||||||
|
products_services: '',
|
||||||
|
content_rules: { do: [], dont: [] },
|
||||||
|
hashtag_profiles: {},
|
||||||
visual_style: { primary_color: '#E85A4F', secondary_color: '#1A1A1A', font: '' },
|
visual_style: { primary_color: '#E85A4F', secondary_color: '#1A1A1A', font: '' },
|
||||||
is_active: true,
|
is_active: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HASHTAG_PLATFORMS = ['instagram', 'facebook', 'youtube', 'tiktok']
|
||||||
|
|
||||||
const NICHE_CHIPS = [
|
const NICHE_CHIPS = [
|
||||||
'Food & Ricette', 'Fitness & Sport', 'Tech & AI', 'Beauty & Skincare',
|
'Food & Ricette', 'Fitness & Sport', 'Tech & AI', 'Beauty & Skincare',
|
||||||
'Fashion & Style', 'Travel & Lifestyle', 'Finance & Investimenti', 'Salute & Wellness',
|
'Fashion & Style', 'Travel & Lifestyle', 'Finance & Investimenti', 'Salute & Wellness',
|
||||||
@@ -45,6 +53,12 @@ export default function CharacterForm() {
|
|||||||
niche: data.niche || '',
|
niche: data.niche || '',
|
||||||
topics: data.topics || [],
|
topics: data.topics || [],
|
||||||
tone: data.tone || '',
|
tone: data.tone || '',
|
||||||
|
brand_voice: data.brand_voice || '',
|
||||||
|
target_audience: data.target_audience || '',
|
||||||
|
business_goals: data.business_goals || '',
|
||||||
|
products_services: data.products_services || '',
|
||||||
|
content_rules: data.content_rules || { do: [], dont: [] },
|
||||||
|
hashtag_profiles: data.hashtag_profiles || {},
|
||||||
visual_style: {
|
visual_style: {
|
||||||
primary_color: data.visual_style?.primary_color || '#E85A4F',
|
primary_color: data.visual_style?.primary_color || '#E85A4F',
|
||||||
secondary_color: data.visual_style?.secondary_color || '#1A1A1A',
|
secondary_color: data.visual_style?.secondary_color || '#1A1A1A',
|
||||||
@@ -222,6 +236,64 @@ export default function CharacterForm() {
|
|||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{/* ── Identità e Voce ──────────────────────────────────── */}
|
||||||
|
<Section title="Identità e Voce">
|
||||||
|
<p style={{ fontSize: '0.82rem', color: 'var(--ink-muted)', margin: '0 0 0.5rem', lineHeight: 1.5 }}>
|
||||||
|
Queste informazioni vengono usate automaticamente dall'AI ogni volta che genera contenuti. Compilale una volta sola con cura.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Field label="Brand Voice — Come comunica questo personaggio">
|
||||||
|
<textarea value={form.brand_voice} onChange={e => handleChange('brand_voice', e.target.value)}
|
||||||
|
placeholder="Descrivi il modo in cui parla: diretto e provocatorio, usa metafore pratiche, mai aziendalese. Parla come al bar con un amico imprenditore. Usa il tu, frasi brevi, battute secche."
|
||||||
|
rows={4} style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.6 }}
|
||||||
|
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Pubblico Target — A chi si rivolge">
|
||||||
|
<textarea value={form.target_audience} onChange={e => handleChange('target_audience', e.target.value)}
|
||||||
|
placeholder="Es. Freelance IT italiani 30-45 anni che sanno di dover automatizzare ma procrastinano. Imprenditori con budget limitato che vogliono risultati concreti."
|
||||||
|
rows={3} style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.6 }}
|
||||||
|
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Obiettivi Business — Perché crea contenuti">
|
||||||
|
<textarea value={form.business_goals} onChange={e => handleChange('business_goals', e.target.value)}
|
||||||
|
placeholder="Es. Vendere consulenze AI automation a 2.500€, posizionarsi come esperto nel settore, costruire una community di freelance tech."
|
||||||
|
rows={3} style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.6 }}
|
||||||
|
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Prodotti / Servizi offerti">
|
||||||
|
<textarea value={form.products_services} onChange={e => handleChange('products_services', e.target.value)}
|
||||||
|
placeholder="Es. Consulenza AI automation (2.500€), Corso online 'AI per Freelance' (297€), Audit gratuito di 30 min. Link: micheleborraccia.it/consulenza"
|
||||||
|
rows={3} style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.6 }}
|
||||||
|
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
|
||||||
|
</Field>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ── Regole Contenuti ────────────────────────────────── */}
|
||||||
|
<Section title="Regole Contenuti">
|
||||||
|
<p style={{ fontSize: '0.82rem', color: 'var(--ink-muted)', margin: '0 0 0.5rem', lineHeight: 1.5 }}>
|
||||||
|
Istruzioni vincolanti: l'AI le seguirà sempre. Compila per evitare di ripeterti ad ogni generazione.
|
||||||
|
</p>
|
||||||
|
<RulesEditor
|
||||||
|
doRules={form.content_rules?.do || []}
|
||||||
|
dontRules={form.content_rules?.dont || []}
|
||||||
|
onChange={(doR, dontR) => handleChange('content_rules', { do: doR, dont: dontR })}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ── Hashtag Profiles ────────────────────────────────── */}
|
||||||
|
<Section title="Profili Hashtag">
|
||||||
|
<p style={{ fontSize: '0.82rem', color: 'var(--ink-muted)', margin: '0 0 0.5rem', lineHeight: 1.5 }}>
|
||||||
|
Hashtag fissi per piattaforma: verranno sempre inclusi nei post generati. L'AI ne aggiungerà altri variabili.
|
||||||
|
</p>
|
||||||
|
<HashtagProfileEditor
|
||||||
|
profiles={form.hashtag_profiles || {}}
|
||||||
|
onChange={p => handleChange('hashtag_profiles', p)}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
{/* ── Stile visivo ──────────────────────────────────────── */}
|
{/* ── Stile visivo ──────────────────────────────────────── */}
|
||||||
<Section title="Stile visivo">
|
<Section title="Stile visivo">
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||||
@@ -311,6 +383,133 @@ function Field({ label, children }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RulesEditor({ doRules, dontRules, onChange }) {
|
||||||
|
const [doInput, setDoInput] = useState('')
|
||||||
|
const [dontInput, setDontInput] = useState('')
|
||||||
|
|
||||||
|
const addDo = () => { const v = doInput.trim(); if (v && !doRules.includes(v)) { onChange([...doRules, v], dontRules) }; setDoInput('') }
|
||||||
|
const addDont = () => { const v = dontInput.trim(); if (v && !dontRules.includes(v)) { onChange(doRules, [...dontRules, v]) }; setDontInput('') }
|
||||||
|
const removeDo = (i) => onChange(doRules.filter((_, idx) => idx !== i), dontRules)
|
||||||
|
const removeDont = (i) => onChange(doRules, dontRules.filter((_, idx) => idx !== i))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||||
|
<div>
|
||||||
|
<label style={miniLabelStyle}>FA SEMPRE</label>
|
||||||
|
<div style={{ display: 'flex', gap: '0.35rem', marginBottom: '0.5rem' }}>
|
||||||
|
<input type="text" value={doInput} onChange={e => setDoInput(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addDo() } }}
|
||||||
|
placeholder="Es. Usa sempre il tu" style={{ ...inputStyle, flex: 1, fontSize: '0.82rem' }}
|
||||||
|
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
|
||||||
|
<button type="button" onClick={addDo} style={{ ...btnSmall, backgroundColor: 'var(--success-light)', color: 'var(--success)' }}>+</button>
|
||||||
|
</div>
|
||||||
|
{doRules.map((r, i) => (
|
||||||
|
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.8rem', padding: '0.25rem 0', color: 'var(--success)' }}>
|
||||||
|
<span style={{ flexShrink: 0 }}>✓</span>
|
||||||
|
<span style={{ flex: 1, color: 'var(--ink)' }}>{r}</span>
|
||||||
|
<button type="button" onClick={() => removeDo(i)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--ink-muted)', fontSize: '0.9rem', padding: 0 }}>×</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={miniLabelStyle}>NON FARE MAI</label>
|
||||||
|
<div style={{ display: 'flex', gap: '0.35rem', marginBottom: '0.5rem' }}>
|
||||||
|
<input type="text" value={dontInput} onChange={e => setDontInput(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addDont() } }}
|
||||||
|
placeholder="Es. Non usare 'rivoluzionario'" style={{ ...inputStyle, flex: 1, fontSize: '0.82rem' }}
|
||||||
|
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
|
||||||
|
<button type="button" onClick={addDont} style={{ ...btnSmall, backgroundColor: 'var(--error-light)', color: 'var(--error)' }}>+</button>
|
||||||
|
</div>
|
||||||
|
{dontRules.map((r, i) => (
|
||||||
|
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.8rem', padding: '0.25rem 0', color: 'var(--error)' }}>
|
||||||
|
<span style={{ flexShrink: 0 }}>✗</span>
|
||||||
|
<span style={{ flex: 1, color: 'var(--ink)' }}>{r}</span>
|
||||||
|
<button type="button" onClick={() => removeDont(i)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--ink-muted)', fontSize: '0.9rem', padding: 0 }}>×</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HashtagProfileEditor({ profiles, onChange }) {
|
||||||
|
const [activeTab, setActiveTab] = useState('instagram')
|
||||||
|
const [tagInput, setTagInput] = useState('')
|
||||||
|
|
||||||
|
const getProfile = (platform) => profiles[platform] || { always: [], max_generated: 12 }
|
||||||
|
const setProfile = (platform, profile) => onChange({ ...profiles, [platform]: profile })
|
||||||
|
|
||||||
|
const addTag = (platform) => {
|
||||||
|
let tag = tagInput.trim()
|
||||||
|
if (!tag) return
|
||||||
|
if (!tag.startsWith('#')) tag = `#${tag}`
|
||||||
|
const p = getProfile(platform)
|
||||||
|
if (!p.always.includes(tag)) {
|
||||||
|
setProfile(platform, { ...p, always: [...p.always, tag] })
|
||||||
|
}
|
||||||
|
setTagInput('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeTag = (platform, idx) => {
|
||||||
|
const p = getProfile(platform)
|
||||||
|
setProfile(platform, { ...p, always: p.always.filter((_, i) => i !== idx) })
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = getProfile(activeTab)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', gap: '0', borderBottom: '2px solid var(--border)', marginBottom: '1rem' }}>
|
||||||
|
{HASHTAG_PLATFORMS.map(p => (
|
||||||
|
<button key={p} type="button" onClick={() => { setActiveTab(p); setTagInput('') }} style={{
|
||||||
|
padding: '0.4rem 0.85rem', fontSize: '0.78rem', fontWeight: activeTab === p ? 700 : 400,
|
||||||
|
fontFamily: "'DM Sans', sans-serif", border: 'none', cursor: 'pointer',
|
||||||
|
backgroundColor: activeTab === p ? 'var(--surface)' : 'transparent',
|
||||||
|
color: activeTab === p ? 'var(--ink)' : 'var(--ink-muted)',
|
||||||
|
borderBottom: activeTab === p ? '2px solid var(--accent)' : '2px solid transparent',
|
||||||
|
marginBottom: '-2px', textTransform: 'capitalize',
|
||||||
|
}}>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.35rem', marginBottom: '0.5rem' }}>
|
||||||
|
<input type="text" value={tagInput} onChange={e => setTagInput(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addTag(activeTab) } }}
|
||||||
|
placeholder={`Hashtag fisso per ${activeTab}…`} style={{ ...inputStyle, flex: 1, fontSize: '0.82rem' }}
|
||||||
|
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
|
||||||
|
<button type="button" onClick={() => addTag(activeTab)} style={btnSmall}>+</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{profile.always.length > 0 ? (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
|
||||||
|
{profile.always.map((tag, i) => (
|
||||||
|
<span key={i} style={{ fontSize: '0.78rem', padding: '0.15rem 0.5rem', backgroundColor: 'var(--accent-light)', color: 'var(--accent)', display: 'inline-flex', alignItems: 'center', gap: '0.3rem' }}>
|
||||||
|
{tag}
|
||||||
|
<button type="button" onClick={() => removeTag(activeTab, i)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--accent)', fontSize: '0.7rem', padding: 0, lineHeight: 1, opacity: 0.7 }}>✕</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p style={{ fontSize: '0.78rem', color: 'var(--ink-muted)', margin: '0.25rem 0 0', fontStyle: 'italic' }}>
|
||||||
|
Nessun hashtag fisso per {activeTab}. L'AI genererà tutti gli hashtag automaticamente.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: '0.75rem' }}>
|
||||||
|
<label style={miniLabelStyle}>Hashtag generati dall'AI (max)</label>
|
||||||
|
<input type="number" min={0} max={30} value={profile.max_generated ?? 12}
|
||||||
|
onChange={e => setProfile(activeTab, { ...profile, max_generated: parseInt(e.target.value) || 0 })}
|
||||||
|
style={{ ...inputStyle, width: 80, fontSize: '0.82rem' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const miniLabelStyle = { display: 'block', fontSize: '0.68rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink-muted)', marginBottom: '0.35rem' }
|
||||||
|
const btnSmall = { padding: '0.35rem 0.65rem', fontSize: '0.8rem', fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: "'DM Sans', sans-serif", backgroundColor: 'var(--cream-dark)', color: 'var(--ink-light)' }
|
||||||
|
|
||||||
const inputStyle = {
|
const inputStyle = {
|
||||||
width: '100%', padding: '0.625rem 0.875rem',
|
width: '100%', padding: '0.625rem 0.875rem',
|
||||||
border: '1px solid var(--border)', borderRadius: 0,
|
border: '1px solid var(--border)', borderRadius: 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user