/* @component-map * App — Main container, tab navigation [app.jsx] * DeckList — Browse/create/manage decks [components/DeckList.jsx] * CardManager — Add/edit cards within a deck [components/CardManager.jsx] * ReviewSession — Spaced repetition review with SM-2 [components/ReviewSession.jsx] * Stats — Overview statistics and progress [components/Stats.jsx] * @end-component-map */ // DEPLOY_CONFIG: {"cron": [{"name": "daily_due_cards_email_reminder", "schedule": "0 9 * * *", "action": "event", "config": {"event_type": "check_due_cards_and_send_reminders"}}], "triggers": []} import { useState, useMemo, useEffect } from 'react'; import { useCollection, useAuth } from 'deplixo'; import { DeckList } from './components/DeckList.jsx'; import { CardManager } from './components/CardManager.jsx'; import { ReviewSession } from './components/ReviewSession.jsx'; import { Stats } from './components/Stats.jsx'; function App() { const auth = typeof useAuth === 'function' ? useAuth() : {}; const { user, loading = false, login = async () => {}, logout = async () => {} } = auth; const collectionHook = typeof useCollection === 'function' ? useCollection : null; const decksCollection = collectionHook ? collectionHook('decks', { personal: true }) : {}; const cardsCollection = collectionHook ? collectionHook('cards', { personal: true }) : {}; const { items: decks = [], loading: decksLoading = false, add: addDeck = async () => {}, update: updateDeck = async () => {}, remove: removeDeck = async () => {} } = decksCollection; const { items: cards = [], loading: cardsLoading = false, add: addCard = async () => {}, update: updateCard = async () => {}, remove: removeCard = async () => {} } = cardsCollection; const [tab, setTab] = useState('decks'); const [selectedDeck, setSelectedDeck] = useState(null); const [reviewDeck, setReviewDeck] = useState(null); const [shareDeck, setShareDeck] = useState(null); const [shareLink, setShareLink] = useState(''); const [shareCopied, setShareCopied] = useState(false); const [importToken, setImportToken] = useState(''); const [importPreview, setImportPreview] = useState(null); const [importLoading, setImportLoading] = useState(false); const [importError, setImportError] = useState(''); const [shareError, setShareError] = useState(''); const parseShareToken = (token) => { try { const raw = token.startsWith('http') ? new URL(token).searchParams.get('share') || new URL(token).searchParams.get('import') : token; if (!raw) return null; const decoded = JSON.parse(decodeURIComponent(escape(atob(raw)))); return decoded?.deckId || null; } catch { return null; } }; const makeShareToken = (deck) => { const payload = { deckId: deck.id, generatedAt: new Date().toISOString(), app: 'vineyard-deck-share' }; return btoa(unescape(encodeURIComponent(JSON.stringify(payload)))); }; const userDecks = useMemo(() => { const userId = user?.id || user?.uid || user?.email; if (!userId) return []; return decks.filter((deck) => { const deckOwner = deck.userId || deck.ownerId || deck.createdBy || deck.uid || deck.email; return !deckOwner || deckOwner === userId; }); }, [decks, user]); const scopedCards = useMemo(() => { const userId = user?.id || user?.uid || user?.email; if (!userId) return []; return cards.filter((card) => { const cardOwner = card.userId || card.ownerId || card.createdBy || card.uid || card.email; return !cardOwner || cardOwner === userId; }); }, [cards, user]); const cardsByDeck = useMemo(() => { const map = new Map(); scopedCards.forEach((card) => { const deckId = card.deckId; if (!deckId) return; if (!map.has(deckId)) map.set(deckId, []); map.get(deckId).push(card); }); return map; }, [scopedCards]); useEffect(() => { if (!user) { setSelectedDeck(null); setReviewDeck(null); setShareDeck(null); setShareLink(''); setImportToken(''); setImportPreview(null); setTab('decks'); return; } if (selectedDeck) { const refreshedDeck = userDecks.find((d) => d.id === selectedDeck.id) || null; setSelectedDeck(refreshedDeck); } if (reviewDeck) { const refreshedReviewDeck = userDecks.find((d) => d.id === reviewDeck.id) || null; setReviewDeck(refreshedReviewDeck); } if (shareDeck) { const refreshedShareDeck = userDecks.find((d) => d.id === shareDeck.id) || null; setShareDeck(refreshedShareDeck); } }, [user, userDecks, selectedDeck, reviewDeck, shareDeck]); useEffect(() => { if (!importToken) { setImportPreview(null); setImportError(''); return; } const deckId = parseShareToken(importToken); if (!deckId) { setImportPreview(null); setImportError('Invalid share link.'); return; } const sharedDeck = decks.find((deck) => deck.id === deckId); if (!sharedDeck) { setImportPreview(null); setImportError('Shared deck not found or unavailable.'); return; } setImportPreview(sharedDeck); setImportError(''); }, [importToken, decks]); useEffect(() => { const url = new URL(window.location.href); const share = url.searchParams.get('share'); const importParam = url.searchParams.get('import'); if (share) { setTab('decks'); setImportToken(share); url.searchParams.delete('share'); window.history.replaceState({}, '', url.toString()); } if (importParam) { setTab('decks'); setImportToken(importParam); url.searchParams.delete('import'); window.history.replaceState({}, '', url.toString()); } }, []); const handleSelectDeck = (deck) => { setSelectedDeck(deck); setTab('cards'); }; const handleStartReview = (deck) => { setReviewDeck(deck); setTab('review'); }; const handleBackToDecks = () => { setSelectedDeck(null); setReviewDeck(null); setTab('decks'); }; const handleLogin = async () => { try { await login('google'); } catch (err) { console.error('Google sign-in failed:', err); } }; const handleLogout = async () => { try { await logout(); } catch (err) { console.error('Sign out failed:', err); } }; const isBusy = loading || decksLoading || cardsLoading; const handleGenerateShareLink = async (deck) => { try { setShareError(''); const token = makeShareToken(deck); const shareUrl = `${window.location.origin}${window.location.pathname}?share=${encodeURIComponent(token)}`; setShareDeck(deck); setShareLink(shareUrl); setShareCopied(false); if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(shareUrl); setShareCopied(true); } } catch (err) { console.error('Unable to create share link:', err); setShareError('Could not create a share link right now.'); } }; const handleCopyShareLink = async () => { try { if (shareLink && navigator.clipboard?.writeText) { await navigator.clipboard.writeText(shareLink); setShareCopied(true); } } catch (err) { console.error('Copy failed:', err); } }; const handleImportSharedDeck = async () => { if (!importPreview) return; try { setImportLoading(true); setImportError(''); const userId = user?.id || user?.uid || user?.email || ''; const baseName = importPreview.name || 'Shared Deck'; const importedDeck = await addDeck({ name: `${baseName} (Imported)`, description: importPreview.description || '', userId, ownerId: userId, createdBy: userId, importedFromDeckId: importPreview.id, importedAt: new Date().toISOString() }); const sourceCards = cardsByDeck.get(importPreview.id) || []; for (const card of sourceCards) { await addCard({ deckId: importedDeck.id, front: card.front || '', back: card.back || '', userId, ownerId: userId, createdBy: userId, importedFromCardId: card.id, importedFromDeckId: importPreview.id, importedAt: new Date().toISOString(), easeFactor: card.easeFactor, interval: card.interval, repetitions: 0, nextReviewAt: new Date().toISOString(), lastReviewedAt: null, correctCount: 0, incorrectCount: 0 }); } setImportToken(''); setImportPreview(null); setTab('decks'); setSelectedDeck(importedDeck); } catch (err) { console.error('Import failed:', err); setImportError('Could not import this shared deck.'); } finally { setImportLoading(false); } }; return (
🍇

Vineyard

Spaced Repetition
{user ? ( <> {user.name || user.email || 'Signed in'} ) : ( )}
{user ? ( <>
{isBusy &&
Loading your decks...
} {!isBusy && tab === 'decks' && ( )} {!isBusy && tab === 'cards' && selectedDeck && } {!isBusy && tab === 'review' && reviewDeck && } {!isBusy && tab === 'stats' && } {!isBusy && tab === 'decks' && (shareLink || importPreview) && (

Shared Deck

{shareDeck && shareLink && (
Share link ready
)} {importPreview && (
Import shared deck into your account
{importPreview.name} • {cardsByDeck.get(importPreview.id)?.length || 0} cards

This will copy the deck and its cards into your private account.

{importError &&
{importError}
}
)} {shareError &&
{shareError}
}
)}
) : (
🔐

Sign in to access your decks

Use Google OAuth to keep your flashcards private and synced to your account.

)}
); } export default App; ReactDOM.createRoot(document.getElementById("root")).render();