/* @component-map
* App — Main container, tab navigation [app.jsx]
* StoryView — Full story display with author attribution [components/StoryView.jsx]
* AddParagraph — Form to add new paragraphs [components/AddParagraph.jsx]
* StoryStats — Word count and reading time [components/StoryStats.jsx]
* StoryHeader — Title and story metadata [components/StoryHeader.jsx]
* @end-component-map */
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useAuth, useCollection, useReactions, usePresence, useBroadcast, useAI, generatePDF } from '@deplixo/sdk';
import { StoryHeader } from './components/StoryHeader.jsx';
import { StoryView } from './components/StoryView.jsx';
import { AddParagraph } from './components/AddParagraph.jsx';
import { StoryStats } from './components/StoryStats.jsx';
function ReactionBar({ targetId, onReact }) {
const { counts, toggle, loading } = useReactions(targetId);
const emojis = ['👍', '❤️', '🎉', '🔥', '👀'];
const handleToggle = emoji => {
toggle(emoji);
if (onReact) onReact({ targetId, emoji });
};
if (loading) return null;
return (
{emojis.map(emoji => (
handleToggle(emoji)}
type="button"
aria-label={`React with ${emoji}`}
title={`React with ${emoji}`}
>
{emoji}
{counts[emoji] ? {counts[emoji]} : null}
))}
);
}
function PresencePanel({ users }) {
const activeUsers = Array.isArray(users) ? users : [];
return (
Working on this story
Live presence updates as people join and leave
{activeUsers.length} active
{activeUsers.length === 0 ? (
No one is active right now.
) : (
{activeUsers.map((member, index) => {
const name = member?.name || member?.user?.name || member?.displayName || 'Anonymous';
const avatar = member?.avatar || member?.user?.avatar || member?.photoURL || '';
const status = member?.status || 'Editing';
return (
{avatar ? (
) : (
{name.charAt(0).toUpperCase()}
)}
{name}
{status}
);
})}
)}
);
}
function CliffhangerAssistant({ paragraphs, activeTab, onSelectSuggestion }) {
const { generate, loading, error } = useAI();
const [analysis, setAnalysis] = useState(null);
const [selectedSuggestion, setSelectedSuggestion] = useState('');
const [appliedSuggestion, setAppliedSuggestion] = useState('');
const storyText = (paragraphs || [])
.map(item => item?.value?.text || item?.value?.content || '')
.filter(Boolean)
.join('\n\n')
.trim();
useEffect(() => {
let cancelled = false;
const detectCliffhanger = async () => {
if (!storyText || activeTab === 'write') {
setAnalysis(null);
setSelectedSuggestion('');
setAppliedSuggestion('');
return;
}
try {
const response = await generate({
system:
'You analyze a collaborative story and detect whether the latest paragraph ends on a cliffhanger. If it does, return JSON with keys cliffhanger (boolean), reason (string), and suggestions (array of exactly 3 short next-direction suggestions). Suggestions should be concise and actionable for the next writer. If it is not a cliffhanger, return cliffhanger false, reason, and an empty suggestions array.',
user: `Story so far:\n\n${storyText}\n\nAnalyze the ending and determine whether the story currently reaches a cliffhanger.`,
json: true,
});
if (!cancelled) {
setAnalysis(response || null);
}
} catch (err) {
if (!cancelled) {
setAnalysis(null);
}
}
};
detectCliffhanger();
return () => {
cancelled = true;
};
}, [storyText, activeTab, generate]);
if (activeTab !== 'story' && activeTab !== 'write') return null;
const suggestions = Array.isArray(analysis?.suggestions) ? analysis.suggestions.slice(0, 3) : [];
const isCliffhanger = Boolean(analysis?.cliffhanger) && suggestions.length > 0;
const handleChoose = suggestion => {
setSelectedSuggestion(suggestion);
setAppliedSuggestion(suggestion);
if (onSelectSuggestion) onSelectSuggestion(suggestion);
};
if (!isCliffhanger) {
return (
AI story assistant
Monitoring ending
{loading ? 'Checking the latest story beat for a possible cliffhanger…' : analysis?.reason || 'The assistant will surface next-step ideas when the story ends on a suspenseful beat.'}
{error ? AI assistant unavailable right now.
: null}
);
}
return (
Cliffhanger detected
3 next directions
{analysis?.reason || 'The story currently ends in a suspenseful place. Pick a direction to guide the next paragraph.'}
{suggestions.map((suggestion, index) => (
handleChoose(suggestion)}
>
{index + 1}
{suggestion}
))}
{appliedSuggestion ? (
Chosen direction: {appliedSuggestion}
) : null}
);
}
function ShareStoryPanel({ title, paragraphs, shareUrl, onCopy }) {
const [copyState, setCopyState] = useState('idle');
const storyText = useMemo(
() =>
(paragraphs || [])
.map(item => item?.value?.text || item?.value?.content || '')
.filter(Boolean)
.join('\n\n')
.trim(),
[paragraphs]
);
const encodedTitle = encodeURIComponent(title || 'Untitled Story');
const encodedUrl = encodeURIComponent(shareUrl);
const encodedBody = encodeURIComponent(`I thought you might enjoy this story: ${title || 'Untitled Story'}\n\n${shareUrl}`);
const canShareNative = typeof navigator !== 'undefined' && typeof navigator.share === 'function';
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(shareUrl);
setCopyState('copied');
if (onCopy) onCopy(shareUrl);
window.setTimeout(() => setCopyState('idle'), 2000);
} catch (err) {
setCopyState('error');
window.setTimeout(() => setCopyState('idle'), 2500);
}
};
const handleNativeShare = async () => {
if (!canShareNative) return;
try {
await navigator.share({
title: title || 'Untitled Story',
text: 'Check out this published story',
url: shareUrl,
});
} catch (err) {
// ignore canceled shares
}
};
return (
Share this story
Copy the link or send it through social apps and email.
{canShareNative ? (
Share
) : null}
{copyState === 'copied' ? 'Copied!' : copyState === 'error' ? 'Copy failed' : 'Copy link'}
{storyText ? (
Published story is ready to share. Readers will land on the story page and can copy the same link.
) : null}
);
}
function StoryBookletPdf({ title, meta, paragraphs, triggerRef, onExportComplete }) {
const bookletRef = useRef(null);
const storyTitle = title || 'Untitled Story';
const orderedParagraphs = useMemo(
() => [...(paragraphs || [])].sort((a, b) => (a.value.order || 0) - (b.value.order || 0)),
[paragraphs]
);
const authorCounts = useMemo(() => {
const counts = new Map();
orderedParagraphs.forEach(item => {
const name = item?.value?.authorName || item?.value?.author || item?.value?.userName || 'Anonymous';
counts.set(name, (counts.get(name) || 0) + 1);
});
return Array.from(counts.entries())
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
}, [orderedParagraphs]);
const wordCount = useMemo(() => {
return orderedParagraphs.reduce((total, item) => {
const text = item?.value?.text || item?.value?.content || '';
return total + text.trim().split(/\s+/).filter(Boolean).length;
}, 0);
}, [orderedParagraphs]);
const readingTime = Math.max(1, Math.ceil(wordCount / 200));
useEffect(() => {
if (!triggerRef?.current) return;
const handleExport = async () => {
if (!bookletRef.current) return;
await generatePDF(bookletRef.current, {
filename: `${storyTitle.replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, '') || 'story'}-booklet.pdf`,
margin: 10,
});
if (onExportComplete) onExportComplete();
};
const button = triggerRef.current;
button.addEventListener('click', handleExport);
return () => button.removeEventListener('click', handleExport);
}, [triggerRef, storyTitle, onExportComplete]);
return (
Collaborative Story Booklet
{storyTitle}
A shared story by {authorCounts.length} contributor{authorCounts.length === 1 ? '' : 's'}
Total words
{wordCount}
Reading time
~{readingTime} min
Paragraphs
{orderedParagraphs.length}
Generated from Deplixo
Contributors
{authorCounts.length === 0 ? (
No contributors yet.
) : (
authorCounts.map((item, index) => (
{item.name}
{item.count} paragraph{item.count === 1 ? '' : 's'}
))
)}
This booklet presents the story in reading order with page breaks between paragraphs when needed.
{orderedParagraphs.map((item, index) => {
const text = item?.value?.text || item?.value?.content || '';
const author = item?.value?.authorName || item?.value?.author || item?.value?.userName || 'Anonymous';
const timestamp = item?.value?.createdAt || item?.value?.updatedAt || item?.createdAt || item?.updatedAt || '';
return (
Story page {index + 1}
{storyTitle}
Paragraph {index + 1}
{text}
{author}
{timestamp ? new Date(timestamp).toLocaleString() : ''}
);
})}
);
}
function App() {
const { user, loading, login, logout } = useAuth();
const { items: paragraphs, loading: dataLoading, add } = useCollection('story_paragraphs', { personal: false });
const { items: storyMeta, add: addMeta, update: updateMeta, loading: metaLoading } = useCollection('story_meta', { personal: false });
const [activeTab, setActiveTab] = useState('story');
const [chosenCliffhangerSuggestion, setChosenCliffhangerSuggestion] = useState('');
const [exportingPdf, setExportingPdf] = useState(false);
const exportButtonRef = useRef(null);
const { users: presenceUsers, update: updatePresence } = usePresence(
user ? { id: user.id, name: user.name, avatar: user.avatar } : null
);
const handleStoryBroadcast = useCallback(() => {}, []);
const handleReactionBroadcast = useCallback(() => {}, []);
const handlePresenceBroadcast = useCallback(() => {}, []);
useBroadcast('story:paragraph-added', handleStoryBroadcast);
useBroadcast('story:reaction', handleReactionBroadcast);
useBroadcast('story:presence', handlePresenceBroadcast);
useEffect(() => {
if (user) {
updatePresence({
id: user.id,
name: user.name,
avatar: user.avatar,
status: activeTab === 'write' ? 'Writing' : 'Reading',
});
}
}, [user, activeTab, updatePresence]);
if (loading || dataLoading || metaLoading) {
return (
✒️
Opening the storybook…
);
}
const sorted = [...paragraphs].sort((a, b) => (a.value.order || 0) - (b.value.order || 0));
const storyTitle = storyMeta.length > 0 ? storyMeta[0].value.title : null;
const canContribute = Boolean(user);
const publishedStory = Boolean(storyTitle && sorted.length > 0);
const shareUrl = typeof window !== 'undefined' ? window.location.href : '';
const handleWriteClick = () => {
if (!canContribute) {
login();
return;
}
setActiveTab('write');
};
const handleTabChange = tab => {
setActiveTab(tab);
if (user) {
const nextPresence = {
id: user.id,
name: user.name,
avatar: user.avatar,
status: tab === 'write' ? 'Writing' : tab === 'stats' ? 'Reviewing stats' : 'Reading',
};
updatePresence(nextPresence);
}
};
const handleLogout = async () => {
await logout();
};
const handleAddedParagraph = () => {
handleStoryBroadcast({ type: 'paragraph-added' });
handleTabChange('story');
};
const handleReaction = payload => {
handleReactionBroadcast({ type: 'reaction', ...payload });
};
const handleSelectSuggestion = suggestion => {
setChosenCliffhangerSuggestion(suggestion);
handleTabChange('write');
};
const suggestedWritePrompt = chosenCliffhangerSuggestion
? `Continue the story by following this direction: ${chosenCliffhangerSuggestion}`
: '';
const handlePdfExportClick = async () => {
if (!exportButtonRef.current) return;
setExportingPdf(true);
try {
exportButtonRef.current.click();
} finally {
setExportingPdf(false);
}
};
return (
{publishedStory ?
: null}
handleTabChange('story')}>
📖 Story
✍️ Write
handleTabChange('stats')}>
📊 Stats
{activeTab === 'story' && (
{sorted.length === 0 ? (
📖
Your story starts here
Be the first to add a paragraph, then react to each section as the story grows.
) : (
} />
)}
{sorted.length > 0 ? (
✦ ✦ ✦
End of current chapter
) : null}
)}
{activeTab === 'write' && (
canContribute ? (
) : (
🔐
Sign in to write
Google OAuth is required for contributor actions like adding new paragraphs.
Sign in to join the story and share your voice.
login()}>
Sign in with Google
)
)}
{activeTab === 'stats' && }
Download booklet PDF
Export the finished story with a cover page and paginated story content.
{exportingPdf ? 'Preparing PDF…' : 'Export PDF booklet'}
setExportingPdf(false)}
/>
);
}
ReactDOM.createRoot(document.getElementById('root')).render( );