@gv-sh/specgen-user
Version:
[](https://github.com/gv-sh/specgen-user)
394 lines (340 loc) • 11.9 kB
JSX
// src/components/stories/StoryViewer.jsx
import React from 'react';
import { Button } from '../ui/button';
import {
Calendar,
Download,
Share,
RefreshCw,
PlusCircle,
Printer
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
// import { Preview, print } from 'react-html2pdf';
import jsPDF from 'jspdf';
import html2canvas from 'html2canvas';
import ReactDOM from 'react-dom/client';
const StoryViewer = ({
story,
onRegenerateStory,
onCreateNew,
loading
}) => {
const navigate = useNavigate();
// Handle regenerate button click
const handleRegenerateClick = () => {
// Navigate to the generating page first
navigate('/generating');
// Then call the regeneration function
onRegenerateStory();
};
// Parse content into paragraphs
const contentParagraphs = story.content ?
story.content.split('\n\n').filter(p => p.trim()) : [];
// Copy to clipboard function
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.error('Failed to copy text: ', err);
// Fallback method
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
return successful;
} catch (err) {
console.error('Fallback copy failed: ', err);
document.body.removeChild(textArea);
return false;
}
}
};
// Share content function
const shareContent = async (shareData) => {
if (navigator.share) {
try {
await navigator.share(shareData);
return true;
} catch (err) {
console.error('Error sharing:', err);
return false;
}
} else {
console.warn('Web Share API not supported');
return false;
}
};
// Enhanced image handling function
const getStoryImage = (story) => {
if (!story) return null;
// Handle base64 image data
if (story.imageData) {
if (typeof story.imageData === 'string') {
// If it already starts with data:image, it's already properly formatted
if (story.imageData.startsWith('data:image')) {
return story.imageData;
}
// Otherwise, assume it's raw base64 and add proper prefix
return `data:image/png;base64,${story.imageData}`;
}
}
// Handle image URL if that's what the API returns
if (story.imageUrl) {
return story.imageUrl;
}
return null;
};
// Format date
const formatDate = (dateString) => {
const date = new Date(dateString);
const options = {
year: 'numeric',
month: 'long',
day: 'numeric'
};
return date.toLocaleDateString('en-US', options);
};
// Get the image source
const imageSource = getStoryImage(story);
// Handle share button click
const handleShare = async () => {
const shareData = {
title: story.title,
text: `${story.title} - Year ${story.year}\n\n${story.content.substring(0, 100)}...`,
url: window.location.href
};
// Try to use Web Share API
const shared = await shareContent(shareData);
// Fallback to copy to clipboard if sharing fails
if (!shared) {
const shareText = `${story.title} - Year ${story.year}\n\n${story.content}`;
copyToClipboard(shareText);
// You would need to show a toast/notification here
alert("Text copied to clipboard for sharing");
}
};
return (
<div className="container max-w-6xl mx-auto h-full flex flex-col" id={'jsx-template'}>
{/* Header */}
<header className="py-6 border-b">
<div className="flex items-center justify-between">
<div>
<h1 className="text-4xl font-bold tracking-tight mb-2">{story.title}</h1>
<div className="flex items-center text-muted-foreground">
{/* Only year here, not date */}
<span>Year {story.year}</span>
</div>
</div>
<div className="flex space-x-2">
<Button
variant="outline"
size="sm"
onClick={handleRegenerateClick}
disabled={loading}
>
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Regenerate
</Button>
<Button
variant="outline"
size="sm"
onClick={onCreateNew}
>
<PlusCircle className="h-4 w-4 mr-2" />
Create New Story
</Button>
</div>
</div>
</header>
<div className="py-8">
<div className="prose prose-lg max-w-3xl mx-auto">
{imageSource && (
<div className="mb-8 not-prose">
<img
src={imageSource}
alt={story.title}
className="w-full h-auto rounded-lg shadow-md"
onError={(e) => {
console.error("Story image failed to load:", imageSource);
e.target.onerror = null;
e.target.src = 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="800" height="400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>';
}}
/>
</div>
)}
{contentParagraphs.map((paragraph, index) => {
// Skip title paragraphs
if (paragraph.includes('**Title:')) {
return null;
}
return (
<p key={index} className=" text-sm/8 mb-4">{paragraph}</p>
);
})}
</div>
</div>
{/* Footer with actions */}
<footer className="py-6 border-t mt-auto">
<div className="flex items-center justify-between max-w-3xl mx-auto">
<div className="flex items-center space-x-4">
<Button
variant="outline"
size="sm"
// onClick={handleDownload}
onClick={() =>
downloadStyledPDF({
story: { title: story.title, year: story.year, createdAt: story.createdAt },
imageSource: imageSource,
contentParagraphs: contentParagraphs
})
}
>
<Download className="h-4 w-4 mr-2" />
Download
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
printStyledPDF({
story: { title: story.title, year: story.year, createdAt: story.createdAt },
imageSource: imageSource,
contentParagraphs: contentParagraphs
})
}
>
<Printer className="h-4 w-4 mr-2" />
Print
</Button>
<Button
variant="outline"
size="sm"
onClick={handleShare}
>
<Share className="h-4 w-4 mr-2" />
Share
</Button>
</div>
{/* Collection info with date moved here */}
<div className="text-sm text-muted-foreground">
<div className="flex items-center">
<Calendar className="h-3.5 w-3.5 mr-1.5" />
<span>{formatDate(story.createdAt)}</span>
<span className="mx-2">•</span>
<span className="text-primary">Speculative Fiction</span>
</div>
</div>
</div>
</footer>
</div>
);
};
export default StoryViewer;
const downloadStyledPDF = async ({ story, imageSource, contentParagraphs, returnInstance = false }) => {
const pageParagraphCount = 10; // Adjust this based on visual size
const pageChunks = [];
for (let i = 1; i < contentParagraphs.length; i += pageParagraphCount) {
pageChunks.push(contentParagraphs.slice(i, i + pageParagraphCount));
}
const pdf = new jsPDF('p', 'mm', 'a4');
const pageWidth = pdf.internal.pageSize.getWidth();
for (let pageIndex = 0; pageIndex < pageChunks.length; pageIndex++) {
const container = document.createElement('div');
container.id = `pdf-render-container-${pageIndex}`;
container.style.position = 'absolute';
container.style.left = '-9999px';
container.style.top = '0';
container.style.width = '794px';
container.style.padding = '15mm';
container.style.backgroundColor = '#fff';
container.style.columnCount = '2';
container.style.columnGap = '40px';
container.style.fontSize = '10px';
container.style.lineHeight = '1.8';
document.body.appendChild(container);
const jsxContent = (
<div>
{pageIndex === 0 && (
<>
<h1 className="text-3xl text-gray-900 font-bold mb-2 tracking-tight ">{story.title}</h1>
<p className="text-gray-500 mb-4 text-base ">Year {story.year}</p>
{imageSource && (
<div className="mb-5 break-inside-avoid">
<img
src={imageSource}
alt={story.title}
className="w-full mx-auto rounded-sm shadow-md"
/>
</div>
)}
</>
)}
{pageChunks[pageIndex].map((paragraph, idx) => (
<p key={idx} className="mb-5 text-[13px] text-gray-900 font-worksans leading-relaxed">
{paragraph}
</p>
))}
<div className="flex items-center text-[10px] text-gray-700 space-x-2 text-sm border-t mb-2">
<span>Created on</span>
<span>{formatDate(story.createdAt)}</span>
<span>|</span>
<span>Anantabhavi</span>
</div>
</div>
);
const root = ReactDOM.createRoot(container);
root.render(jsxContent);
await new Promise(resolve => setTimeout(resolve, 500));
const canvas = await html2canvas(container, {
scale: 2,
useCORS: true,
scrollY: -window.scrollY,
backgroundColor: '#fff',
windowWidth: container.scrollWidth
});
const imgData = canvas.toDataURL('image/jpeg', 1.0);
const scaledWidth = pageWidth;
const scaledHeight = (canvas.height * scaledWidth) / canvas.width;
if (pageIndex > 0) pdf.addPage();
pdf.addImage(imgData, 'JPEG', 0, 0, scaledWidth, scaledHeight);
root.unmount();
document.body.removeChild(container);
}
const safeTitle = story.title.replace(/\s+/g, '_').toLowerCase();
if (returnInstance) {
return pdf;
} else {
pdf.save(`${safeTitle}.pdf`);
}
};
const printStyledPDF = async ({ story, imageSource, contentParagraphs }) => {
const pdf = await downloadStyledPDF({
story,
imageSource,
contentParagraphs,
returnInstance: true // enable PDF return instead of save
});
const pdfBlob = pdf.output('blob');
const pdfUrl = URL.createObjectURL(pdfBlob);
const printWindow = window.open(pdfUrl);
printWindow.onload = function () {
printWindow.focus();
printWindow.print();
};
};
const formatDate = (dateString) => {
const date = new Date(dateString);
const options = {
year: 'numeric',
month: 'long',
day: 'numeric'
};
return date.toLocaleDateString('en-US', options);
};