// DEPLOY_CONFIG: {"cron": [{"name": "auto_schedule_discussion_on_full_progress", "schedule": "*/5 * * * *", "action": "event", "config": {"event_type": "check_all_members_complete"}}], "triggers": [{"name": "send_discussion_ready_email", "on": "event", "collection": "discussion_events", "actions": [{"type": "email", "to": "{{member.email}}", "subject": "Discussion scheduled", "body": "All members have reached 100% progress. The discussion has been automatically scheduled."}]}]} import { useState, useCallback, useEffect, useMemo } from 'react'; import { useAI, useAuth, useCollection, usePresence, renderChart } from '@deplixo/sdk'; import { CurrentBook } from './components/CurrentBook.jsx'; import { ReadingNotes } from './components/ReadingNotes.jsx'; import { Discussion } from './components/Discussion.jsx'; import { BookManagement } from './components/BookManagement.jsx'; function App() { const { user, loading, logout } = useAuth(); const { generate, loading: aiLoading, error: aiError } = useAI(); const [activeTab, setActiveTab] = useState('book'); const [presencePayload, setPresencePayload] = useState({ view: 'book', chapter: null, bookTitle: null, bookId: null }); const [voteInput, setVoteInput] = useState({ title: '', author: '', genre: '' }); const [voteStatus, setVoteStatus] = useState(''); const [discussionQuestions, setDiscussionQuestions] = useState([]); const [discussionQuestionsLoading, setDiscussionQuestionsLoading] = useState(false); const [discussionQuestionsStatus, setDiscussionQuestionsStatus] = useState(''); const { items: bookItems, loading: booksLoading } = useCollection('books'); const { items: noteItems, loading: notesLoading } = useCollection('reading-notes'); const { items: discussionItems, loading: discussionLoading, add: addDiscussion, update: updateDiscussion } = useCollection('discussion'); const { items: voteItems, loading: votesLoading, add: addVote, update: updateVote, remove: removeVote } = useCollection('book-votes', { personal: true }); const currentBook = bookItems?.[0] || null; const currentChapter = presencePayload.chapter; const buildPresence = useCallback((overrides = {}) => { const book = currentBook || {}; return { userId: user?.id || user?.email || user?.name || 'unknown', name: user?.name || 'Member', avatar: user?.avatar || null, email: user?.email || null, view: activeTab, bookId: book?.id || null, bookTitle: book?.title || null, chapter: currentChapter, updatedAt: Date.now(), ...overrides }; }, [user, activeTab, currentBook, currentChapter]); const handlePresenceUpdate = useCallback((data) => { if (!data) return; setPresencePayload(prev => ({ ...prev, view: data.view ?? prev.view, chapter: data.chapter ?? prev.chapter, bookTitle: data.bookTitle ?? prev.bookTitle, bookId: data.bookId ?? prev.bookId })); }, []); const { users: presenceUsers, update: updatePresence } = usePresence(buildPresence()); const tabs = [ { id: 'book', label: 'πŸ“– Current Book', icon: 'πŸ“–' }, { id: 'notes', label: 'πŸ“ My Notes', icon: 'πŸ“' }, { id: 'discussion', label: 'πŸ’¬ Discussion', icon: 'πŸ’¬' }, { id: 'votes', label: 'πŸ—³οΈ Book Vote', icon: 'πŸ—³οΈ' }, { id: 'manage', label: 'βš™οΈ Manage', icon: 'βš™οΈ' } ]; useEffect(() => { updatePresence(buildPresence()); }, [activeTab, currentBook, currentChapter, buildPresence, updatePresence]); useEffect(() => { if (!user) return; const defaultChapter = currentBook?.currentChapter || null; setPresencePayload(prev => ({ ...prev, view: activeTab, chapter: activeTab === 'notes' ? (prev.chapter ?? defaultChapter) : (activeTab === 'discussion' ? prev.chapter : null), bookTitle: currentBook?.title || prev.bookTitle || null, bookId: currentBook?.id || prev.bookId || null })); }, [activeTab, currentBook, user]); const normalizedVotes = useMemo(() => { const map = new Map(); (voteItems || []).forEach(vote => { if (!vote) return; const title = (vote.title || '').trim(); if (!title) return; const key = title.toLowerCase(); const existing = map.get(key) || { title, author: vote.author || '', genre: vote.genre || '', count: 0, voters: [], updatedAt: 0, ids: [] }; existing.count += Number(vote.count || 1); existing.updatedAt = Math.max(existing.updatedAt, vote.updatedAt || 0); existing.ids.push(vote.id); if (!existing.author && vote.author) existing.author = vote.author; if (!existing.genre && vote.genre) existing.genre = vote.genre; if (vote.userId) existing.voters.push(vote.userId); map.set(key, existing); }); return [...map.values()].sort((a, b) => b.count - a.count || b.updatedAt - a.updatedAt || a.title.localeCompare(b.title)); }, [voteItems]); const myVote = useMemo(() => { const userKey = user?.id || user?.email || user?.name; if (!userKey) return null; return (voteItems || []).find(vote => vote?.userId === userKey) || null; }, [voteItems, user]); const discussionBookTitle = currentBook?.title || presencePayload.bookTitle || ''; const discussionPromptCount = Array.isArray(discussionQuestions) ? discussionQuestions.length : 0; const generateDiscussionQuestions = useCallback(async () => { const title = (discussionBookTitle || '').trim(); if (!title) { setDiscussionQuestionsStatus('Add a current book title first to generate prompts.'); return; } setDiscussionQuestionsLoading(true); setDiscussionQuestionsStatus('Generating discussion prompts...'); try { const response = await generate({ system: 'You generate engaging book club discussion prompts. Return JSON only.', user: `Create 6 discussion questions for the book titled "${title}". Make them thoughtful, open-ended, and suitable for a club meeting. Return JSON in this format: {"questions":["...","..."]}`, json: true }); const questions = Array.isArray(response?.questions) ? response.questions.filter(Boolean).map(q => String(q).trim()).filter(Boolean) : []; if (questions.length === 0) { throw new Error('No questions returned'); } setDiscussionQuestions(questions); setDiscussionQuestionsStatus('Discussion prompts generated successfully.'); } catch (error) { setDiscussionQuestionsStatus('Unable to generate prompts right now.'); } finally { setDiscussionQuestionsLoading(false); } }, [generate, discussionBookTitle]); const useGeneratedQuestionsForDiscussion = useCallback(async () => { if (!discussionPromptCount) { setDiscussionQuestionsStatus('Generate discussion prompts first.'); return; } const payload = { bookId: currentBook?.id || presencePayload.bookId || null, bookTitle: discussionBookTitle || null, chapter: presencePayload.chapter || currentBook?.currentChapter || null, title: `${discussionBookTitle ? `${discussionBookTitle} ` : ''}Discussion`, questions: discussionQuestions, createdBy: user?.id || user?.email || user?.name || 'unknown', createdAt: Date.now(), updatedAt: Date.now(), status: 'scheduled' }; try { await addDiscussion(payload); setDiscussionQuestionsStatus('Scheduled discussion pre-populated with generated questions.'); } catch (error) { setDiscussionQuestionsStatus('Unable to save the scheduled discussion right now.'); } }, [discussionPromptCount, addDiscussion, currentBook, presencePayload.chapter, presencePayload.bookId, discussionBookTitle, discussionQuestions, user]); const handleVoteSubmit = async (e) => { e.preventDefault(); const title = voteInput.title.trim(); if (!title) return; const userKey = user?.id || user?.email || user?.name || 'unknown'; const existing = myVote; const payload = { userId: userKey, name: user?.name || 'Member', avatar: user?.avatar || null, email: user?.email || null, title, author: voteInput.author.trim(), genre: voteInput.genre.trim(), updatedAt: Date.now() }; try { if (existing?.id) { await updateVote(existing.id, payload); } else { await addVote({ ...payload, count: 1 }); } setVoteStatus('Your vote has been saved.'); setVoteInput({ title: '', author: '', genre: '' }); } catch (error) { setVoteStatus('Unable to save your vote right now.'); } }; const clearMyVote = async () => { if (!myVote?.id) return; try { await removeVote(myVote.id); setVoteStatus('Your vote was removed.'); } catch (error) { setVoteStatus('Unable to remove your vote right now.'); } }; if (loading) { return
Signing in...
; } if (!user) { return
Please sign in to continue.
; } const isMemberActionAllowed = !!user; const readingMembers = (presenceUsers || []).filter(member => member?.name && (member.view === 'book' || member.view === 'notes' || member.view === 'discussion')); const topPick = normalizedVotes[0] || null; return (
πŸ“š

Book Club

Read together, grow together

{user.avatar ? ( {user.name} ) : ( πŸ‘€ )}
{user.name} {user.email}

Currently reading

{currentBook ? `${currentBook.title}${currentChapter ? ` β€’ Chapter ${currentChapter}` : ''}` : 'No active book yet'}

{readingMembers.length} online
{readingMembers.length > 0 ? readingMembers.map(member => (
{member.avatar ? ( {member.name} ) : ( πŸ‘€ )}
{member.name} {member.view === 'book' && 'Reading the current book'} {member.view === 'notes' && (member.chapter ? `Taking notes in Chapter ${member.chapter}` : 'Taking notes')} {member.view === 'discussion' && (member.chapter ? `Discussing Chapter ${member.chapter}` : 'Joining discussion')}
)) : (
No one is marked as reading right now.
)}
{activeTab === 'book' && } {activeTab === 'notes' && } {activeTab === 'discussion' && ( )} {activeTab === 'votes' && (

Next month’s book vote

Vote for the next read and see what the club is leaning toward.

{normalizedVotes.reduce((sum, item) => sum + item.count, 0)} votes
setVoteInput(prev => ({ ...prev, title: e.target.value }))} placeholder="Add a title to vote for" />
setVoteInput(prev => ({ ...prev, author: e.target.value }))} placeholder="Optional" />
setVoteInput(prev => ({ ...prev, genre: e.target.value }))} placeholder="Optional" />
{voteStatus &&
{voteStatus}
}

