Files
promptstory/App.tsx
2026-02-15 13:22:48 +01:00

319 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } from 'react';
import { WizardState, Step, GeneratedContent } from './types';
import StepContext from './components/StepContext';
import StepPlatform from './components/StepPlatform';
import StepEventType from './components/StepEventType';
import StepToneGoal from './components/StepToneGoal';
import StepDetails from './components/StepDetails';
import StepResult from './components/StepResult';
import { ChevronLeft, ExternalLink, Sparkles, User } from 'lucide-react';
import { generateStoryContent } from './services/geminiService';
const STORAGE_KEY = 'gpx-storyteller-state-v6'; // Incremented version for structure change
const INITIAL_STATE: WizardState = {
step: Step.CONTEXT,
context: null,
storyStyle: null,
platform: null,
eventType: null,
tone: null,
goal: null,
title: 'Poznań Maraton 2025',
description: 'Byłem totalnie nieprzygotowany. Ostatnie pół roku prawie nie ćwiczyłem, a w dodatku biegłem na antybiotykach z zapaleniem osemki. To była walka a nie przyjemność z biegania. To było głupie. Ale było warto',
files: [],
stats: {
distance: '',
duration: '',
elevation: '',
},
waypoints: [],
tripData: {
startPoint: { place: '', description: '' },
endPoint: { place: '', description: '' },
stops: [],
travelMode: null,
googleMapsKey: ''
}
};
const App: React.FC = () => {
const [data, setData] = useState<WizardState>(INITIAL_STATE);
const [generatedContent, setGeneratedContent] = useState<GeneratedContent | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [isLoaded, setIsLoaded] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Image loading states
const [logoError, setLogoError] = useState(false);
const [avatarError, setAvatarError] = useState(false);
// Persistence Logic
useEffect(() => {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
// Reset files because File objects cannot be serialized to local storage
parsed.files = [];
// Ensure new fields exist if loading from old state
if (!parsed.stats) parsed.stats = { distance: '', duration: '', elevation: '' };
if (!parsed.waypoints) parsed.waypoints = [];
// Ensure tripData exists
if (!parsed.tripData) {
parsed.tripData = { ...INITIAL_STATE.tripData };
}
// Defaults for new fields if migrating
if (!parsed.tone) parsed.tone = null;
if (!parsed.goal) parsed.goal = null;
if (!parsed.storyStyle) parsed.storyStyle = null;
setData(parsed);
} catch (e) {
console.error("Failed to load state", e);
}
}
setIsLoaded(true);
}, []);
useEffect(() => {
if (isLoaded) {
// Exclude files from persistence to avoid quota issues and serialization errors
const stateToSave = { ...data, files: [] };
localStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
}
}, [data, isLoaded]);
// UPDATED: Now supports functional updates to prevent stale state issues
const updateData = (updates: Partial<WizardState> | ((prev: WizardState) => Partial<WizardState>)) => {
setData(prev => {
const newValues = typeof updates === 'function' ? updates(prev) : updates;
return { ...prev, ...newValues };
});
};
const nextStep = () => {
if (data.step < Step.RESULT) {
updateData({ step: data.step + 1 });
}
};
const prevStep = () => {
if (data.step > Step.CONTEXT) {
updateData({ step: data.step - 1 });
}
};
const handleGenerate = async () => {
setIsGenerating(true);
setErrorMessage(null);
try {
const content = await generateStoryContent(
data,
process.env.API_KEY || ''
);
setGeneratedContent(content);
updateData({ step: Step.RESULT });
} catch (error: any) {
console.error("Generowanie nie powiodło się:", error);
setErrorMessage("Błąd generowania. Sprawdź poprawność klucza API w pliku .env oraz połączenie z siecią. " + (error.message || ''));
} finally {
setIsGenerating(false);
}
};
const handleRegenerate = async (slideCount: number, feedback: string) => {
setIsGenerating(true);
setErrorMessage(null);
try {
const content = await generateStoryContent(
data,
process.env.API_KEY || '',
{ slideCount, feedback }
);
setGeneratedContent(content);
} catch (error: any) {
console.error("Regenerowanie nie powiodło się:", error);
setErrorMessage("Błąd regenerowania: " + (error.message || ''));
} finally {
setIsGenerating(false);
}
};
const resetApp = () => {
setData({ ...INITIAL_STATE });
setGeneratedContent(null);
localStorage.removeItem(STORAGE_KEY);
// Force reload to clear any hung states
window.location.reload();
};
if (!isLoaded) return null;
// Step Labels for Progress Bar (Order Changed)
const stepsLabels = ['Kontekst', 'Typ', 'Platforma', 'Vibe & Cel', 'Szczegóły'];
return (
<div className="min-h-screen bg-white text-gray-900 flex flex-col font-sans">
{/* Header */}
<header className="bg-white sticky top-0 z-10 border-b border-gray-100">
<div className="max-w-4xl mx-auto px-6 py-4 flex justify-between items-center">
<div className="flex items-center space-x-3">
{!logoError ? (
<img
src="logo.png"
onError={() => setLogoError(true)}
alt="PromptStory Logo"
className="w-10 h-10 object-contain"
/>
) : (
<div className="text-[#EA4420]">
<Sparkles size={32} strokeWidth={2} />
</div>
)}
<h1 className="text-xl font-bold tracking-tight text-gray-900">PromptStory</h1>
</div>
<button onClick={resetApp} className="text-xs font-medium text-gray-400 hover:text-[#EA4420] transition-colors uppercase tracking-wide">
Resetuj
</button>
</div>
</header>
<main className="max-w-4xl mx-auto px-6 py-10 flex-grow w-full">
{/* Progress Bar */}
{data.step !== Step.RESULT && (
<div className="mb-12">
<div className="flex justify-between mb-3">
{stepsLabels.map((label, idx) => (
<span key={label} className={`text-xs font-bold uppercase tracking-wider transition-colors ${data.step >= idx ? 'text-[#EA4420]' : 'text-gray-300'}`}>
0{idx + 1}. {label}
</span>
))}
</div>
<div className="h-1 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-[#EA4420] transition-all duration-500 ease-out"
style={{ width: `${((data.step + 1) / 5) * 100}%` }}
/>
</div>
</div>
)}
{/* Dynamic Content Container */}
<div className="bg-white min-h-[400px]">
{/* STEP 1: CONTEXT */}
{data.step === Step.CONTEXT && <StepContext data={data} updateData={updateData} nextStep={nextStep} />}
{/* STEP 2: EVENT TYPE (Moved UP) */}
{data.step === Step.EVENT_TYPE && <StepEventType data={data} updateData={updateData} nextStep={nextStep} />}
{/* STEP 3: PLATFORM (Moved DOWN) */}
{data.step === Step.PLATFORM && <StepPlatform data={data} updateData={updateData} nextStep={nextStep} />}
{/* STEP 4: TONE & GOAL (NEW) */}
{data.step === Step.TONE_GOAL && <StepToneGoal data={data} updateData={updateData} nextStep={nextStep} />}
{/* STEP 5: DETAILS */}
{data.step === Step.DETAILS && (
<StepDetails
data={data}
updateData={updateData as any}
onGenerate={handleGenerate}
isGenerating={isGenerating}
/>
)}
{/* STEP 6: RESULT */}
{data.step === Step.RESULT && generatedContent && (
<StepResult
content={generatedContent}
onRegenerate={handleRegenerate}
isRegenerating={isGenerating}
tripData={data.tripData}
/>
)}
{/* Error Message */}
{errorMessage && (
<div className="mt-6 p-4 bg-red-50 text-red-600 rounded-md border border-red-100 text-sm text-center">
{errorMessage}
</div>
)}
</div>
{/* Navigation Controls (Back Only) */}
{data.step > Step.CONTEXT && data.step < Step.RESULT && (
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 p-4 md:static md:bg-transparent md:border-0 md:p-0 mt-12 mb-8">
<div className="max-w-4xl mx-auto">
<button
onClick={prevStep}
disabled={isGenerating}
className="flex items-center space-x-2 text-gray-500 hover:text-gray-900 font-semibold px-6 py-3 rounded-md border border-gray-200 hover:bg-gray-50 transition-colors disabled:opacity-50"
>
<ChevronLeft size={20} />
<span>Wróć</span>
</button>
</div>
</div>
)}
</main>
{/* Footer / Author Info */}
<footer className="border-t border-gray-100 py-10 bg-gray-50/30 mt-auto">
<div className="max-w-4xl mx-auto px-6 flex flex-col md:flex-row items-center justify-between gap-8">
<div className="flex items-start md:items-center gap-5">
{/* Avatar */}
<div className="relative group cursor-pointer flex-shrink-0">
<div className="absolute -inset-0.5 bg-gradient-to-r from-[#EA4420] to-orange-400 rounded-full opacity-30 group-hover:opacity-100 transition duration-500 blur"></div>
{!avatarError ? (
<img
src="avatar.jpeg"
onError={() => setAvatarError(true)}
alt="Arkadiusz Bykowski"
className="relative w-20 h-20 rounded-full object-cover border border-gray-100 bg-white"
/>
) : (
<div className="relative w-20 h-20 rounded-full border border-gray-100 bg-white flex items-center justify-center text-gray-300">
<User size={40} />
</div>
)}
</div>
{/* Text Info */}
<div className="text-left">
<h3 className="text-gray-900 font-bold text-xl leading-tight">Arkadiusz Bykowski</h3>
<p className="text-gray-600 text-sm mt-2 leading-relaxed max-w-lg">
Zamieniam Twoją stronę w maszynkę do zarabiania pieniędzy. Od 12 lat projektuję strony, które sprzedają bez Twojego udziału póki Ty śpisz, one robią za Ciebie robotę. Specjalizuję się w landing page'ach, które budują zaufanie i zmieniają odwiedzających w płacących klientów.
</p>
<div className="flex flex-wrap gap-2 mt-3">
<span className="text-xs font-medium text-gray-500 bg-gray-100 px-2 py-1 rounded-md">Product Design</span>
<span className="text-xs font-medium text-gray-500 bg-gray-100 px-2 py-1 rounded-md">AI Automation</span>
<span className="text-xs font-medium text-gray-500 bg-gray-100 px-2 py-1 rounded-md">No-Code</span>
</div>
</div>
</div>
{/* Button */}
<a
href="https://bykowski.pro/"
target="_blank"
rel="noopener noreferrer"
className="flex-shrink-0 flex items-center space-x-2 text-sm font-bold text-gray-700 hover:text-[#EA4420] transition-all bg-white border border-gray-200 px-6 py-3 rounded-full hover:shadow-md hover:border-[#EA4420]/30 active:scale-95"
>
<span>Odwiedź stronę</span>
<ExternalLink size={16} />
</a>
</div>
</footer>
</div>
);
};
export default App;