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)
|
||||
topics = Column(JSON, default=list)
|
||||
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)
|
||||
social_accounts = Column(JSON, default=dict)
|
||||
affiliate_links = Column(JSON, default=list)
|
||||
|
||||
@@ -111,6 +111,12 @@ def generate_content(
|
||||
"niche": character.niche,
|
||||
"topics": character.topics or [],
|
||||
"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)
|
||||
@@ -145,7 +151,19 @@ def generate_content(
|
||||
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] = []
|
||||
if affiliate_link_dicts:
|
||||
|
||||
@@ -24,6 +24,12 @@ class CharacterBase(BaseModel):
|
||||
niche: str
|
||||
topics: list[str] = []
|
||||
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 = {}
|
||||
social_accounts: dict = {}
|
||||
affiliate_links: list[dict] = []
|
||||
@@ -40,6 +46,12 @@ class CharacterUpdate(BaseModel):
|
||||
niche: Optional[str] = None
|
||||
topics: Optional[list[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
|
||||
social_accounts: Optional[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.
|
||||
|
||||
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.
|
||||
llm_provider: LLM provider instance for text generation.
|
||||
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"
|
||||
|
||||
system_prompt = (
|
||||
f"You are {name}, a social media content creator in the {niche} niche. "
|
||||
f"Your expertise covers: {topics_str}. "
|
||||
f"Your communication style is {tone}. "
|
||||
f"You create authentic, engaging content that resonates with your audience. "
|
||||
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. "
|
||||
f"NON scrivere MAI le etichette del framework nel post (es. non scrivere 'PROBLEMA:', "
|
||||
f"'AGITAZIONE:', 'SOLUZIONE:', 'ATTENZIONE:', 'INTERESSE:', ecc.). "
|
||||
f"Il lettore non deve percepire alcun framework — deve sembrare un post naturale e spontaneo."
|
||||
)
|
||||
# Base identity
|
||||
system_parts = [
|
||||
f"You are {name}, a social media content creator in the {niche} niche.",
|
||||
f"Your expertise covers: {topics_str}.",
|
||||
f"Your communication style is {tone}.",
|
||||
]
|
||||
|
||||
# Rich profile: brand voice
|
||||
brand_voice = character.get("brand_voice")
|
||||
if brand_voice:
|
||||
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_guidance = {
|
||||
|
||||
@@ -7,10 +7,18 @@ const EMPTY_FORM = {
|
||||
niche: '',
|
||||
topics: [],
|
||||
tone: '',
|
||||
brand_voice: '',
|
||||
target_audience: '',
|
||||
business_goals: '',
|
||||
products_services: '',
|
||||
content_rules: { do: [], dont: [] },
|
||||
hashtag_profiles: {},
|
||||
visual_style: { primary_color: '#E85A4F', secondary_color: '#1A1A1A', font: '' },
|
||||
is_active: true,
|
||||
}
|
||||
|
||||
const HASHTAG_PLATFORMS = ['instagram', 'facebook', 'youtube', 'tiktok']
|
||||
|
||||
const NICHE_CHIPS = [
|
||||
'Food & Ricette', 'Fitness & Sport', 'Tech & AI', 'Beauty & Skincare',
|
||||
'Fashion & Style', 'Travel & Lifestyle', 'Finance & Investimenti', 'Salute & Wellness',
|
||||
@@ -45,6 +53,12 @@ export default function CharacterForm() {
|
||||
niche: data.niche || '',
|
||||
topics: data.topics || [],
|
||||
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: {
|
||||
primary_color: data.visual_style?.primary_color || '#E85A4F',
|
||||
secondary_color: data.visual_style?.secondary_color || '#1A1A1A',
|
||||
@@ -222,6 +236,64 @@ export default function CharacterForm() {
|
||||
)}
|
||||
</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 ──────────────────────────────────────── */}
|
||||
<Section title="Stile visivo">
|
||||
<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 = {
|
||||
width: '100%', padding: '0.625rem 0.875rem',
|
||||
border: '1px solid var(--border)', borderRadius: 0,
|
||||
|
||||
Reference in New Issue
Block a user