Current standings

Ranked by the number of member votes.

{topPick ? Leading: {topPick.title} : null}
{votesLoading ? (
Loading votes...
) : normalizedVotes.length > 0 ? (
{normalizedVotes.map((entry, index) => (
#{index + 1}
{entry.title} {entry.author ? `${entry.author}` : 'No author added'}{entry.genre ? ` β€’ ${entry.genre}` : ''}
{entry.count}
))}
) : (
No votes yet. Be the first to add one.
)}
)} {activeTab === 'manage' && ( isMemberActionAllowed ? :
πŸ”’

Members only

Sign in with Google to manage the current book and member-related actions.

)}
); } function ReadingPaceChart({ title, subtitle, chartData, emptyText }) { const canvasRef = useRef(null); const chartKey = useMemo(() => { if (!chartData || !Array.isArray(chartData.labels) || !Array.isArray(chartData.values)) return ''; return `${chartData.label || title}-${chartData.labels.join('|')}-${chartData.values.join('|')}`; }, [chartData, title]); useEffect(() => { if (!canvasRef.current) return; if (!chartData || !Array.isArray(chartData.labels) || !Array.isArray(chartData.values) || chartData.labels.length === 0) return; renderChart(canvasRef.current, { type: 'line', data: { labels: chartData.labels, datasets: [ { label: chartData.label || title, data: chartData.values, tension: 0.35, fill: true } ] } }); }, [chartKey, chartData, title]); const hasData = !!(chartData && Array.isArray(chartData.labels) && Array.isArray(chartData.values) && chartData.labels.length > 0); return (

{title}

{subtitle}

{hasData ? ( ) : (
{emptyText}
)}
); } ReactDOM.createRoot(document.getElementById("root")).render();