From 228edf2a91a0446aa03f79498906e393446c13ef Mon Sep 17 00:00:00 2001 From: Michele Date: Mon, 6 Apr 2026 01:57:44 +0200 Subject: [PATCH] feat: daily suggestion cache, saved ideas (Swipe File), remove Piani Attivi Backend: - Suggestions cached in DB per user, regenerated only after 24h - ?force=true parameter to regenerate on demand - New endpoints: GET/POST/DELETE /content/ideas for saved ideas - POST /content/ideas/{id}/mark-used to track usage Frontend: - Dashboard: suggestions loaded from cache, not regenerated on every visit - Dashboard: "Salva idea" button on each suggestion card - Dashboard: "Dammi altri suggerimenti" CTA to force regeneration - Dashboard: removed "Piani Attivi" stat card - SavedIdeas page: list saved ideas, add new, delete, generate from idea - Sidebar: added "Idee" nav item after "Contenuti" - App.jsx: added /ideas route Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/routers/content.py | 192 +++++++++++++++++++++---- frontend/src/App.jsx | 2 + frontend/src/components/Dashboard.jsx | 84 ++++++++--- frontend/src/components/Layout.jsx | 1 + frontend/src/components/SavedIdeas.jsx | 159 ++++++++++++++++++++ 5 files changed, 390 insertions(+), 48 deletions(-) create mode 100644 frontend/src/components/SavedIdeas.jsx diff --git a/backend/app/routers/content.py b/backend/app/routers/content.py index dd6a5c3..8c9fc31 100644 --- a/backend/app/routers/content.py +++ b/backend/app/routers/content.py @@ -342,34 +342,14 @@ def approve_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} - +def _generate_suggestions(db: Session, current_user: User, character) -> list[str]: + """Internal: call LLM to generate topic suggestions.""" 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} + return [] recent_posts = ( db.query(Post) @@ -406,12 +386,174 @@ def get_topic_suggestions( try: result = llm.generate(prompt, system=system_prompt) lines = [line.strip() for line in result.strip().splitlines() if line.strip()] - suggestions = lines[:3] + return lines[:3] except Exception: - suggestions = [] + return [] + + +@router.get("/suggestions") +def get_topic_suggestions( + force: bool = Query(False), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get cached daily suggestions or generate new ones. Use force=true to regenerate.""" + 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) + if not provider_name or not api_key: + return {"suggestions": [], "character_id": character.id, "needs_setup": True} + + today = date.today().isoformat() + + # Check cache + if not force: + cached = _get_setting(db, "daily_suggestions", current_user.id) + if cached and isinstance(cached, dict) and cached.get("date") == today: + return { + "suggestions": cached.get("suggestions", []), + "character_id": cached.get("character_id", character.id), + "character_name": cached.get("character_name", character.name), + "cached": True, + } + + # Generate new suggestions + suggestions = _generate_suggestions(db, current_user, character) + + # Save to cache + cache_data = { + "date": today, + "suggestions": suggestions, + "character_id": character.id, + "character_name": character.name, + } + existing = ( + db.query(SystemSetting) + .filter(SystemSetting.key == "daily_suggestions", SystemSetting.user_id == current_user.id) + .first() + ) + if existing: + existing.value = cache_data + existing.updated_at = datetime.utcnow() + else: + db.add(SystemSetting(key="daily_suggestions", value=cache_data, user_id=current_user.id)) + db.commit() return { "suggestions": suggestions, "character_id": character.id, "character_name": character.name, + "cached": False, } + + +# === Saved Ideas (Swipe File) === + +@router.get("/ideas") +def get_saved_ideas( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get all saved ideas for the user.""" + setting = ( + db.query(SystemSetting) + .filter(SystemSetting.key == "saved_ideas", SystemSetting.user_id == current_user.id) + .first() + ) + ideas = setting.value if setting and isinstance(setting.value, list) else [] + return {"ideas": ideas, "total": len(ideas)} + + +@router.post("/ideas") +def save_idea( + data: dict, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Save an idea for later use.""" + text = data.get("text", "").strip() + if not text: + raise HTTPException(status_code=400, detail="Text is required") + + setting = ( + db.query(SystemSetting) + .filter(SystemSetting.key == "saved_ideas", SystemSetting.user_id == current_user.id) + .first() + ) + ideas = setting.value if setting and isinstance(setting.value, list) else [] + + new_idea = { + "id": str(uuid.uuid4())[:8], + "text": text, + "note": data.get("note", ""), + "saved_at": datetime.utcnow().isoformat(), + "used": False, + } + ideas.insert(0, new_idea) + + if setting: + setting.value = ideas + setting.updated_at = datetime.utcnow() + else: + db.add(SystemSetting(key="saved_ideas", value=ideas, user_id=current_user.id)) + db.commit() + return new_idea + + +@router.delete("/ideas/{idea_id}") +def delete_idea( + idea_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Delete a saved idea.""" + setting = ( + db.query(SystemSetting) + .filter(SystemSetting.key == "saved_ideas", SystemSetting.user_id == current_user.id) + .first() + ) + if not setting or not isinstance(setting.value, list): + raise HTTPException(status_code=404, detail="Idea not found") + + ideas = [i for i in setting.value if i.get("id") != idea_id] + if len(ideas) == len(setting.value): + raise HTTPException(status_code=404, detail="Idea not found") + + setting.value = ideas + setting.updated_at = datetime.utcnow() + db.commit() + return {"ok": True} + + +@router.post("/ideas/{idea_id}/mark-used") +def mark_idea_used( + idea_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Mark an idea as used.""" + setting = ( + db.query(SystemSetting) + .filter(SystemSetting.key == "saved_ideas", SystemSetting.user_id == current_user.id) + .first() + ) + if not setting or not isinstance(setting.value, list): + raise HTTPException(status_code=404, detail="Idea not found") + + for idea in setting.value: + if idea.get("id") == idea_id: + idea["used"] = True + break + else: + raise HTTPException(status_code=404, detail="Idea not found") + + setting.updated_at = datetime.utcnow() + db.commit() + return {"ok": True} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 45a3658..3d49735 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -18,6 +18,7 @@ import SocialAccounts from './components/SocialAccounts' import CommentsQueue from './components/CommentsQueue' import SettingsPage from './components/SettingsPage' import EditorialCalendar from './components/EditorialCalendar' +import SavedIdeas from './components/SavedIdeas' import AdminSettings from './components/AdminSettings' import LandingPage from './components/LandingPage' import CookieBanner from './components/CookieBanner' @@ -52,6 +53,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/Dashboard.jsx b/frontend/src/components/Dashboard.jsx index 2f08f67..79cfeb5 100644 --- a/frontend/src/components/Dashboard.jsx +++ b/frontend/src/components/Dashboard.jsx @@ -38,7 +38,7 @@ export default function Dashboard() { setRecentPosts(posts.slice(0, 5)) setProviderStatus(providers) setLoading(false) - // Load suggestions if LLM is configured + // Load cached suggestions (won't regenerate if already generated today) if (providers?.llm?.configured && chars.length > 0) { setSuggestionsLoading(true) api.get('/content/suggestions') @@ -106,7 +106,6 @@ export default function Dashboard() { - {/* ── Provider status ─────────────────────────────────────── */} @@ -153,29 +152,68 @@ export default function Dashboard() { Genero idee per te... ) : ( -
- {suggestions.suggestions.map((topic, i) => ( - +
+ {suggestions.suggestions.map((topic, i) => ( +
+

+ {topic} +

+
+ + Genera → + + +
+
+ ))} +
+
+
+ Dammi altri suggerimenti + +
+ )} )} diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index 912150e..e33ebdc 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -9,6 +9,7 @@ const nav = [ { to: '/', label: 'Dashboard' }, { to: '/characters',label: 'Personaggi' }, { to: '/content', label: 'Contenuti' }, + { to: '/ideas', label: 'Idee' }, { to: '/affiliates',label: 'Link Affiliati' }, { to: '/editorial', label: 'Pianificazione' }, { to: '/schedule', label: 'Schedulazione' }, diff --git a/frontend/src/components/SavedIdeas.jsx b/frontend/src/components/SavedIdeas.jsx new file mode 100644 index 0000000..2fd93cb --- /dev/null +++ b/frontend/src/components/SavedIdeas.jsx @@ -0,0 +1,159 @@ +import { useState, useEffect } from 'react' +import { Link } from 'react-router-dom' +import { api } from '../api' +import ConfirmModal from './ConfirmModal' + +export default function SavedIdeas() { + const [ideas, setIdeas] = useState([]) + const [loading, setLoading] = useState(true) + const [newIdea, setNewIdea] = useState('') + const [deleteTarget, setDeleteTarget] = useState(null) + + useEffect(() => { loadIdeas() }, []) + + const loadIdeas = async () => { + setLoading(true) + try { + const data = await api.get('/content/ideas') + setIdeas(data.ideas || []) + } catch {} finally { setLoading(false) } + } + + const handleAdd = async () => { + const text = newIdea.trim() + if (!text) return + try { + await api.post('/content/ideas', { text }) + setNewIdea('') + loadIdeas() + } catch {} + } + + const handleDelete = async () => { + if (!deleteTarget) return + try { + await api.delete(`/content/ideas/${deleteTarget}`) + setDeleteTarget(null) + loadIdeas() + } catch {} + } + + const handleMarkUsed = async (id) => { + try { + await api.post(`/content/ideas/${id}/mark-used`) + loadIdeas() + } catch {} + } + + const formatDate = (d) => { + if (!d) return '' + const diff = Date.now() - new Date(d).getTime() + const mins = Math.floor(diff / 60000) + if (mins < 60) return `${mins}m fa` + const hours = Math.floor(mins / 60) + if (hours < 24) return `${hours}h fa` + const days = Math.floor(hours / 24) + return `${days}g fa` + } + + return ( +
+
+ Idee +
+

+ Idee Salvate +

+

+ Cattura spunti e topic interessanti. Usali quando vuoi per generare contenuti. +

+
+ + {/* Add new idea */} +
+ setNewIdea(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleAdd() }} + placeholder="Scrivi un'idea per un post..." + style={inputStyle} + /> + +
+ + {loading ? ( +
+
+
+ ) : ideas.length === 0 ? ( +
+
+

Nessuna idea salvata

+

+ Salva idee dalla Dashboard o aggiungile qui per usarle quando vuoi. +

+
+ ) : ( +
+ {ideas.map(idea => ( +
+
+

+ {idea.text} +

+
+ {formatDate(idea.saved_at)} + {idea.used && ( + + USATA + + )} +
+
+
+ handleMarkUsed(idea.id)} + style={{ fontSize: '0.75rem', color: 'var(--accent)', fontWeight: 600, textDecoration: 'none', whiteSpace: 'nowrap' }} + > + Genera → + + +
+
+ ))} +
+ )} + + setDeleteTarget(null)} + /> + +
+ ) +} + +const inputStyle = { + flex: 1, 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.6rem 1.25rem', backgroundColor: 'var(--ink)', color: 'white', + fontFamily: "'DM Sans', sans-serif", fontWeight: 600, fontSize: '0.875rem', + border: 'none', cursor: 'pointer', whiteSpace: 'nowrap', +}