Files
deardiary/frontend/src/pages/Journal.tsx
lotherk deaf496a7d feat: terminology fix (Entry→Event), diary page generation, settings refactor
- Rename Entry→Event throughout frontend and backend
- Change 'journal' terminology to 'diary page'
- Add professional footer with links
- Redirect to diary page after generation
- Error handling for generation failures
- Fix settings to store per-provider configs in providerSettings
- Backend reads API key from providerSettings
- Use prisma db push instead of migrate for schema sync
- Clean up duplicate entries.ts file
2026-03-26 23:10:33 +00:00

285 lines
9.8 KiB
TypeScript

import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { api, Journal, Task } from '../lib/api';
import { useTheme } from '../lib/ThemeContext';
const detailsStyles = `
details[open] .details-arrow {
transform: rotate(90deg);
}
details summary::-webkit-details-marker {
display: none;
}
`;
function GeneratingModal({
isOpen,
provider,
model,
step,
response
}: {
isOpen: boolean;
provider: string;
model?: string;
step: number;
response?: string;
}) {
const { resolvedTheme } = useTheme();
if (!isOpen) return null;
const steps = [
{ label: 'Sending entries to AI...', done: step > 0 },
{ label: 'Waiting for response...', done: step > 1 },
{ label: 'Processing & formatting...', done: step > 2 },
];
const formatResponse = (str: string | undefined): string | null => {
if (!str) return null;
try {
const parsed = JSON.parse(str);
if (parsed.content?.[0]?.text) {
return parsed.content[0].text;
}
if (parsed.choices?.[0]?.message?.content) {
return parsed.choices[0].message.content;
}
return JSON.stringify(parsed, null, 2);
} catch {
return str;
}
};
const output = formatResponse(response);
return (
<>
<style>{detailsStyles}</style>
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
<div className={`relative ${resolvedTheme === 'dark' ? 'bg-slate-800' : 'bg-white'} rounded-2xl p-8 shadow-2xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto`}>
<div className="text-center mb-6">
<div className="w-16 h-16 mx-auto mb-6 relative">
<div className="absolute inset-0 border-4 border-slate-600 rounded-full" />
<div className="absolute inset-0 border-4 border-purple-500 rounded-full border-t-transparent animate-spin" />
</div>
<h3 className="text-xl font-semibold mb-4">Generating Journal</h3>
<div className="space-y-3 text-sm text-left">
{steps.map((s, i) => (
<div key={i} className="flex items-center gap-3">
<div className={`w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold ${
s.done
? 'bg-green-500 text-white'
: step === i + 1
? 'bg-yellow-500 text-white animate-pulse'
: 'bg-slate-600 text-slate-400'
}`}>
{s.done ? '✓' : i + 1}
</div>
<span className={s.done ? 'text-slate-300' : step === i + 1 ? 'text-yellow-300' : 'text-slate-500'}>
{s.label}
</span>
</div>
))}
</div>
<div className="mt-4 w-full bg-slate-700 rounded-full h-2">
<div
className="h-2 bg-purple-500 rounded-full transition-all duration-300"
style={{ width: `${(step / 3) * 100}%` }}
/>
</div>
</div>
<details className="mt-4 border border-slate-600 rounded-lg">
<summary className="px-4 py-3 cursor-pointer text-sm text-slate-400 hover:text-slate-300 flex items-center gap-2">
<span className="transform transition-transform details-arrow"></span>
Details (AI Output)
</summary>
<div className="px-4 pb-4">
{output ? (
<pre className="mt-2 p-3 bg-slate-900 rounded-lg text-xs text-slate-300 max-h-48 overflow-auto whitespace-pre-wrap">
{output}
</pre>
) : (
<p className="mt-2 text-xs text-slate-500 italic">Waiting for response...</p>
)}
</div>
</details>
<div className="mt-4 text-xs text-slate-500 text-center">
{provider.charAt(0).toUpperCase() + provider.slice(1)}
{model && `${model}`}
</div>
</div>
</div>
</>
);
}
export default function JournalPage() {
const { date } = useParams<{ date: string }>();
const [journal, setJournal] = useState<Journal | null>(null);
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [currentProvider, setCurrentProvider] = useState('');
const [currentModel, setCurrentModel] = useState('');
const [generatingStep, setGeneratingStep] = useState(0);
const [currentResponse, setCurrentResponse] = useState<string | undefined>();
useEffect(() => {
if (date) {
loadJournal();
loadTasks();
}
}, [date]);
const loadJournal = async () => {
if (!date) return;
setLoading(true);
const res = await api.getJournal(date);
if (res.data) {
setJournal(res.data);
}
setLoading(false);
};
const loadTasks = async () => {
if (!date) return;
const res = await api.getJournalTasks(date);
if (res.data) {
setTasks(res.data);
}
};
const handleGenerate = async () => {
if (!date) return;
setGenerating(true);
setGeneratingStep(1);
setCurrentProvider('ai');
setCurrentModel('');
setCurrentResponse(undefined);
const res = await api.generateJournal(date);
setGeneratingStep(3);
if (res.data) {
setCurrentResponse(res.data.task.response);
setJournal(res.data.journal);
setCurrentProvider(res.data.task.provider);
setCurrentModel(res.data.task.model || '');
setTasks(prev => [res.data!.task, ...prev]);
}
setTimeout(() => setGenerating(false), 500);
};
const formatDate = (dateStr: string) => {
const d = new Date(dateStr + 'T12:00:00');
return d.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
};
if (!date) return null;
return (
<>
<GeneratingModal
isOpen={generating}
provider={currentProvider}
model={currentModel}
step={generatingStep}
response={currentResponse}
/>
<div className="max-w-4xl mx-auto p-4">
<div className="mb-6">
<a href={`/day/${date}`} className="text-slate-400 hover:text-white text-sm mb-1 inline-block"> Back to day</a>
<h1 className="text-2xl font-bold">Diary Page</h1>
<p className="text-slate-400">{formatDate(date)}</p>
</div>
{loading ? (
<div className="text-center py-12 text-slate-400">Loading...</div>
) : !journal || journal.content === 'Generating...' ? (
<div className="text-center py-12">
<p className="text-slate-400 mb-4">No diary page written yet</p>
<button
onClick={handleGenerate}
disabled={generating}
className="px-6 py-3 bg-purple-600 hover:bg-purple-700 rounded-lg font-medium transition disabled:opacity-50"
>
Generate Diary Page
</button>
</div>
) : (
<div>
<div className="text-sm text-slate-400 mb-4">
Generated {new Date(journal.generatedAt).toLocaleString()} {journal.eventCount} events
</div>
<div className="prose prose-invert prose-slate max-w-none">
<div className="bg-slate-900 rounded-xl p-6 border border-slate-800 whitespace-pre-wrap leading-relaxed">
{journal.content}
</div>
</div>
<div className="mt-6 flex gap-4">
<button
onClick={handleGenerate}
disabled={generating}
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded-lg text-sm transition disabled:opacity-50"
>
Rewrite
</button>
<a
href={`/tasks/${date}`}
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded-lg text-sm transition"
>
View Tasks
</a>
</div>
</div>
)}
{tasks.length > 0 && (
<div className="mt-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-medium">Generation Tasks</h2>
<a
href={`/tasks/${date}`}
className="text-sm text-purple-400 hover:text-purple-300"
>
View all
</a>
</div>
<div className="space-y-2">
{tasks.slice(0, 3).map(task => (
<details key={task.id} className="bg-slate-900 rounded-lg border border-slate-800">
<summary className="p-3 cursor-pointer flex items-center justify-between text-sm">
<div className="flex items-center gap-3">
<span className={`w-2 h-2 rounded-full ${
task.status === 'completed' ? 'bg-green-500' :
task.status === 'failed' ? 'bg-red-500' : 'bg-yellow-500'
}`} />
<span className="font-medium">
{task.provider.charAt(0).toUpperCase() + task.provider.slice(1)}
</span>
</div>
<span className={`text-xs px-2 py-1 rounded ${
task.status === 'completed' ? 'bg-green-500/20 text-green-400' :
task.status === 'failed' ? 'bg-red-500/20 text-red-400' : 'bg-yellow-500/20 text-yellow-400'
}`}>
{task.status}
</span>
</summary>
</details>
))}
</div>
</div>
)}
</div>
</>
);
}