Files
grateful-journal/src/pages/HistoryPage.tsx

359 lines
13 KiB
TypeScript

import { useState, useEffect } from 'react'
import { useAuth } from '../contexts/AuthContext'
import { getUserEntries, type JournalEntry } from '../lib/api'
import { decryptEntry } from '../lib/crypto'
import { formatIST, getISTDateComponents } from '../lib/timezone'
import BottomNav from '../components/BottomNav'
import { useOnboardingTour, hasPendingTourStep, clearPendingTourStep } from '../hooks/useOnboardingTour'
interface DecryptedEntry extends JournalEntry {
decryptedTitle?: string
decryptedContent?: string
decryptError?: string
}
export default function HistoryPage() {
const { user, userId, secretKey, loading } = useAuth()
const [currentMonth, setCurrentMonth] = useState(new Date())
const [selectedDate, setSelectedDate] = useState(new Date())
const [entries, setEntries] = useState<DecryptedEntry[]>([])
const [loadingEntries, setLoadingEntries] = useState(false)
const [selectedEntry, setSelectedEntry] = useState<DecryptedEntry | null>(null)
const { continueTourOnHistory } = useOnboardingTour()
// Continue onboarding tour if navigated here from the home page tour
useEffect(() => {
if (hasPendingTourStep() === 'history') {
clearPendingTourStep()
continueTourOnHistory()
}
}, [])
// Fetch entries on mount and when userId changes
useEffect(() => {
if (!user || !userId) return
const fetchEntries = async () => {
setLoadingEntries(true)
try {
const token = await user.getIdToken()
const response = await getUserEntries(userId, token, 100, 0)
// Decrypt entries if they are encrypted
const decryptedEntries: DecryptedEntry[] = await Promise.all(
response.entries.map(async (entry) => {
if (entry.encryption?.encrypted && entry.encryption?.ciphertext && entry.encryption?.nonce) {
// Entry is encrypted, try to decrypt
if (!secretKey) {
return {
...entry,
decryptError: 'Encryption key not available',
decryptedTitle: '[Encrypted]',
}
}
try {
const decrypted = await decryptEntry(
entry.encryption.ciphertext,
entry.encryption.nonce,
secretKey
)
// Split decrypted content: first line is title, rest is content
const lines = decrypted.split('\n\n')
const decryptedTitle = lines[0]
const decryptedContent = lines.slice(1).join('\n\n')
return {
...entry,
decryptedTitle,
decryptedContent,
}
} catch (error) {
console.error(`Failed to decrypt entry ${entry.id}:`, error)
return {
...entry,
decryptError: 'Failed to decrypt entry',
decryptedTitle: '[Decryption Failed]',
}
}
} else {
// Entry is not encrypted, use plaintext
return {
...entry,
decryptedTitle: entry.title || '[Untitled]',
decryptedContent: entry.content || '',
}
}
})
)
setEntries(decryptedEntries)
} catch (error) {
console.error('Error fetching entries:', error)
} finally {
setLoadingEntries(false)
}
}
fetchEntries()
}, [user, userId, secretKey])
const getDaysInMonth = (date: Date) => {
const year = date.getFullYear()
const month = date.getMonth()
const firstDay = new Date(year, month, 1)
const lastDay = new Date(year, month + 1, 0)
const daysInMonth = lastDay.getDate()
const startingDayOfWeek = firstDay.getDay()
return { daysInMonth, startingDayOfWeek }
}
const hasEntryOnDate = (day: number) => {
return entries.some((entry) => {
const components = getISTDateComponents(entry.createdAt)
return (
components.date === day &&
components.month === currentMonth.getMonth() &&
components.year === currentMonth.getFullYear()
)
})
}
const isToday = (day: number) => {
const today = new Date()
return (
day === today.getDate() &&
currentMonth.getMonth() === today.getMonth() &&
currentMonth.getFullYear() === today.getFullYear()
)
}
const formatDate = (date: string) => {
return formatIST(date, 'date')
}
const formatTime = (date: string) => {
return formatIST(date, 'time')
}
const { daysInMonth, startingDayOfWeek } = getDaysInMonth(currentMonth)
const monthName = currentMonth.toLocaleDateString('en-US', {
month: 'long',
year: 'numeric',
})
const previousMonth = () => {
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1))
}
const nextMonth = () => {
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1))
}
// Get entries for selected date (in IST)
const selectedDateEntries = entries.filter((entry) => {
const components = getISTDateComponents(entry.createdAt)
return (
components.date === selectedDate.getDate() &&
components.month === selectedDate.getMonth() &&
components.year === selectedDate.getFullYear()
)
})
const isDateSelected = (day: number) => {
return (
day === selectedDate.getDate() &&
currentMonth.getMonth() === selectedDate.getMonth() &&
currentMonth.getFullYear() === selectedDate.getFullYear()
)
}
const handleDateClick = (day: number) => {
setSelectedDate(new Date(currentMonth.getFullYear(), currentMonth.getMonth(), day))
}
if (loading) {
return (
<div className="history-page" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<p style={{ color: '#9ca3af' }}>Loading</p>
<BottomNav />
</div>
)
}
return (
<div className="history-page">
<header className="history-header">
<div className="history-header-text">
<h1>History</h1>
<p className="history-subtitle">Your past reflections</p>
</div>
{/* <button type="button" className="history-search-btn" title="Search entries">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>
</button> */}
</header>
<main className="history-container">
<div id="tour-calendar" className="calendar-card">
<div className="calendar-header">
<h2 className="calendar-month">{monthName}</h2>
<div className="calendar-nav">
<button type="button" onClick={previousMonth} className="calendar-nav-btn" title="Previous month">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<button type="button" onClick={nextMonth} className="calendar-nav-btn" title="Next month">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div>
</div>
<div className="calendar-grid">
<div className="calendar-weekday">S</div>
<div className="calendar-weekday">M</div>
<div className="calendar-weekday">T</div>
<div className="calendar-weekday">W</div>
<div className="calendar-weekday">T</div>
<div className="calendar-weekday">F</div>
<div className="calendar-weekday">S</div>
{Array.from({ length: startingDayOfWeek }).map((_, i) => (
<div key={`empty-${i}`} className="calendar-day calendar-day-empty"></div>
))}
{Array.from({ length: daysInMonth }).map((_, i) => {
const day = i + 1
const hasEntry = hasEntryOnDate(day)
const isTodayDate = isToday(day)
const isSelected = isDateSelected(day)
return (
<button
key={day}
type="button"
className={`calendar-day ${hasEntry ? 'calendar-day-has-entry' : ''} ${isTodayDate ? 'calendar-day-today' : ''} ${isSelected ? 'calendar-day-selected' : ''}`}
onClick={() => handleDateClick(day)}
>
{day}
</button>
)
})}
</div>
</div>
<section id="tour-entries-list" className="recent-entries">
<h3 className="recent-entries-title">
{selectedDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).toUpperCase()}
</h3>
{loadingEntries ? (
<p style={{ color: '#9ca3af', fontSize: '0.875rem', textAlign: 'center', padding: '1.5rem 0', fontFamily: '"Sniglet", system-ui' }}>
Loading entries
</p>
) : (
<div className="entries-list">
{selectedDateEntries.length === 0 ? (
<p style={{ color: '#9ca3af', fontSize: '0.875rem', textAlign: 'center', padding: '1.5rem 0', fontFamily: '"Sniglet", system-ui' }}>
No entries for this day yet. Start writing!
</p>
) : (
selectedDateEntries.map((entry) => (
<button
key={entry.id}
type="button"
className="entry-card"
onClick={() => setSelectedEntry(entry)}
>
<div className="entry-header">
<span className="entry-date">{formatDate(entry.createdAt)}</span>
<span className="entry-time">{formatTime(entry.createdAt)}</span>
</div>
<h4 className="entry-title">{entry.decryptedTitle || entry.title || '[Untitled]'}</h4>
{entry.decryptedContent && (
<p className="entry-preview">{entry.decryptedContent}</p>
)}
</button>
))
)}
</div>
)}
</section>
</main>
{/* Entry Detail Modal */}
{selectedEntry && (
<div
className="entry-modal-overlay"
onClick={(e) => {
if (e.target === e.currentTarget) setSelectedEntry(null)
}}
>
<div className="entry-modal">
<div className="entry-modal-header">
<div className="entry-modal-meta">
<span className="entry-modal-date">{formatDate(selectedEntry.createdAt)}</span>
<span className="entry-modal-time">{formatTime(selectedEntry.createdAt)}</span>
</div>
<button
type="button"
className="entry-modal-close"
onClick={() => setSelectedEntry(null)}
title="Close"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<h2 className="entry-modal-title">
{selectedEntry.decryptedTitle || selectedEntry.title || '[Untitled]'}
</h2>
{selectedEntry.decryptError ? (
<div className="entry-modal-error">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
{selectedEntry.decryptError}
</div>
) : (
<div className="entry-modal-content">
{selectedEntry.decryptedContent
? selectedEntry.decryptedContent.split('\n').map((line, i) => (
<p key={i}>{line || '\u00A0'}</p>
))
: <p className="entry-modal-empty">No content</p>
}
</div>
)}
{selectedEntry.encryption?.encrypted && (
<div className="entry-modal-badge">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
End-to-end encrypted
</div>
)}
</div>
</div>
)}
<BottomNav />
</div>
)
}