Files
promptstory/App.tsx
2026-02-15 16:44:14 +00:00

490 lines
19 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, Lock, ArrowRight, AlertCircle, Bug } from 'lucide-react';
import { generateStoryContent } from './services/geminiService';
import { getEnvVar } from './utils/envUtils';
const STORAGE_KEY = 'gpx-storyteller-state-v6'; // Incremented version for structure change
const AUTH_KEY = 'promptstory-auth-token';
// --- PASSWORD CONFIGURATION ---
const ENV_PASSWORD = getEnvVar('VITE_APP_PASSWORD');
// Fallback Hardcoded Password (Safety net)
const FALLBACK_PASS = 'Preorder$Disinfect6$Childlike$Unnamed1';
// Decision Logic
const APP_PASSWORD = ENV_PASSWORD || FALLBACK_PASS;
const IS_USING_FALLBACK = !ENV_PASSWORD;
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: ''
}
};
// --- LOGIN SCREEN COMPONENT ---
const LoginScreen: React.FC<{ onLogin: (success: boolean) => void }> = ({ onLogin }) => {
const [input, setInput] = useState('');
const [error, setError] = useState(false);
const [showDebug, setShowDebug] = useState(false);
// DEBUGGING: Log to console on mount
useEffect(() => {
console.group("--- DEBUG PASSWORD SYSTEM ---");
console.log("1. Detected ENV Variable:", ENV_PASSWORD ? "****" : "(empty)");
console.log("2. Final Password Source:", IS_USING_FALLBACK ? "FALLBACK (Code)" : ".ENV FILE");
console.log("3. Active Password Length:", APP_PASSWORD.length);
console.groupEnd();
}, []);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Trim input to avoid accidental spaces from copy-paste
if (input.trim() === APP_PASSWORD) {
onLogin(true);
} else {
console.log(`Failed Login. Input: "${input}" vs Expected: "${APP_PASSWORD}"`);
setError(true);
setInput('');
}
};
return (
<div className="min-h-screen bg-gray-50 flex flex-col items-center justify-center p-4">
<div className="bg-white p-8 rounded-xl shadow-lg border border-gray-100 max-w-md w-full text-center space-y-6 animate-fade-in relative">
{/* Debug Toggle (Hidden in corner) */}
<button
onClick={() => setShowDebug(!showDebug)}
className="absolute top-2 right-2 text-gray-200 hover:text-gray-400 p-2"
title="Pokaż informacje debugowania"
>
<Bug size={16} />
</button>
<div className="flex justify-center mb-2">
<div className="bg-[#EA4420]/10 p-4 rounded-full text-[#EA4420]">
<Lock size={32} />
</div>
</div>
<div>
<h2 className="text-2xl font-bold text-gray-900">Dostęp Chroniony</h2>
<p className="text-gray-500 mt-2">Wprowadź hasło, aby uzyskać dostęp do kreatora PromptStory.</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="relative">
<input
type="password"
value={input}
onChange={(e) => {
setInput(e.target.value);
setError(false);
}}
placeholder="Hasło..."
className={`w-full p-4 border rounded-lg outline-none transition-all font-medium text-center tracking-widest ${
error
? 'border-red-300 bg-red-50 focus:border-red-500'
: 'border-gray-200 focus:border-[#EA4420] focus:ring-1 focus:ring-[#EA4420]'
}`}
autoFocus
/>
</div>
{error && (
<div className="flex items-center justify-center gap-2 text-red-500 text-sm font-medium animate-pulse">
<AlertCircle size={16} />
<span>Nieprawidłowe hasło</span>
</div>
)}
<button
type="submit"
className="w-full bg-[#EA4420] text-white py-4 rounded-lg font-bold hover:bg-[#d63b1a] transition-colors flex items-center justify-center gap-2"
>
<span>Odblokuj</span>
<ArrowRight size={20} />
</button>
</form>
{/* DEBUG PANEL */}
{showDebug && (
<div className="bg-gray-800 text-left text-green-400 p-4 rounded-md text-xs font-mono mt-4 overflow-hidden">
<p className="font-bold border-b border-gray-600 mb-2 pb-1 text-white">DEBUG STATUS:</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 mt-2">
<span className="text-gray-400">SOURCE:</span>
<span className={IS_USING_FALLBACK ? "text-yellow-400 font-bold" : "text-green-400 font-bold"}>
{IS_USING_FALLBACK ? "FALLBACK (Code)" : ".ENV FILE"}
</span>
<span className="text-gray-400">ACTIVE PASS:</span>
<span>{APP_PASSWORD.substring(0, 4)}... (len: {APP_PASSWORD.length})</span>
</div>
{IS_USING_FALLBACK && (
<div className="mt-3 text-yellow-500 border-t border-gray-600 pt-2">
<p>System używa hasła awaryjnego zdefiniowanego w kodzie.</p>
</div>
)}
</div>
)}
<p className="text-xs text-gray-300 pt-4">
PromptStory v1.0 Private Instance
</p>
</div>
</div>
);
};
const App: React.FC = () => {
// Auth State
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isAuthChecking, setIsAuthChecking] = useState(true);
// App State
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);
// 1. Check Auth on Mount
useEffect(() => {
const storedAuth = localStorage.getItem(AUTH_KEY);
if (storedAuth === 'true') {
setIsAuthenticated(true);
}
setIsAuthChecking(false);
}, []);
const handleLogin = (success: boolean) => {
if (success) {
localStorage.setItem(AUTH_KEY, 'true');
setIsAuthenticated(true);
}
};
const handleLogout = () => {
localStorage.removeItem(AUTH_KEY);
setIsAuthenticated(false);
resetApp();
};
// 2. Load Data 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 {
// SAFE ENV ACCESS
const apiKey = getEnvVar('API_KEY');
const content = await generateStoryContent(
data,
apiKey || ''
);
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 {
// SAFE ENV ACCESS
const apiKey = getEnvVar('API_KEY');
const content = await generateStoryContent(
data,
apiKey || '',
{ 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();
};
// --- RENDER LOGIC ---
if (isAuthChecking || !isLoaded) return null; // Loading state
if (!isAuthenticated) {
return <LoginScreen onLogin={handleLogin} />;
}
// 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>
<div className="flex items-center gap-4">
<button onClick={resetApp} className="text-xs font-medium text-gray-400 hover:text-[#EA4420] transition-colors uppercase tracking-wide">
Resetuj
</button>
<button onClick={handleLogout} className="text-xs font-medium text-gray-400 hover:text-red-500 transition-colors uppercase tracking-wide">
Wyloguj
</button>
</div>
</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;