feat: mobile UX fixes + Phase C one-click generation
Mobile UX: - index.css: comprehensive mobile media queries — headings scale down, touch targets enforced, grid-2col-mobile collapse class, tablet breakpoint - ContentArchive/ContentPage: grid minmax uses min(100%, Npx) to prevent overflow on small screens - CharacterForm: visual style + rules editor grids collapse on mobile - Dashboard: stat cards grid mobile-safe - Layout: better nav touch targets, footer responsive gap Phase C — One-Click Generation: - Backend: GET /api/content/suggestions endpoint — LLM generates 3 topic ideas based on character profile and avoids repeating recent posts - Dashboard: "Suggerimenti per oggi" section loads suggestions on mount, each card links to /content with prefilled topic + character - ContentPage: reads ?topic= and ?character= URL params, auto-fills form and auto-triggers generation (one-click flow from Dashboard) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -340,3 +340,78 @@ def approve_post(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(post)
|
db.refresh(post)
|
||||||
return post
|
return post
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/suggestions")
|
||||||
|
def get_topic_suggestions(
|
||||||
|
character_id: int | None = Query(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Suggest 3 content topics based on character profile and recent posts."""
|
||||||
|
if character_id:
|
||||||
|
character = (
|
||||||
|
db.query(Character)
|
||||||
|
.filter(Character.id == character_id, Character.user_id == current_user.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
character = (
|
||||||
|
db.query(Character)
|
||||||
|
.filter(Character.user_id == current_user.id, Character.is_active == True)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not character:
|
||||||
|
return {"suggestions": [], "character_id": None}
|
||||||
|
|
||||||
|
provider_name = _get_setting(db, "llm_provider", current_user.id)
|
||||||
|
api_key = _get_setting(db, "llm_api_key", current_user.id)
|
||||||
|
model = _get_setting(db, "llm_model", current_user.id)
|
||||||
|
|
||||||
|
if not provider_name or not api_key:
|
||||||
|
return {"suggestions": [], "character_id": character.id, "needs_setup": True}
|
||||||
|
|
||||||
|
recent_posts = (
|
||||||
|
db.query(Post)
|
||||||
|
.filter(Post.character_id == character.id)
|
||||||
|
.order_by(Post.created_at.desc())
|
||||||
|
.limit(5)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
recent_topics = [p.text_content[:100] for p in recent_posts if p.text_content]
|
||||||
|
recent_str = "\n".join(f"- {t}" for t in recent_topics) if recent_topics else "Nessun post recente."
|
||||||
|
|
||||||
|
base_url = _get_setting(db, "llm_base_url", current_user.id)
|
||||||
|
llm = get_llm_provider(provider_name, api_key, model, base_url=base_url)
|
||||||
|
|
||||||
|
topics = character.topics or []
|
||||||
|
niche = character.niche or "general"
|
||||||
|
target = character.target_audience or ""
|
||||||
|
|
||||||
|
system_prompt = (
|
||||||
|
"Sei un social media strategist esperto. "
|
||||||
|
"Suggerisci 3 idee per post social, ciascuna su una riga. "
|
||||||
|
"Ogni idea deve essere una frase breve (max 15 parole) che descrive il topic. "
|
||||||
|
"Non numerare, non aggiungere spiegazioni. Solo 3 righe, una per idea."
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
f"Personaggio: {character.name}, nicchia: {niche}\n"
|
||||||
|
f"Topic abituali: {', '.join(topics) if topics else 'generici'}\n"
|
||||||
|
f"Target: {target}\n"
|
||||||
|
f"Post recenti (evita ripetizioni):\n{recent_str}\n\n"
|
||||||
|
f"Suggerisci 3 idee per post nuovi e diversi dai recenti:"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = llm.generate(prompt, system=system_prompt)
|
||||||
|
lines = [line.strip() for line in result.strip().splitlines() if line.strip()]
|
||||||
|
suggestions = lines[:3]
|
||||||
|
except Exception:
|
||||||
|
suggestions = []
|
||||||
|
|
||||||
|
return {
|
||||||
|
"suggestions": suggestions,
|
||||||
|
"character_id": character.id,
|
||||||
|
"character_name": character.name,
|
||||||
|
}
|
||||||
|
|||||||
@@ -299,7 +299,7 @@ export default function CharacterForm() {
|
|||||||
|
|
||||||
{/* ── Stile visivo ──────────────────────────────────────── */}
|
{/* ── Stile visivo ──────────────────────────────────────── */}
|
||||||
<Section title="Stile visivo">
|
<Section title="Stile visivo">
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
<div className="grid-2col-mobile" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||||
<Field label="Colore primario">
|
<Field label="Colore primario">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
|
||||||
<input type="color" value={form.visual_style.primary_color} onChange={(e) => handleStyleChange('primary_color', e.target.value)}
|
<input type="color" value={form.visual_style.primary_color} onChange={(e) => handleStyleChange('primary_color', e.target.value)}
|
||||||
@@ -396,7 +396,7 @@ function RulesEditor({ doRules, dontRules, onChange }) {
|
|||||||
const removeDont = (i) => onChange(doRules, dontRules.filter((_, idx) => idx !== i))
|
const removeDont = (i) => onChange(doRules, dontRules.filter((_, idx) => idx !== i))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
<div className="grid-2col-mobile" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||||
<div>
|
<div>
|
||||||
<label style={miniLabelStyle}>FA SEMPRE</label>
|
<label style={miniLabelStyle}>FA SEMPRE</label>
|
||||||
<div style={{ display: 'flex', gap: '0.35rem', marginBottom: '0.5rem' }}>
|
<div style={{ display: 'flex', gap: '0.35rem', marginBottom: '0.5rem' }}>
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export default function ContentArchive() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(380px, 1fr))', gap: '1rem' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(min(100%, 380px), 1fr))', gap: '1rem' }}>
|
||||||
{groups.map(group => {
|
{groups.map(group => {
|
||||||
const activeIdx = activePlatform[group.key] || 0
|
const activeIdx = activePlatform[group.key] || 0
|
||||||
const activePost = group.posts[activeIdx] || group.posts[0]
|
const activePost = group.posts[activeIdx] || group.posts[0]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
import { useAuth } from '../AuthContext'
|
import { useAuth } from '../AuthContext'
|
||||||
import ConfirmModal from './ConfirmModal'
|
import ConfirmModal from './ConfirmModal'
|
||||||
@@ -38,7 +38,9 @@ const STATUS_COLORS = {
|
|||||||
|
|
||||||
export default function ContentPage() {
|
export default function ContentPage() {
|
||||||
const { isPro } = useAuth()
|
const { isPro } = useAuth()
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const availablePlatforms = isPro ? PLATFORMS : PLATFORMS.filter(p => ['instagram', 'facebook'].includes(p.value))
|
const availablePlatforms = isPro ? PLATFORMS : PLATFORMS.filter(p => ['instagram', 'facebook'].includes(p.value))
|
||||||
|
const autoGenerateRef = useRef(false)
|
||||||
const [characters, setCharacters] = useState([])
|
const [characters, setCharacters] = useState([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [charsLoading, setCharsLoading] = useState(true)
|
const [charsLoading, setCharsLoading] = useState(true)
|
||||||
@@ -60,9 +62,32 @@ export default function ContentPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get('/characters/').then(d => { setCharacters(d); setCharsLoading(false) }).catch(() => setCharsLoading(false))
|
api.get('/characters/').then(d => {
|
||||||
|
setCharacters(d)
|
||||||
|
setCharsLoading(false)
|
||||||
|
// One-click flow: pre-fill from URL params
|
||||||
|
const urlTopic = searchParams.get('topic')
|
||||||
|
const urlCharacter = searchParams.get('character')
|
||||||
|
if (urlTopic && d.length > 0) {
|
||||||
|
const charId = urlCharacter || String(d[0].id)
|
||||||
|
setForm(prev => ({ ...prev, character_id: charId, topic_hint: urlTopic }))
|
||||||
|
autoGenerateRef.current = true
|
||||||
|
setSearchParams({}, { replace: true }) // clean URL
|
||||||
|
}
|
||||||
|
}).catch(() => setCharsLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Auto-generate when arriving from one-click flow
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoGenerateRef.current && form.character_id && !charsLoading) {
|
||||||
|
autoGenerateRef.current = false
|
||||||
|
// Small delay to let React render the form
|
||||||
|
setTimeout(() => {
|
||||||
|
document.querySelector('form')?.requestSubmit()
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
}, [form.character_id, charsLoading])
|
||||||
|
|
||||||
const toggleChip = (field, value) => {
|
const toggleChip = (field, value) => {
|
||||||
setForm(prev => {
|
setForm(prev => {
|
||||||
const arr = prev[field]
|
const arr = prev[field]
|
||||||
@@ -196,7 +221,7 @@ export default function ContentPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: '1.25rem' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 300px), 1fr))', gap: '1.25rem' }}>
|
||||||
{/* Generation form */}
|
{/* Generation form */}
|
||||||
<div style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)', borderTop: '4px solid var(--accent)', padding: '1.5rem' }}>
|
<div style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)', borderTop: '4px solid var(--accent)', padding: '1.5rem' }}>
|
||||||
<div style={{ marginBottom: '1.25rem' }}>
|
<div style={{ marginBottom: '1.25rem' }}>
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export default function Dashboard() {
|
|||||||
const [recentPosts, setRecentPosts] = useState([])
|
const [recentPosts, setRecentPosts] = useState([])
|
||||||
const [providerStatus, setProviderStatus] = useState(null)
|
const [providerStatus, setProviderStatus] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [suggestions, setSuggestions] = useState(null)
|
||||||
|
const [suggestionsLoading, setSuggestionsLoading] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -36,6 +38,14 @@ export default function Dashboard() {
|
|||||||
setRecentPosts(posts.slice(0, 5))
|
setRecentPosts(posts.slice(0, 5))
|
||||||
setProviderStatus(providers)
|
setProviderStatus(providers)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
// Load suggestions if LLM is configured
|
||||||
|
if (providers?.llm?.configured && chars.length > 0) {
|
||||||
|
setSuggestionsLoading(true)
|
||||||
|
api.get('/content/suggestions')
|
||||||
|
.then(data => setSuggestions(data))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setSuggestionsLoading(false))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -87,7 +97,7 @@ export default function Dashboard() {
|
|||||||
{/* ── Stats grid ──────────────────────────────────────────── */}
|
{/* ── Stats grid ──────────────────────────────────────────── */}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
|
gridTemplateColumns: 'repeat(auto-fill, minmax(min(100%, 160px), 1fr))',
|
||||||
gap: '1rem',
|
gap: '1rem',
|
||||||
marginBottom: '2rem',
|
marginBottom: '2rem',
|
||||||
}}>
|
}}>
|
||||||
@@ -133,6 +143,43 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Topic Suggestions (Phase C) ─────────────────────────── */}
|
||||||
|
{(suggestionsLoading || (suggestions?.suggestions?.length > 0)) && (
|
||||||
|
<div style={{ marginBottom: '2rem' }}>
|
||||||
|
<span className="editorial-tag" style={{ marginBottom: '0.75rem', display: 'block' }}>Suggerimenti per oggi</span>
|
||||||
|
{suggestionsLoading ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '1rem', backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}>
|
||||||
|
<div style={{ width: 18, height: 18, border: '2px solid var(--border)', borderTopColor: 'var(--accent)', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
|
||||||
|
<span style={{ fontSize: '0.85rem', color: 'var(--ink-muted)' }}>Genero idee per te...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(min(100%, 280px), 1fr))', gap: '0.75rem' }}>
|
||||||
|
{suggestions.suggestions.map((topic, i) => (
|
||||||
|
<Link
|
||||||
|
key={i}
|
||||||
|
to={`/content?topic=${encodeURIComponent(topic)}&character=${suggestions.character_id}`}
|
||||||
|
style={{
|
||||||
|
display: 'block', padding: '1rem 1.25rem',
|
||||||
|
backgroundColor: 'var(--surface)', border: '1px solid var(--border)',
|
||||||
|
borderLeft: '4px solid var(--accent)', textDecoration: 'none',
|
||||||
|
transition: 'border-color 0.15s, background-color 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'var(--accent-light)'; e.currentTarget.style.borderColor = 'var(--accent)' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'var(--surface)'; e.currentTarget.style.borderColor = 'var(--border)' }}
|
||||||
|
>
|
||||||
|
<p style={{ fontSize: '0.88rem', color: 'var(--ink)', margin: 0, lineHeight: 1.5, fontWeight: 500 }}>
|
||||||
|
{topic}
|
||||||
|
</p>
|
||||||
|
<span style={{ fontSize: '0.72rem', color: 'var(--accent)', fontWeight: 600, marginTop: '0.4rem', display: 'inline-block' }}>
|
||||||
|
Genera →
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Recent posts ────────────────────────────────────────── */}
|
{/* ── Recent posts ────────────────────────────────────────── */}
|
||||||
{recentPosts.length > 0 && (
|
{recentPosts.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export default function Layout() {
|
|||||||
onClick={() => isMobile && setSidebarOpen(false)}
|
onClick={() => isMobile && setSidebarOpen(false)}
|
||||||
style={({ isActive }) => ({
|
style={({ isActive }) => ({
|
||||||
display: 'block',
|
display: 'block',
|
||||||
padding: '0.6rem 0.875rem',
|
padding: '0.7rem 0.875rem',
|
||||||
fontSize: '0.84rem',
|
fontSize: '0.84rem',
|
||||||
fontWeight: isActive ? 600 : 400,
|
fontWeight: isActive ? 600 : 400,
|
||||||
color: isActive ? 'var(--accent)' : 'var(--ink-light)',
|
color: isActive ? 'var(--accent)' : 'var(--ink-light)',
|
||||||
@@ -221,7 +221,7 @@ export default function Layout() {
|
|||||||
<p style={{ fontSize: '0.72rem', color: 'var(--ink-muted)', margin: 0 }}>
|
<p style={{ fontSize: '0.72rem', color: 'var(--ink-muted)', margin: 0 }}>
|
||||||
© {new Date().getFullYear()} Leopost
|
© {new Date().getFullYear()} Leopost
|
||||||
</p>
|
</p>
|
||||||
<div style={{ display: 'flex', gap: '1.25rem' }}>
|
<div style={{ display: 'flex', gap: isMobile ? '0.75rem' : '1.25rem' }}>
|
||||||
{[{ to: '/privacy', label: 'Privacy' }, { to: '/termini', label: 'Termini' }, { to: '/cookie', label: 'Cookie' }].map(({ to, label }) => (
|
{[{ to: '/privacy', label: 'Privacy' }, { to: '/termini', label: 'Termini' }, { to: '/cookie', label: 'Cookie' }].map(({ to, label }) => (
|
||||||
<Link
|
<Link
|
||||||
key={to}
|
key={to}
|
||||||
|
|||||||
@@ -196,44 +196,58 @@ h1, h2, h3, h4 {
|
|||||||
|
|
||||||
/* ─── Mobile / Responsive ───────────────────────────────────── */
|
/* ─── Mobile / Responsive ───────────────────────────────────── */
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
/* Minimum touch target size */
|
/* Touch targets */
|
||||||
button, a, [role="button"] {
|
button, a, [role="button"] {
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
min-width: 44px;
|
min-width: 44px;
|
||||||
}
|
}
|
||||||
/* Prevent iOS font size zoom on inputs */
|
/* Prevent iOS font size zoom */
|
||||||
input, select, textarea {
|
input, select, textarea {
|
||||||
font-size: 16px !important;
|
font-size: 16px !important;
|
||||||
}
|
}
|
||||||
/* Cards stack nicely on mobile */
|
|
||||||
.card-editorial {
|
.card-editorial {
|
||||||
padding: 1.25rem !important;
|
padding: 1rem !important;
|
||||||
}
|
}
|
||||||
/* Legal pages readable on small screens */
|
/* Headings scale down */
|
||||||
|
h2 {
|
||||||
|
font-size: 1.35rem !important;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: 1.1rem !important;
|
||||||
|
}
|
||||||
|
/* Buttons */
|
||||||
|
.btn-outline, .btn-primary {
|
||||||
|
min-height: 44px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
/* Legal tables */
|
||||||
.legal-content table {
|
.legal-content table {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
display: block;
|
display: block;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
/* Ghost navbar fix */
|
||||||
/* Fix ghost navbar space on mobile */
|
|
||||||
#root {
|
#root {
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
min-height: -webkit-fill-available;
|
min-height: -webkit-fill-available;
|
||||||
}
|
}
|
||||||
|
/* Two-column grids collapse */
|
||||||
/* Ensure secondary buttons always look like buttons on mobile */
|
.grid-2col-mobile {
|
||||||
.btn-outline, .btn-primary {
|
grid-template-columns: 1fr !important;
|
||||||
min-height: 44px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tablet */
|
||||||
/* ─── Mobile UX improvements ───────────────────────────────────── */
|
@media (min-width: 768px) and (max-width: 1024px) {
|
||||||
|
h2 {
|
||||||
|
font-size: 1.6rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
/* ─── Print ─────────────────────────────────────────────────── */
|
/* ─── Print ─────────────────────────────────────────────────── */
|
||||||
@media print {
|
@media print {
|
||||||
aside, footer, .cookie-banner { display: none !important; }
|
aside, footer, .cookie-banner { display: none !important; }
|
||||||
|
|||||||
Reference in New Issue
Block a user