feat: v0.1.0 - geolocation capture, calendar, search, Starlight docs site

- Automatic browser geolocation capture on event creation
- Reverse geocoding via Nominatim API for place names
- Full-text search with SQLite FTS5
- Calendar view for browsing past entries
- DateNavigator component for day navigation
- SearchModal with Ctrl+K shortcut
- QuickAddWidget with Ctrl+J shortcut
- Starlight documentation site with GitHub Pages deployment
- Multiple AI provider support (Groq, OpenAI, Anthropic, Ollama, LM Studio)
- Multi-user registration support

BREAKING: Events now include latitude/longitude/placeName fields
This commit is contained in:
lotherk
2026-03-27 02:27:55 +00:00
parent deaf496a7d
commit 0bdd71a4ed
67 changed files with 15201 additions and 355 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "deardiary-frontend",
"private": true,
"version": "0.0.1",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -2,13 +2,18 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { useState, useEffect } from 'react';
import { api } from './lib/api';
import Auth from './pages/Auth';
import Dashboard from './pages/Dashboard';
import Home from './pages/Home';
import History from './pages/History';
import Day from './pages/Day';
import Journal from './pages/Journal';
import Diary from './pages/Diary';
import Calendar from './pages/Calendar';
import Tasks from './pages/Tasks';
import Settings from './pages/Settings';
import QuickAddWidget from './components/QuickAddWidget';
import SearchModal from './components/SearchModal';
import { useTheme } from './lib/ThemeContext';
import packageJson from '../package.json';
function PrivateRoute({ children }: { children: React.ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
@@ -33,18 +38,38 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
function Navbar() {
function Navbar({ onQuickAdd, onSearch, appName = 'DearDiary' }: { onQuickAdd: () => void; onSearch: () => void; appName?: string }) {
return (
<nav className="bg-slate-900 border-b border-slate-800 sticky top-0 z-50">
<div className="max-w-4xl mx-auto px-4 py-3 flex items-center justify-between">
<a href="/" className="flex items-center gap-2">
<span className="text-lg font-bold bg-gradient-to-r from-purple-400 to-pink-400 bg-clip-text text-transparent">
DearDiary.io
{appName}
</span>
</a>
<div className="flex gap-6">
<a href="/" className="text-slate-300 hover:text-white transition">Today</a>
<a href="/history" className="text-slate-300 hover:text-white transition">History</a>
<div className="flex items-center gap-4">
<button
onClick={onSearch}
className="flex items-center gap-2 px-3 py-1.5 text-sm text-slate-400 hover:text-white bg-slate-800 hover:bg-slate-700 rounded-lg transition"
title="Search (Ctrl+K)"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<span className="hidden sm:inline">Search</span>
<kbd className="hidden sm:inline px-1.5 py-0.5 text-xs bg-slate-700 rounded">Ctrl+K</kbd>
</button>
<button
onClick={onQuickAdd}
className="w-8 h-8 flex items-center justify-center bg-purple-600 hover:bg-purple-700 rounded-lg font-bold transition"
title="Quick Add (Ctrl+J)"
>
+
</button>
<a href="/" className="text-slate-300 hover:text-white transition">Dashboard</a>
<a href="/today" className="text-slate-300 hover:text-white transition">Today</a>
<a href="/calendar" className="text-slate-300 hover:text-white transition">Calendar</a>
<a href="/diary" className="text-slate-300 hover:text-white transition">Diary</a>
<a href="/settings" className="text-slate-300 hover:text-white transition">Settings</a>
</div>
</div>
@@ -52,17 +77,20 @@ function Navbar() {
);
}
function Footer() {
function Footer({ appName = 'DearDiary' }: { appName?: string }) {
const { resolvedTheme } = useTheme();
return (
<footer className={`py-6 text-center text-sm text-slate-500 ${resolvedTheme === 'dark' ? 'border-t border-slate-800' : 'border-t border-slate-200'}`}>
<p>DearDiary.io Self-hosted AI-powered journaling · <a href="https://github.com/lotherk/deardiary" className="hover:text-purple-400 transition">GitHub</a> · <a href="https://deardiary.io" className="hover:text-purple-400 transition">deardiary.io</a> · MIT License · © 2024 Konrad Lother</p>
<p>{appName} v{packageJson.version} Self-hosted AI-powered journaling · <a href="https://github.com/lotherk/deardiary" className="hover:text-purple-400 transition">GitHub</a> · <a href="https://deardiary.io" className="hover:text-purple-400 transition">deardiary.io</a> · MIT License · © 2026 Konrad Lother</p>
</footer>
);
}
function App() {
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
const [showQuickAdd, setShowQuickAdd] = useState(false);
const [showSearch, setShowSearch] = useState(false);
const [appName, setAppName] = useState('DearDiary');
const { resolvedTheme } = useTheme();
useEffect(() => {
@@ -70,27 +98,61 @@ function App() {
setIsAuthenticated(!!key);
}, []);
if (isAuthenticated === null) {
return (
<div className={`min-h-screen flex items-center justify-center ${resolvedTheme === 'dark' ? 'bg-slate-950' : 'bg-white'}`}>
<div className="animate-pulse text-slate-400">Loading...</div>
</div>
);
}
useEffect(() => {
const fetchServerInfo = async () => {
try {
const res = await fetch('/api/v1/server-info');
const data = await res.json();
if (data.data?.appName) {
setAppName(data.data.appName);
}
} catch {
// Use default app name
}
};
fetchServerInfo();
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'j') {
e.preventDefault();
setShowQuickAdd(true);
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
setShowSearch(true);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
return (
<BrowserRouter>
<div className={`min-h-screen ${resolvedTheme === 'dark' ? 'bg-slate-950 text-slate-100' : 'bg-white text-slate-900'}`}>
{isAuthenticated ? <Navbar /> : null}
{isAuthenticated && (
<>
<Navbar onQuickAdd={() => setShowQuickAdd(true)} onSearch={() => setShowSearch(true)} appName={appName} />
<QuickAddWidget isOpen={showQuickAdd} onClose={() => setShowQuickAdd(false)} />
<SearchModal isOpen={showSearch} onClose={() => setShowSearch(false)} />
</>
)}
<Routes>
<Route path="/login" element={
isAuthenticated ? <Navigate to="/" replace /> : <Auth onAuth={() => setIsAuthenticated(true)} />
} />
<Route path="/" element={
<PrivateRoute><Dashboard /></PrivateRoute>
} />
<Route path="/today" element={
<PrivateRoute><Home /></PrivateRoute>
} />
<Route path="/history" element={
<PrivateRoute><History /></PrivateRoute>
<Route path="/diary" element={
<PrivateRoute><Diary /></PrivateRoute>
} />
<Route path="/calendar" element={
<PrivateRoute><Calendar /></PrivateRoute>
} />
<Route path="/day/:date" element={
<PrivateRoute><Day /></PrivateRoute>
@@ -106,7 +168,7 @@ function App() {
} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<Footer />
<Footer appName={appName} />
</div>
</BrowserRouter>
);

View File

@@ -0,0 +1,96 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
interface DateNavigatorProps {
currentDate?: string;
}
export default function DateNavigator({ currentDate }: DateNavigatorProps) {
const navigate = useNavigate();
const today = new Date().toISOString().split('T')[0];
const [inputValue, setInputValue] = useState(currentDate || today);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
const handleInputSubmit = (e: React.FormEvent) => {
e.preventDefault();
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (dateRegex.test(inputValue)) {
navigate(`/day/${inputValue}`);
}
};
const goToPrev = () => {
if (!currentDate) return;
const prev = new Date(currentDate + 'T12:00:00');
prev.setDate(prev.getDate() - 1);
const prevStr = prev.toISOString().split('T')[0];
navigate(`/day/${prevStr}`);
};
const goToNext = () => {
if (!currentDate) return;
const next = new Date(currentDate + 'T12:00:00');
next.setDate(next.getDate() + 1);
const nextStr = next.toISOString().split('T')[0];
const todayDate = new Date();
todayDate.setHours(23, 59, 59, 999);
if (next <= todayDate) {
navigate(`/day/${nextStr}`);
}
};
const goToToday = () => {
navigate(`/day/${today}`);
};
const isToday = currentDate === today;
const isFuture = currentDate ? new Date(currentDate) > new Date() : false;
return (
<div className="flex items-center gap-2">
<button
onClick={goToPrev}
disabled={!currentDate}
className="p-2 hover:bg-slate-800 rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed"
title="Previous day"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<form onSubmit={handleInputSubmit} className="flex items-center gap-2">
<input
type="date"
value={inputValue}
onChange={handleInputChange}
max={isFuture ? undefined : today}
className="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-sm focus:border-purple-500 focus:outline-none"
/>
</form>
<button
onClick={goToNext}
disabled={!currentDate || isToday || isFuture}
className="p-2 hover:bg-slate-800 rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed"
title="Next day"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
{!isToday && currentDate && (
<button
onClick={goToToday}
className="px-3 py-1.5 text-sm text-purple-400 hover:text-purple-300 transition"
>
Today
</button>
)}
</div>
);
}

View File

@@ -11,39 +11,54 @@ export default function EntryList({ events, onDelete, readOnly }: Props) {
return new Date(dateStr).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
};
const getTypeIcon = (type: string) => {
switch (type) {
case 'photo': return '📷';
case 'voice': return '🎤';
case 'health': return '💚';
default: return '📝';
}
};
return (
<div className="space-y-3">
{readOnly && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg p-3 text-sm text-amber-400">
🔒 Events are locked because a diary page has been written. Delete the diary page to edit events.
</div>
)}
{events.map((event) => (
<div
key={event.id}
className="bg-slate-900 rounded-lg p-4 border border-slate-800 border-l-4 border-l-blue-500"
className="bg-slate-900 rounded-lg p-4 border border-slate-800"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">📝</span>
<span className="text-lg">{getTypeIcon(event.type)}</span>
<span className="text-xs text-slate-500">{formatTime(event.createdAt)}</span>
<span className="text-xs px-2 py-0.5 bg-slate-800 rounded text-slate-400 capitalize">{event.type}</span>
</div>
<p className="text-slate-200">{event.content}</p>
{event.metadata && (
{event.mediaPath && (
<div className="mt-3">
{event.type === 'photo' && (
<img
src={event.mediaPath}
alt="Event photo"
className="max-w-md rounded-lg border border-slate-700"
loading="lazy"
/>
)}
{event.type === 'voice' && (
<audio controls className="w-full max-w-md mt-2">
<source src={event.mediaPath} type="audio/webm" />
Your browser does not support audio.
</audio>
)}
</div>
)}
{(event.placeName || (event.latitude && event.longitude)) && (
<div className="mt-2 text-xs text-slate-500">
{(() => {
try {
const meta = JSON.parse(event.metadata);
return meta.location ? (
<span>📍 {meta.location.lat?.toFixed(4)}, {meta.location.lng?.toFixed(4)}</span>
) : meta.duration ? (
<span> {meta.duration}s</span>
) : null;
} catch {
return null;
}
})()}
📍 {event.placeName || `${event.latitude?.toFixed(4)}, ${event.longitude?.toFixed(4)}`}
</div>
)}
</div>

View File

@@ -0,0 +1,120 @@
import { useState, useRef, useEffect } from 'react';
import { api } from '../lib/api';
import { getCurrentLocation } from '../lib/geolocation';
interface Props {
isOpen: boolean;
onClose: () => void;
}
export default function QuickAddWidget({ isOpen, onClose }: Props) {
const [type, setType] = useState('event');
const [content, setContent] = useState('');
const [loading, setLoading] = useState(false);
const [locked, setLocked] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const today = new Date().toISOString().split('T')[0];
useEffect(() => {
if (isOpen) {
checkDiaryStatus();
}
}, [isOpen]);
const checkDiaryStatus = async () => {
const res = await api.getDay(today);
if (res.data) {
setLocked(!!res.data.journal);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!content.trim() || locked) return;
setLoading(true);
const location = await getCurrentLocation();
await api.createEvent(today, type, content, undefined, location ?? undefined);
setContent('');
setLoading(false);
onClose();
};
if (!isOpen) return null;
if (locked) {
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-20">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div className="relative bg-slate-800 rounded-2xl shadow-2xl w-full max-w-md mx-4 p-6 text-center">
<p className="text-slate-300 mb-2">Today's diary is locked</p>
<p className="text-slate-500 text-sm mb-4">Delete the diary to add more events.</p>
<a
href={`/journal/${today}`}
className="inline-block px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg text-sm font-medium transition"
>
View Diary
</a>
</div>
</div>
);
}
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-20">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div className="relative bg-slate-800 rounded-2xl shadow-2xl w-full max-w-md mx-4">
<form onSubmit={handleSubmit} className="p-4">
<div className="flex gap-2 mb-3">
<button
type="button"
onClick={() => setType('event')}
className={`px-3 py-1 rounded text-sm ${type === 'event' ? 'bg-purple-600' : 'bg-slate-700'}`}
>
Event
</button>
<button
type="button"
onClick={() => setType('health')}
className={`px-3 py-1 rounded text-sm ${type === 'health' ? 'bg-purple-600' : 'bg-slate-700'}`}
>
Health
</button>
<button
type="button"
onClick={() => setType('photo')}
className={`px-3 py-1 rounded text-sm ${type === 'photo' ? 'bg-purple-600' : 'bg-slate-700'}`}
>
Photo
</button>
<button
type="button"
onClick={() => setType('voice')}
className={`px-3 py-1 rounded text-sm ${type === 'voice' ? 'bg-purple-600' : 'bg-slate-700'}`}
>
Voice
</button>
</div>
<div className="flex gap-2">
<input
ref={inputRef}
type="text"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={type === 'health' ? 'How are you feeling?' : 'Log an event...'}
className="flex-1 px-4 py-3 bg-slate-900 rounded-lg border border-slate-700 focus:border-purple-500 focus:outline-none"
/>
<button
type="submit"
disabled={loading || !content.trim()}
className="px-6 py-3 bg-purple-600 hover:bg-purple-700 rounded-lg font-medium transition disabled:opacity-50"
>
+
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,159 @@
import { useState, useEffect, useRef } from 'react';
import { api } from '../lib/api';
interface SearchModalProps {
isOpen: boolean;
onClose: () => void;
}
export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<{
journals: Array<{ date: string; title: string; excerpt: string }>;
events: Array<{ date: string; type: string; content: string }>;
}>({ journals: [], events: [] });
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
useEffect(() => {
if (!query || query.length < 2) {
setResults({ journals: [], events: [] });
return;
}
const timer = setTimeout(async () => {
setLoading(true);
const res = await api.search(query);
if (res.data) {
setResults(res.data);
}
setLoading(false);
}, 300);
return () => clearTimeout(timer);
}, [query]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
}
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
const formatDate = (dateStr: string) => {
const d = new Date(dateStr + 'T12:00:00');
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
};
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]" onClick={onClose}>
<div className="absolute inset-0 bg-black/50" />
<div
className="relative w-full max-w-2xl mx-4 bg-slate-900 rounded-xl border border-slate-700 shadow-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="p-4 border-b border-slate-800">
<div className="flex items-center gap-3">
<svg className="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search diaries and events..."
className="flex-1 bg-transparent text-lg outline-none placeholder-slate-500"
/>
<kbd className="px-2 py-1 text-xs bg-slate-800 text-slate-400 rounded">ESC</kbd>
</div>
</div>
<div className="max-h-[60vh] overflow-y-auto">
{loading && (
<div className="p-8 text-center text-slate-400">
Searching...
</div>
)}
{!loading && query.length >= 2 && results.journals.length === 0 && results.events.length === 0 && (
<div className="p-8 text-center text-slate-400">
No results found for "{query}"
</div>
)}
{!loading && (results.journals.length > 0 || results.events.length > 0) && (
<div className="p-4 space-y-6">
{results.journals.length > 0 && (
<div>
<h3 className="text-sm font-medium text-slate-400 mb-3">
Diary Pages ({results.journals.length})
</h3>
<div className="space-y-2">
{results.journals.map((j, i) => (
<a
key={i}
href={`/journal/${j.date}`}
onClick={onClose}
className="block p-3 bg-slate-800/50 hover:bg-slate-800 rounded-lg transition"
>
<div className="flex items-center justify-between mb-1">
<span className="font-medium">{j.title || formatDate(j.date)}</span>
<span className="text-xs text-slate-500">{formatDate(j.date)}</span>
</div>
<p className="text-sm text-slate-400 line-clamp-2">{j.excerpt}...</p>
</a>
))}
</div>
</div>
)}
{results.events.length > 0 && (
<div>
<h3 className="text-sm font-medium text-slate-400 mb-3">
Events ({results.events.length})
</h3>
<div className="space-y-2">
{results.events.map((e, i) => (
<a
key={i}
href={`/day/${e.date}`}
onClick={onClose}
className="block p-3 bg-slate-800/50 hover:bg-slate-800 rounded-lg transition"
>
<div className="flex items-center justify-between mb-1">
<span className="text-xs px-2 py-0.5 bg-slate-700 rounded">{e.type}</span>
<span className="text-xs text-slate-500">{formatDate(e.date)}</span>
</div>
<p className="text-sm text-slate-300">{e.content}</p>
</a>
))}
</div>
</div>
)}
</div>
)}
{!loading && query.length < 2 && (
<div className="p-8 text-center text-slate-500">
Type at least 2 characters to search
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -46,10 +46,6 @@ class ApiClient {
return response.json() as Promise<ApiResponse<T>>;
}
async register(email: string, password: string) {
return this.request<{ user: { id: string; email: string } }>('POST', '/auth/register', { email, password });
}
async login(email: string, password: string) {
return this.request<{ token: string; userId: string }>('POST', '/auth/login', { email, password });
}
@@ -68,7 +64,7 @@ class ApiClient {
}
async getDays() {
return this.request<Array<{ date: string; eventCount: number; hasJournal: boolean }>>('GET', '/days');
return this.request<Array<{ date: string; eventCount: number; hasJournal: boolean; journalTitle?: string; journalExcerpt?: string }>>('GET', '/days');
}
async getDay(date: string) {
@@ -79,8 +75,8 @@ class ApiClient {
return this.request<{ deleted: boolean }>('DELETE', `/days/${date}`);
}
async createEvent(date: string, type: string, content: string, metadata?: object) {
return this.request<Event>('POST', '/events', { date, type, content, metadata });
async createEvent(date: string, type: string, content: string, metadata?: object, location?: { latitude: number; longitude: number; placeName?: string }) {
return this.request<Event>('POST', '/events', { date, type, content, metadata, ...location });
}
async updateEvent(id: string, content: string, metadata?: object) {
@@ -129,14 +125,18 @@ class ApiClient {
return response.json() as Promise<ApiResponse<{ mediaPath: string }>>;
}
async generateJournal(date: string) {
return this.request<{ journal: Journal; task: Task }>('POST', `/journal/generate/${date}`);
async generateJournal(date: string, instructions?: string) {
return this.request<{ journal: Journal; task: Task }>('POST', `/journal/generate/${date}`, { instructions: instructions || '' });
}
async getJournal(date: string) {
return this.request<Journal>('GET', `/journal/${date}`);
}
async getJournals(page: number = 1, limit: number = 10) {
return this.request<{ journals: Journal[]; total: number; page: number; limit: number; totalPages: number }>('GET', `/journals?page=${page}&limit=${limit}`);
}
async getJournalTasks(date: string) {
return this.request<Task[]>('GET', `/journal/${date}/tasks`);
}
@@ -149,9 +149,37 @@ class ApiClient {
return this.request<Settings>('GET', '/settings');
}
async search(query: string) {
return this.request<{ journals: Array<{ date: string; title: string; excerpt: string }>; events: Array<{ date: string; type: string; content: string }> }>('GET', `/search?q=${encodeURIComponent(query)}`);
}
async updateSettings(settings: Partial<Settings>) {
return this.request<Settings>('PUT', '/settings', settings);
}
async exportData() {
return this.request<ExportData>('GET', '/export');
}
async importData(data: ExportData) {
return this.request<ImportResult>('POST', '/import', data);
}
async deleteAccount() {
return this.request<{ deleted: boolean }>('DELETE', '/account');
}
async resetAccount() {
return this.request<{ reset: boolean }>('POST', '/account/reset');
}
async changePassword(currentPassword: string, newPassword: string) {
return this.request<{ changed: boolean }>('POST', '/account/password', { currentPassword, newPassword });
}
async register(email: string, password: string) {
return this.request<{ apiKey: string; userId: string }>('POST', '/auth/register', { email, password });
}
}
export interface Event {
@@ -161,12 +189,16 @@ export interface Event {
content: string;
mediaPath?: string;
metadata?: string;
latitude?: number;
longitude?: number;
placeName?: string;
createdAt: string;
}
export interface Journal {
id: string;
date: string;
title?: string;
content: string;
eventCount: number;
generatedAt: string;
@@ -183,6 +215,7 @@ export interface Task {
request?: string;
response?: string;
error?: string;
title?: string | null;
createdAt: string;
completedAt?: string;
}
@@ -203,4 +236,69 @@ export interface Settings {
}>;
}
export interface ExportData {
version: string;
exportedAt: string;
settings: {
aiProvider: string;
aiApiKey?: string;
aiModel?: string;
aiBaseUrl?: string;
journalPrompt?: string;
language?: string;
timezone?: string;
providerSettings?: string;
journalContextDays?: number;
};
events: Array<{
id: string;
date: string;
type: string;
content: string;
mediaPath?: string;
metadata?: string;
latitude?: number;
longitude?: number;
placeName?: string;
createdAt: string;
}>;
journals: Array<{
id: string;
date: string;
title?: string;
content: string;
eventCount: number;
generatedAt: string;
}>;
tasks: Array<{
id: string;
journalId: string;
date: string;
type: string;
status: string;
provider: string;
model?: string;
prompt?: string;
request?: string;
response?: string;
error?: string;
title?: string;
createdAt: string;
completedAt?: string;
}>;
}
export interface ImportResult {
compatible: boolean;
importedEvents: number;
importedJournals: number;
importedTasks: number;
skippedEvents: number;
skippedJournals: number;
totalEvents: number;
totalJournals: number;
totalTasks: number;
warning?: string;
}
export const api = new ApiClient();

View File

@@ -0,0 +1,50 @@
export interface Geolocation {
latitude: number;
longitude: number;
placeName?: string;
}
export async function getCurrentLocation(): Promise<Geolocation | null> {
if (!navigator.geolocation) {
return null;
}
return new Promise((resolve) => {
navigator.geolocation.getCurrentPosition(
async (position) => {
const { latitude, longitude } = position.coords;
let placeName: string | undefined;
try {
const response = await fetch(
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=18&addressdetails=1`
);
const data = await response.json();
if (data.address) {
const parts = [];
if (data.address.building || data.address.house_number) {
parts.push(data.address.building || data.address.house_number);
}
if (data.address.road) {
parts.push(data.address.road);
}
if (data.address.city || data.address.town || data.address.village) {
parts.push(data.address.city || data.address.town || data.address.village);
}
if (parts.length > 0) {
placeName = parts.slice(0, 2).join(', ');
}
}
} catch {
// Geocoding failed, continue without place name
}
resolve({ latitude, longitude, placeName });
},
() => {
resolve(null);
},
{ enableHighAccuracy: true, timeout: 5000, maximumAge: 60000 }
);
});
}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { api } from '../lib/api';
export default function Auth({ onAuth }: { onAuth: () => void }) {
@@ -7,6 +7,26 @@ export default function Auth({ onAuth }: { onAuth: () => void }) {
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [registrationEnabled, setRegistrationEnabled] = useState(false);
const [appName, setAppName] = useState('DearDiary');
useEffect(() => {
const fetchServerInfo = async () => {
try {
const res = await fetch('/api/v1/server-info');
const data = await res.json();
if (data.data) {
setRegistrationEnabled(data.data.registrationEnabled);
if (data.data.appName) {
setAppName(data.data.appName);
}
}
} catch {
// Server info not available, use defaults
}
};
fetchServerInfo();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -44,7 +64,7 @@ export default function Auth({ onAuth }: { onAuth: () => void }) {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-md">
<h1 className="text-3xl font-bold text-center mb-8 text-slate-100">DearDiary</h1>
<h1 className="text-3xl font-bold text-center mb-8 text-slate-100">{appName}</h1>
<div className="bg-slate-900 rounded-xl p-6 border border-slate-800">
<div className="flex gap-4 mb-6">
@@ -56,14 +76,16 @@ export default function Auth({ onAuth }: { onAuth: () => void }) {
>
Login
</button>
<button
onClick={() => setMode('register')}
className={`flex-1 py-2 rounded-lg transition ${
mode === 'register' ? 'bg-slate-700 text-white' : 'text-slate-400 hover:text-white'
}`}
>
Register
</button>
{registrationEnabled && (
<button
onClick={() => setMode('register')}
className={`flex-1 py-2 rounded-lg transition ${
mode === 'register' ? 'bg-slate-700 text-white' : 'text-slate-400 hover:text-white'
}`}
>
Register
</button>
)}
</div>
<form onSubmit={handleSubmit} className="space-y-4">

View File

@@ -0,0 +1,200 @@
import { useState, useEffect } from 'react';
import { api } from '../lib/api';
interface DayInfo {
date: string;
eventCount: number;
hasJournal: boolean;
}
export default function Calendar() {
const [currentDate, setCurrentDate] = useState(new Date());
const [days, setDays] = useState<DayInfo[]>([]);
useEffect(() => {
loadMonth();
}, [currentDate]);
const loadMonth = async () => {
const res = await api.getDays();
if (res.data) {
setDays(res.data);
}
};
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const firstDayOfMonth = new Date(year, month, 1);
const lastDayOfMonth = new Date(year, month + 1, 0);
const startingDay = firstDayOfMonth.getDay();
const daysInMonth = lastDayOfMonth.getDate();
const monthName = currentDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
const prevMonth = () => {
setCurrentDate(new Date(year, month - 1, 1));
};
const nextMonth = () => {
setCurrentDate(new Date(year, month + 1, 1));
};
const goToToday = () => {
setCurrentDate(new Date());
};
const formatDate = (day: number) => {
return `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
};
const getDayInfo = (day: number): DayInfo | undefined => {
const dateStr = formatDate(day);
return days.find(d => d.date === dateStr);
};
const isToday = (day: number) => {
const today = new Date();
return today.getDate() === day && today.getMonth() === month && today.getFullYear() === year;
};
const isFuture = (day: number) => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const dayDate = new Date(year, month, day);
return dayDate > today;
};
const weeks = [];
let currentWeek = Array(startingDay).fill(null);
for (let day = 1; day <= daysInMonth; day++) {
currentWeek.push(day);
if (currentWeek.length === 7) {
weeks.push(currentWeek);
currentWeek = [];
}
}
if (currentWeek.length > 0) {
while (currentWeek.length < 7) {
currentWeek.push(null);
}
weeks.push(currentWeek);
}
return (
<div className="max-w-4xl mx-auto p-4">
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<div>
<a href="/" className="text-sm text-slate-400 hover:text-white transition"> Dashboard</a>
<h1 className="text-2xl font-bold mt-2">Calendar</h1>
</div>
<button
onClick={goToToday}
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg text-sm font-medium transition"
>
Today
</button>
</div>
<div className="flex items-center justify-between">
<button
onClick={prevMonth}
className="p-2 hover:bg-slate-800 rounded-lg transition"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<h2 className="text-xl font-semibold">{monthName}</h2>
<button
onClick={nextMonth}
className="p-2 hover:bg-slate-800 rounded-lg transition"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
<div className="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
<div className="grid grid-cols-7 border-b border-slate-800">
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
<div key={day} className="p-3 text-center text-sm font-medium text-slate-400">
{day}
</div>
))}
</div>
<div className="grid grid-cols-7">
{weeks.flat().map((day, index) => {
if (day === null) {
return <div key={`empty-${index}`} className="min-h-[80px] p-2 border-r border-b border-slate-800 bg-slate-950/50" />;
}
const dayInfo = getDayInfo(day);
const dateStr = formatDate(day);
const future = isFuture(day);
const today = isToday(day);
return (
<div
key={day}
className={`min-h-[80px] p-2 border-r border-b border-slate-800 ${
future ? 'bg-slate-950/30' : 'bg-slate-900'
}`}
>
<div className="flex items-center justify-between mb-1">
<span className={`text-sm ${today ? 'w-7 h-7 flex items-center justify-center bg-purple-600 rounded-full text-white font-bold' : ''} ${
future ? 'text-slate-600' : 'text-slate-300'
}`}>
{day}
</span>
</div>
{!future && dayInfo && (
<div className="space-y-1">
{dayInfo.hasJournal ? (
<a
href={`/journal/${dateStr}`}
className="block text-xs px-2 py-1 bg-purple-600/20 text-purple-400 rounded hover:bg-purple-600/30 transition"
title={`Diary: ${dayInfo.eventCount} events`}
>
📖 {dayInfo.eventCount}
</a>
) : dayInfo.eventCount > 0 ? (
<a
href={`/day/${dateStr}`}
className="block text-xs px-2 py-1 bg-slate-700/50 text-slate-400 rounded hover:bg-slate-700 transition"
title={`${dayInfo.eventCount} events`}
>
📝 {dayInfo.eventCount}
</a>
) : null}
</div>
)}
</div>
);
})}
</div>
</div>
<div className="mt-6 flex items-center gap-6 text-sm text-slate-400">
<div className="flex items-center gap-2">
<span className="w-4 h-4 bg-purple-600/20 rounded"></span>
<span>Has diary</span>
</div>
<div className="flex items-center gap-2">
<span className="w-4 h-4 bg-slate-700/50 rounded"></span>
<span>Has events</span>
</div>
<div className="flex items-center gap-2">
<span className="w-4 h-4 bg-slate-950/50 border border-slate-800 rounded"></span>
<span>Future</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,165 @@
import { useState, useEffect } from 'react';
import { api, Event } from '../lib/api';
interface DayInfo {
date: string;
eventCount: number;
hasJournal: boolean;
journalTitle?: string;
journalGeneratedAt?: string;
journalExcerpt?: string;
}
interface TodayInfo {
eventCount: number;
hasJournal: boolean;
events: Event[];
}
export default function Dashboard() {
const [days, setDays] = useState<DayInfo[]>([]);
const [today, setToday] = useState<TodayInfo | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
setLoading(true);
const todayDate = new Date().toISOString().split('T')[0];
const [daysRes, todayRes] = await Promise.all([
api.getDays(),
api.getDay(todayDate)
]);
if (daysRes.data) {
setDays(daysRes.data.slice(0, 7));
}
if (todayRes.data) {
setToday({
eventCount: todayRes.data.events.length,
hasJournal: !!todayRes.data.journal,
events: todayRes.data.events
});
}
setLoading(false);
};
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (dateStr === today.toISOString().split('T')[0]) return 'Today';
if (dateStr === yesterday.toISOString().split('T')[0]) return 'Yesterday';
return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
};
if (loading) {
return (
<div className="max-w-4xl mx-auto p-4">
<div className="text-center py-12 text-slate-400">Loading...</div>
</div>
);
}
return (
<div className="max-w-4xl mx-auto p-4">
<div className="mb-8">
<h1 className="text-2xl font-bold mb-2">Dashboard</h1>
<p className="text-slate-400">Your recent diary entries</p>
</div>
{today && (
<div className="mb-8 bg-gradient-to-br from-purple-900/50 to-pink-900/50 rounded-2xl p-6 border border-purple-500/20">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-xl font-bold">Today</h2>
<p className="text-purple-300 text-sm">
{today.eventCount} {today.eventCount === 1 ? 'event' : 'events'} captured
{today.hasJournal && <span className="ml-2 text-amber-400">🔒 Locked</span>}
</p>
</div>
<div className="flex gap-2">
{today.hasJournal ? (
<a href={`/journal/${new Date().toISOString().split('T')[0]}`} className="px-4 py-2 bg-amber-600 hover:bg-amber-700 rounded-lg text-sm font-medium transition">
View Page
</a>
) : today.eventCount > 0 ? (
<a href="/today" className="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg text-sm font-medium transition">
Generate Diary
</a>
) : null}
</div>
</div>
{today.events.length > 0 ? (
<div className="space-y-2">
{today.events.slice(-3).reverse().map((event) => (
<div key={event.id} className="flex items-center gap-3 text-sm">
<span className="text-slate-500 w-12">{formatTime(event.createdAt)}</span>
<span className="text-slate-300">{event.content}</span>
</div>
))}
{today.events.length > 3 && (
<a href="/today" className="text-purple-400 text-sm hover:text-purple-300">
+{today.events.length - 3} more events
</a>
)}
</div>
) : (
<p className="text-slate-400 text-sm">No events captured today yet.</p>
)}
</div>
)}
<h3 className="text-lg font-medium mb-4 text-slate-300">Recent Diary Pages</h3>
<div className="grid gap-4">
{days.length === 0 ? (
<div className="text-center py-12 text-slate-400">
<p>No diary entries yet.</p>
<a href="/today" className="text-purple-400 hover:text-purple-300 mt-2 inline-block">
Start capturing your day
</a>
</div>
) : (
days.map((day) => (
<a
key={day.date}
href={day.hasJournal ? `/journal/${day.date}` : `/day/${day.date}`}
className="block bg-slate-900 rounded-xl p-4 border border-slate-800 hover:border-slate-700 transition"
>
<div className="flex items-center justify-between mb-2">
<h3 className="font-medium">{day.journalTitle || formatDate(day.date)}</h3>
{day.hasJournal ? (
<span className="text-xs px-2 py-1 bg-purple-500/20 text-purple-400 rounded">Diary</span>
) : (
<span className="text-xs px-2 py-1 bg-slate-700 text-slate-400 rounded">Draft</span>
)}
</div>
{day.journalExcerpt && (
<p className="text-sm text-slate-400 mb-2 line-clamp-2">
{day.journalExcerpt}...
</p>
)}
<div className="text-sm text-slate-500">
{day.eventCount} {day.eventCount === 1 ? 'event' : 'events'}
{day.hasJournal && day.journalGeneratedAt && (
<span className="ml-2">
· {formatDate(day.date)}
</span>
)}
</div>
</a>
))
)}
</div>
</div>
);
}

View File

@@ -1,8 +1,10 @@
import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { api, Event } from '../lib/api';
import { getCurrentLocation } from '../lib/geolocation';
import EntryInput from '../components/EntryInput';
import EntryList from '../components/EntryList';
import DateNavigator from '../components/DateNavigator';
export default function Day() {
const { date } = useParams<{ date: string }>();
@@ -28,7 +30,8 @@ export default function Day() {
const handleAddEvent = async (type: string, content: string, metadata?: object) => {
if (!date) return { error: { message: 'No date' } };
const res = await api.createEvent(date, type, content, metadata);
const location = await getCurrentLocation();
const res = await api.createEvent(date, type, content, metadata, location ?? undefined);
if (res.data) {
setEvents((prev) => [...prev, res.data!]);
}
@@ -65,22 +68,25 @@ export default function Day() {
<div className="max-w-4xl mx-auto p-4">
<div className="flex items-center justify-between mb-6">
<div>
<a href="/history" className="text-slate-400 hover:text-white text-sm mb-1 inline-block"> Back</a>
<a href="/" className="text-slate-400 hover:text-white text-sm mb-1 inline-block"> Dashboard</a>
<h1 className="text-2xl font-bold">{formatDate(date)}</h1>
</div>
{hasJournal && (
<div className="flex gap-2">
<button
onClick={handleDeleteJournal}
className="px-3 py-2 bg-red-600/20 hover:bg-red-600/30 text-red-400 rounded-lg text-sm transition"
>
Delete Diary
</button>
<a href={`/journal/${date}`} className="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg font-medium transition">
View Diary
</a>
</div>
)}
<div className="flex items-center gap-4">
<DateNavigator currentDate={date} />
{hasJournal && (
<div className="flex gap-2">
<button
onClick={handleDeleteJournal}
className="px-3 py-2 bg-red-600/20 hover:bg-red-600/30 text-red-400 rounded-lg text-sm transition"
>
Delete Diary
</button>
<a href={`/journal/${date}`} className="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg font-medium transition">
View Diary
</a>
</div>
)}
</div>
</div>
{deleteError && (
@@ -89,7 +95,13 @@ export default function Day() {
</div>
)}
<EntryInput onSubmit={handleAddEvent} />
{hasJournal ? (
<div className="mb-4 p-4 bg-slate-800/50 border border-slate-700 rounded-lg text-center">
<p className="text-slate-400 text-sm">Events are locked. <a href={`/journal/${date}`} className="text-purple-400 hover:underline">View diary</a> or <button onClick={handleDeleteJournal} className="text-red-400 hover:underline">delete diary</button> to add more events.</p>
</div>
) : (
<EntryInput onSubmit={handleAddEvent} />
)}
{loading ? (
<div className="text-center py-12 text-slate-400">Loading...</div>

View File

@@ -0,0 +1,154 @@
import { useState, useEffect } from 'react';
import { api, Journal } from '../lib/api';
const PAGE_SIZES = [10, 50, 100];
export default function Diary() {
const [journals, setJournals] = useState<Journal[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(10);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(0);
useEffect(() => {
loadJournals();
}, [page, limit]);
const loadJournals = async () => {
setLoading(true);
const res = await api.getJournals(page, limit);
if (res.data) {
setJournals(res.data.journals);
setTotal(res.data.total);
setTotalPages(res.data.totalPages);
}
setLoading(false);
};
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' });
};
const formatDateShort = (dateStr: string) => {
const d = new Date(dateStr + 'T12:00:00');
return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
};
return (
<div className="max-w-4xl mx-auto p-4">
<div className="mb-6">
<h1 className="text-2xl font-bold mb-2">Diary</h1>
<p className="text-slate-400 text-sm">Read your diary pages</p>
</div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<label className="text-sm text-slate-400">Show:</label>
<select
value={limit}
onChange={(e) => { setLimit(parseInt(e.target.value)); setPage(1); }}
className="bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-purple-500"
>
{PAGE_SIZES.map(size => (
<option key={size} value={size}>{size} per page</option>
))}
</select>
</div>
<div className="text-sm text-slate-400">
{total} diary pages total
</div>
</div>
{loading ? (
<div className="text-center py-12 text-slate-400">Loading...</div>
) : journals.length === 0 ? (
<div className="text-center py-12">
<p className="text-slate-400 mb-2">No diary pages yet</p>
<p className="text-slate-500 text-sm">Generate diary pages from your events to see them here</p>
</div>
) : (
<div className="space-y-4">
{journals.map((journal) => (
<div key={journal.id} className="bg-slate-900 rounded-xl p-6 border border-slate-800">
<div className="flex items-start justify-between mb-3">
<div>
<a href={`/journal/${journal.date}`} className="text-lg font-semibold hover:text-purple-400 transition">
{journal.title || formatDateShort(journal.date)}
</a>
<p className="text-sm text-slate-400">{formatDate(journal.date)}</p>
</div>
<div className="text-xs text-slate-500">
{journal.eventCount} events
</div>
</div>
<div className="prose prose-invert prose-sm prose-slate max-w-none">
<div className="text-slate-300 whitespace-pre-wrap leading-relaxed">
{journal.content}
</div>
</div>
<div className="mt-4 pt-3 border-t border-slate-800 flex justify-between items-center">
<span className="text-xs text-slate-500">
Generated {new Date(journal.generatedAt).toLocaleString()}
</span>
<a
href={`/journal/${journal.date}`}
className="text-sm text-purple-400 hover:text-purple-300 transition"
>
View & Edit
</a>
</div>
</div>
))}
</div>
)}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-8">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed transition"
>
Previous
</button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let pageNum: number;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (page <= 3) {
pageNum = i + 1;
} else if (page >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = page - 2 + i;
}
return (
<button
key={pageNum}
onClick={() => setPage(pageNum)}
className={`w-10 h-10 rounded text-sm transition ${
page === pageNum
? 'bg-purple-600 text-white'
: 'bg-slate-800 hover:bg-slate-700'
}`}
>
{pageNum}
</button>
);
})}
</div>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed transition"
>
Next
</button>
</div>
)}
</div>
);
}

View File

@@ -5,6 +5,7 @@ interface DayInfo {
date: string;
eventCount: number;
hasJournal: boolean;
journalTitle?: string;
}
export default function History() {
@@ -55,16 +56,14 @@ export default function History() {
className="flex items-center justify-between p-4 bg-slate-900 rounded-lg border border-slate-800"
>
<div className="flex items-center gap-4">
<a href={`/day/${day.date}`} className="font-medium hover:text-blue-400">
{formatDate(day.date)}
<a href={day.hasJournal ? `/journal/${day.date}` : `/day/${day.date}`} className="font-medium hover:text-blue-400">
{day.journalTitle || formatDate(day.date)}
</a>
<span className="text-slate-400 text-sm">
{day.eventCount} {day.eventCount === 1 ? 'event' : 'events'}
</span>
{day.hasJournal && (
<a href={`/journal/${day.date}`} className="text-purple-400 text-sm hover:text-purple-300">
Diary
</a>
<span className="text-purple-400 text-xs px-2 py-1 bg-purple-500/20 rounded">Diary</span>
)}
</div>
<button

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { api, Event } from '../lib/api';
import { getCurrentLocation } from '../lib/geolocation';
import EntryInput from '../components/EntryInput';
import EntryList from '../components/EntryList';
@@ -8,7 +9,9 @@ export default function Home() {
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [hasJournal, setHasJournal] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const navigate = useNavigate();
const today = new Date().toISOString().split('T')[0];
@@ -22,12 +25,14 @@ export default function Home() {
const res = await api.getDay(today);
if (res.data) {
setEvents(res.data.events);
setHasJournal(!!res.data.journal);
}
setLoading(false);
};
const handleAddEvent = async (type: string, content: string, metadata?: object) => {
const res = await api.createEvent(today, type, content, metadata);
const location = await getCurrentLocation();
const res = await api.createEvent(today, type, content, metadata, location ?? undefined);
if (res.data) {
setEvents((prev) => [...prev, res.data!]);
}
@@ -41,14 +46,26 @@ export default function Home() {
}
};
const handleDeleteJournal = async () => {
if (!confirm('Delete diary page? This will unlock events for editing.')) return;
const res = await api.deleteJournal(today);
if (res.data) {
setHasJournal(false);
setSuccess(null);
}
};
const handleGenerateJournal = async () => {
setGenerating(true);
setError(null);
setSuccess(null);
const res = await api.generateJournal(today);
setGenerating(false);
if (res.error) {
setError(res.error.message);
} else {
setHasJournal(true);
setSuccess('Diary page generated! Events are now locked.');
navigate(`/journal/${today}`);
}
};
@@ -57,16 +74,26 @@ export default function Home() {
<div className="max-w-4xl mx-auto p-4">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold">Today</h1>
<a href="/" className="text-sm text-slate-400 hover:text-white transition"> Dashboard</a>
<h1 className="text-2xl font-bold mt-2">Today</h1>
<p className="text-slate-400">{new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}</p>
</div>
<button
onClick={handleGenerateJournal}
disabled={generating || events.length === 0}
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg font-medium transition disabled:opacity-50"
>
{generating ? 'Generating...' : 'Generate Diary Page'}
</button>
{hasJournal ? (
<a
href={`/journal/${today}`}
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg font-medium transition"
>
View Diary Page
</a>
) : (
<button
onClick={handleGenerateJournal}
disabled={generating || events.length === 0}
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg font-medium transition disabled:opacity-50"
>
{generating ? 'Generating...' : 'Generate Diary Page'}
</button>
)}
</div>
{error && (
@@ -75,7 +102,19 @@ export default function Home() {
</div>
)}
<EntryInput onSubmit={handleAddEvent} />
{success && (
<div className="mb-4 p-4 bg-green-500/20 border border-green-500/30 rounded-lg text-green-400">
{success} <button onClick={handleDeleteJournal} className="underline hover:no-underline ml-2">Delete diary</button>
</div>
)}
{hasJournal ? (
<div className="mb-4 p-4 bg-slate-800/50 border border-slate-700 rounded-lg text-center">
<p className="text-slate-400 text-sm">Events are locked. <a href={`/journal/${today}`} className="text-purple-400 hover:underline">View diary</a> or <button onClick={handleDeleteJournal} className="text-red-400 hover:underline">delete diary</button> to add more events.</p>
</div>
) : (
<EntryInput onSubmit={handleAddEvent} />
)}
{loading ? (
<div className="text-center py-12 text-slate-400">Loading...</div>
@@ -85,10 +124,10 @@ export default function Home() {
<p className="text-slate-500 text-sm">Start capturing your day above</p>
</div>
) : (
<EntryList events={events} onDelete={handleDeleteEvent} />
<EntryList events={events} onDelete={handleDeleteEvent} readOnly={hasJournal} />
)}
{events.length > 0 && (
{events.length > 0 && hasJournal && (
<div className="mt-6 text-center">
<a href={`/journal/${today}`} className="text-purple-400 hover:text-purple-300 text-sm">
View diary page

View File

@@ -120,6 +120,54 @@ function GeneratingModal({
);
}
function RewriteModal({
isOpen,
instructions,
onChange,
onRewrite,
onCancel
}: {
isOpen: boolean;
instructions: string;
onChange: (v: string) => void;
onRewrite: () => void;
onCancel: () => void;
}) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onCancel} />
<div className="relative bg-slate-800 rounded-2xl p-6 shadow-2xl max-w-lg w-full mx-4">
<h3 className="text-xl font-semibold mb-4">Rewrite Diary Page</h3>
<p className="text-sm text-slate-400 mb-4">
Add any specific instructions or changes you want the AI to consider when rewriting your diary page.
</p>
<textarea
value={instructions}
onChange={(e) => onChange(e.target.value)}
placeholder="e.g., Focus more on the morning meeting, include more details about the lunch break..."
className="w-full h-32 px-4 py-3 bg-slate-900 rounded-lg border border-slate-700 focus:border-purple-500 focus:outline-none resize-none text-sm"
/>
<div className="flex gap-3 mt-4">
<button
onClick={onCancel}
className="flex-1 px-4 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg text-sm transition"
>
Cancel
</button>
<button
onClick={onRewrite}
className="flex-1 px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg text-sm font-medium transition"
>
Rewrite
</button>
</div>
</div>
</div>
);
}
export default function JournalPage() {
const { date } = useParams<{ date: string }>();
const [journal, setJournal] = useState<Journal | null>(null);
@@ -130,6 +178,8 @@ export default function JournalPage() {
const [currentModel, setCurrentModel] = useState('');
const [generatingStep, setGeneratingStep] = useState(0);
const [currentResponse, setCurrentResponse] = useState<string | undefined>();
const [showRewriteModal, setShowRewriteModal] = useState(false);
const [rewriteInstructions, setRewriteInstructions] = useState('');
useEffect(() => {
if (date) {
@@ -138,6 +188,13 @@ export default function JournalPage() {
}
}, [date]);
useEffect(() => {
if (!generating) {
loadJournal();
loadTasks();
}
}, [generating]);
const loadJournal = async () => {
if (!date) return;
setLoading(true);
@@ -156,9 +213,8 @@ export default function JournalPage() {
}
};
const handleGenerate = async () => {
const handleReRunTask = async (_task: Task) => {
if (!date) return;
setGenerating(true);
setGeneratingStep(1);
setCurrentProvider('ai');
@@ -167,6 +223,10 @@ export default function JournalPage() {
const res = await api.generateJournal(date);
setGeneratingStep(3);
if (res.error) {
setGenerating(false);
return;
}
if (res.data) {
setCurrentResponse(res.data.task.response);
setJournal(res.data.journal);
@@ -177,6 +237,41 @@ export default function JournalPage() {
setTimeout(() => setGenerating(false), 500);
};
const handleRewrite = async () => {
setShowRewriteModal(false);
if (!date) return;
setGenerating(true);
setGeneratingStep(1);
setCurrentProvider('ai');
setCurrentModel('');
setCurrentResponse(undefined);
const res = await api.generateJournal(date, rewriteInstructions);
setGeneratingStep(3);
if (res.error) {
setGenerating(false);
return;
}
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 handleDelete = async () => {
if (!date) return;
if (!confirm('Delete diary page? This will unlock events for editing.')) return;
const res = await api.deleteJournal(date);
if (res.data) {
window.location.href = `/day/${date}`;
}
};
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' });
@@ -194,6 +289,14 @@ export default function JournalPage() {
response={currentResponse}
/>
<RewriteModal
isOpen={showRewriteModal}
instructions={rewriteInstructions}
onChange={setRewriteInstructions}
onRewrite={handleRewrite}
onCancel={() => setShowRewriteModal(false)}
/>
<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>
@@ -207,7 +310,7 @@ export default function JournalPage() {
<div className="text-center py-12">
<p className="text-slate-400 mb-4">No diary page written yet</p>
<button
onClick={handleGenerate}
onClick={() => setShowRewriteModal(true)}
disabled={generating}
className="px-6 py-3 bg-purple-600 hover:bg-purple-700 rounded-lg font-medium transition disabled:opacity-50"
>
@@ -216,9 +319,12 @@ export default function JournalPage() {
</div>
) : (
<div>
<div className="text-sm text-slate-400 mb-4">
<div className="text-sm text-slate-400 mb-2">
Generated {new Date(journal.generatedAt).toLocaleString()} {journal.eventCount} events
</div>
{journal.title && (
<h2 className="text-xl font-bold mb-4">{journal.title}</h2>
)}
<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}
@@ -226,38 +332,30 @@ export default function JournalPage() {
</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"
onClick={() => setShowRewriteModal(true)}
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded-lg text-sm transition"
>
Rewrite
</button>
<a
href={`/tasks/${date}`}
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded-lg text-sm transition"
<button
onClick={handleDelete}
className="px-4 py-2 bg-red-600/20 hover:bg-red-600/30 text-red-400 rounded-lg text-sm transition"
>
View Tasks
</a>
Delete
</button>
</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>
<h2 className="text-lg font-medium mb-4">Generation Tasks</h2>
<div className="space-y-2">
{tasks.slice(0, 3).map(task => (
{tasks.map((task, index) => (
<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="text-slate-500 text-xs w-6">#{tasks.length - index}</span>
<span className={`w-2 h-2 rounded-full ${
task.status === 'completed' ? 'bg-green-500' :
task.status === 'failed' ? 'bg-red-500' : 'bg-yellow-500'
@@ -265,14 +363,60 @@ export default function JournalPage() {
<span className="font-medium">
{task.provider.charAt(0).toUpperCase() + task.provider.slice(1)}
</span>
{task.model && <span className="text-slate-500 text-xs">{task.model}</span>}
<span className="text-slate-600 text-xs">
{new Date(task.createdAt).toLocaleString()}
</span>
</div>
<div className="flex items-center gap-2">
{task.title && (
<span className="text-slate-400 text-xs italic max-w-[200px] truncate" title={task.title}>
"{task.title}"
</span>
)}
<button
onClick={(e) => { e.stopPropagation(); handleReRunTask(task); }}
disabled={generating}
className="text-slate-400 hover:text-white transition disabled:opacity-50 p-1"
title="Re-run this task"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<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>
</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>
<div className="p-3 pt-0 border-t border-slate-800 text-sm space-y-2">
{task.error && (
<div className="text-red-400 bg-red-500/10 rounded p-2">
Error: {task.error}
</div>
)}
{task.prompt && (
<div>
<div className="text-slate-500 text-xs mb-1">Prompt:</div>
<pre className="bg-slate-800 rounded p-2 text-xs overflow-auto max-h-32">{task.prompt}</pre>
</div>
)}
{task.request && (
<div>
<div className="text-slate-500 text-xs mb-1">Request:</div>
<pre className="bg-slate-800 rounded p-2 text-xs overflow-auto max-h-32">{task.request}</pre>
</div>
)}
{task.response && (
<div>
<div className="text-slate-500 text-xs mb-1">Response:</div>
<pre className="bg-slate-800 rounded p-2 text-xs overflow-auto max-h-32">{task.response}</pre>
</div>
)}
</div>
</details>
))}
</div>

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { api, Settings } from '../lib/api';
import { useState, useEffect, useRef } from 'react';
import { api, Settings, ExportData, ImportResult } from '../lib/api';
import { useTheme } from '../lib/ThemeContext';
import packageJson from '../../package.json';
interface ProviderSettings {
apiKey?: string;
@@ -39,6 +40,25 @@ export default function SettingsPage() {
const [saved, setSaved] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState<ImportResult | null>(null);
const [importWarning, setImportWarning] = useState<string | null>(null);
const [showImportConfirm, setShowImportConfirm] = useState(false);
const [pendingImport, setPendingImport] = useState<ExportData | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState('');
const [deleting, setDeleting] = useState(false);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [resetConfirmText, setResetConfirmText] = useState('');
const [resetting, setResetting] = useState(false);
const [showPasswordChange, setShowPasswordChange] = useState(false);
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [passwordError, setPasswordError] = useState('');
const [passwordSuccess, setPasswordSuccess] = useState('');
const [changingPassword, setChangingPassword] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { theme, setTheme } = useTheme();
useEffect(() => {
@@ -117,7 +137,7 @@ export default function SettingsPage() {
...settings,
aiProvider: provider as FullSettings['aiProvider'],
aiApiKey: newProviderSettingsData.apiKey,
aiModel: newProviderSettingsData.model || DEFAULT_MODELS[provider] || '',
aiModel: DEFAULT_MODELS[provider] || '',
aiBaseUrl: newProviderSettingsData.baseUrl,
providerSettings: newProviderSettings,
});
@@ -182,6 +202,178 @@ export default function SettingsPage() {
setTesting(false);
};
const handleExport = async () => {
setExporting(true);
try {
const res = await api.exportData();
if (res.data) {
const blob = new Blob([JSON.stringify(res.data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `deardiary-export-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} else {
alert('Export failed: ' + (res.error?.message || 'Unknown error'));
}
} catch (err) {
alert('Export failed: ' + (err instanceof Error ? err.message : 'Unknown error'));
}
setExporting(false);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const data = JSON.parse(event.target?.result as string) as ExportData;
if (!data.version || !data.events || !data.journals) {
alert('Invalid export file format');
return;
}
const fileVersion = data.version;
const currentVersion = packageJson.version;
const fileParts = fileVersion.split('.').map(Number);
const currentParts = currentVersion.split('.').map(Number);
const fileNum = fileParts[0] * 10000 + fileParts[1] * 100 + (fileParts[2] || 0);
const currentNum = currentParts[0] * 10000 + currentParts[1] * 100 + (currentParts[2] || 0);
if (fileNum < currentNum) {
setImportWarning(`This export was created with version ${fileVersion}, which is older than the current version ${currentVersion}. Import may fail or lose some data.`);
} else if (fileNum > currentNum) {
setImportWarning(`This export was created with version ${fileVersion}, which is newer than the current version ${currentVersion}. Some features may not work correctly.`);
} else {
setImportWarning(null);
}
setPendingImport(data);
setShowImportConfirm(true);
setImportResult(null);
} catch {
alert('Failed to parse export file');
}
};
reader.readAsText(file);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleImport = async () => {
if (!pendingImport) return;
setImporting(true);
setShowImportConfirm(false);
try {
const res = await api.importData(pendingImport);
if (res.error) {
setImportResult({
compatible: false,
importedEvents: 0,
importedJournals: 0,
importedTasks: 0,
skippedEvents: 0,
skippedJournals: 0,
totalEvents: 0,
totalJournals: 0,
totalTasks: 0,
warning: res.error.message,
});
} else if (res.data) {
setImportResult(res.data);
}
} catch (err) {
setImportResult({
compatible: false,
importedEvents: 0,
importedJournals: 0,
importedTasks: 0,
skippedEvents: 0,
skippedJournals: 0,
totalEvents: 0,
totalJournals: 0,
totalTasks: 0,
warning: err instanceof Error ? err.message : 'Import failed',
});
}
setPendingImport(null);
setImporting(false);
};
const handleDeleteAccount = async () => {
if (deleteConfirmText !== 'DELETE') return;
setDeleting(true);
try {
const res = await api.deleteAccount();
if (res.data?.deleted) {
window.location.href = '/login';
} else {
alert('Failed to delete account: ' + (res.error?.message || 'Unknown error'));
setDeleting(false);
}
} catch (err) {
alert('Failed to delete account: ' + (err instanceof Error ? err.message : 'Unknown error'));
setDeleting(false);
}
};
const handleResetAccount = async () => {
if (resetConfirmText !== 'RESET') return;
setResetting(true);
try {
const res = await api.resetAccount();
if (res.data?.reset) {
window.location.reload();
} else {
alert('Failed to reset account: ' + (res.error?.message || 'Unknown error'));
setResetting(false);
}
} catch (err) {
alert('Failed to reset account: ' + (err instanceof Error ? err.message : 'Unknown error'));
setResetting(false);
}
};
const handlePasswordChange = async () => {
if (newPassword.length < 6) {
setPasswordError('New password must be at least 6 characters');
return;
}
setChangingPassword(true);
setPasswordError('');
setPasswordSuccess('');
try {
const res = await api.changePassword(currentPassword, newPassword);
if (res.error) {
setPasswordError(res.error.message);
} else {
setPasswordSuccess('Password changed successfully');
setCurrentPassword('');
setNewPassword('');
setShowPasswordChange(false);
}
} catch (err) {
setPasswordError(err instanceof Error ? err.message : 'Failed to change password');
}
setChangingPassword(false);
};
const handleLogout = () => {
api.clearApiKey();
window.location.href = '/login';
@@ -322,13 +514,13 @@ export default function SettingsPage() {
<div className="space-y-4">
<div>
<label className="block text-sm text-slate-400 mb-1">System Prompt</label>
<label className="block text-sm text-slate-400 mb-1">Prompt</label>
<textarea
value={settings.journalPrompt || ''}
onChange={(e) => setSettings({ ...settings, journalPrompt: e.target.value })}
rows={4}
className="w-full px-4 py-2 bg-slate-800 rounded-lg border border-slate-700 focus:border-blue-500 focus:outline-none resize-none"
placeholder="Instructions for the AI journal writer..."
placeholder="Custom instructions for the diary writer (optional)..."
/>
</div>
@@ -397,6 +589,264 @@ export default function SettingsPage() {
</div>
</section>
<section className="bg-slate-900 rounded-xl p-6 border border-slate-800">
<h2 className="text-lg font-medium mb-4">Data Export / Import</h2>
<div className="space-y-4">
<div>
<p className="text-sm text-slate-400 mb-3">
Export your diary data as a JSON file. This includes all events, diary pages, generation tasks, and settings.
Your AI API keys are included so you won't need to reconfigure after import.
</p>
<div className="flex gap-3">
<button
onClick={handleExport}
disabled={exporting}
className="px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg text-sm font-medium transition disabled:opacity-50"
>
{exporting ? 'Exporting...' : 'Export All Data'}
</button>
<input
type="file"
ref={fileInputRef}
onChange={handleFileSelect}
accept=".json"
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={importing}
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg text-sm font-medium transition disabled:opacity-50"
>
{importing ? 'Importing...' : 'Import Data'}
</button>
</div>
</div>
{importWarning && !showImportConfirm && (
<div className="p-3 bg-amber-500/20 border border-amber-500/50 rounded-lg">
<p className="text-amber-400 text-sm">{importWarning}</p>
</div>
)}
{showImportConfirm && pendingImport && (
<div className="p-4 bg-slate-800 rounded-lg border border-slate-700">
<h3 className="font-medium mb-3">Ready to Import</h3>
<div className="text-sm text-slate-400 mb-4 space-y-1">
<p>Version: {pendingImport.version}</p>
<p>Exported: {new Date(pendingImport.exportedAt).toLocaleString()}</p>
<p>Events: {pendingImport.events.length}</p>
<p>Journals: {pendingImport.journals.length}</p>
<p>Tasks: {pendingImport.tasks.length}</p>
</div>
<div className="flex gap-3">
<button
onClick={handleImport}
disabled={importing}
className="px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg text-sm font-medium transition disabled:opacity-50"
>
{importing ? 'Importing...' : 'Confirm Import'}
</button>
<button
onClick={() => { setShowImportConfirm(false); setPendingImport(null); }}
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg text-sm transition"
>
Cancel
</button>
</div>
</div>
)}
{importResult && !showImportConfirm && (
<div className={`p-4 rounded-lg border ${importResult.compatible ? 'bg-green-500/10 border-green-500/30' : 'bg-red-500/10 border-red-500/30'}`}>
<h3 className={`font-medium mb-2 ${importResult.compatible ? 'text-green-400' : 'text-red-400'}`}>
{importResult.compatible ? 'Import Complete' : 'Import Warning'}
</h3>
{importResult.warning && (
<p className="text-amber-400 text-sm mb-2">{importResult.warning}</p>
)}
<div className="text-sm text-slate-400 space-y-1">
<p>Events: {importResult.importedEvents} imported, {importResult.skippedEvents} skipped</p>
<p>Journals: {importResult.importedJournals} imported, {importResult.skippedJournals} skipped</p>
<p>Tasks: {importResult.importedTasks} imported</p>
</div>
</div>
)}
</div>
</section>
<section className="bg-red-950/30 rounded-xl p-6 border border-red-900/50">
<h2 className="text-lg font-medium mb-4 text-red-400">Danger Zone</h2>
<div className="space-y-4">
<div>
<p className="text-sm text-slate-400 mb-3">
Permanently delete your account and all associated data. This action cannot be undone.
</p>
{!showDeleteConfirm ? (
<button
onClick={() => setShowDeleteConfirm(true)}
className="px-4 py-2 bg-red-600/20 hover:bg-red-600/30 border border-red-600/50 text-red-400 rounded-lg text-sm font-medium transition"
>
Delete Account
</button>
) : (
<div className="space-y-3">
<p className="text-sm text-red-300">
This will permanently delete:
</p>
<ul className="text-xs text-slate-400 list-disc list-inside space-y-1">
<li>All your events and diary pages</li>
<li>All generation tasks and AI interactions</li>
<li>Your account and settings</li>
</ul>
<p className="text-sm text-slate-300 pt-2">
Type DELETE to confirm:
</p>
<input
type="text"
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
placeholder="DELETE"
className="w-full max-w-xs px-4 py-2 bg-slate-800 rounded-lg border border-red-600/50 focus:border-red-500 focus:outline-none text-red-400 font-mono"
/>
<div className="flex gap-3 pt-2">
<button
onClick={handleDeleteAccount}
disabled={deleteConfirmText !== 'DELETE' || deleting}
className="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg text-sm font-medium transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{deleting ? 'Deleting...' : 'Permanently Delete Everything'}
</button>
<button
onClick={() => { setShowDeleteConfirm(false); setDeleteConfirmText(''); }}
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg text-sm transition"
>
Cancel
</button>
</div>
</div>
)}
</div>
<div className="border-t border-red-900/30 pt-4 mt-4">
<h3 className="text-sm font-medium text-slate-300 mb-2">Reset Account</h3>
<p className="text-xs text-slate-400 mb-3">
Wipe all data and settings but keep your account. You'll have a fresh start.
</p>
{!showResetConfirm ? (
<button
onClick={() => setShowResetConfirm(true)}
className="px-4 py-2 bg-amber-600/20 hover:bg-amber-600/30 border border-amber-600/50 text-amber-400 rounded-lg text-sm font-medium transition"
>
Reset Account
</button>
) : (
<div className="space-y-3">
<p className="text-xs text-amber-300">
This will permanently delete:
</p>
<ul className="text-xs text-slate-400 list-disc list-inside space-y-1">
<li>All events and diary pages</li>
<li>All generation tasks</li>
<li>All settings (AI config, prompts, etc.)</li>
</ul>
<p className="text-xs text-slate-300 pt-2">
Your account (email/password) will be kept.
</p>
<p className="text-xs text-slate-300 pt-1">
Type RESET to confirm:
</p>
<input
type="text"
value={resetConfirmText}
onChange={(e) => setResetConfirmText(e.target.value)}
placeholder="RESET"
className="w-full max-w-xs px-4 py-2 bg-slate-800 rounded-lg border border-amber-600/50 focus:border-amber-500 focus:outline-none text-amber-400 font-mono"
/>
<div className="flex gap-3 pt-2">
<button
onClick={handleResetAccount}
disabled={resetConfirmText !== 'RESET' || resetting}
className="px-4 py-2 bg-amber-600 hover:bg-amber-700 rounded-lg text-sm font-medium transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{resetting ? 'Resetting...' : 'Reset Account'}
</button>
<button
onClick={() => { setShowResetConfirm(false); setResetConfirmText(''); }}
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg text-sm transition"
>
Cancel
</button>
</div>
</div>
)}
</div>
</div>
</section>
<section className="bg-slate-900 rounded-xl p-6 border border-slate-800">
<h2 className="text-lg font-medium mb-4">Security</h2>
<div className="space-y-4">
{!showPasswordChange ? (
<button
onClick={() => setShowPasswordChange(true)}
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm font-medium transition"
>
Change Password
</button>
) : (
<div className="space-y-3 max-w-md">
<div>
<label className="block text-sm text-slate-400 mb-1">Current Password</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="w-full px-4 py-2 bg-slate-800 rounded-lg border border-slate-700 focus:border-purple-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-slate-400 mb-1">New Password</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Min 6 characters"
className="w-full px-4 py-2 bg-slate-800 rounded-lg border border-slate-700 focus:border-purple-500 focus:outline-none"
/>
</div>
{passwordError && (
<p className="text-red-400 text-sm">{passwordError}</p>
)}
{passwordSuccess && (
<p className="text-green-400 text-sm">{passwordSuccess}</p>
)}
<div className="flex gap-3 pt-2">
<button
onClick={handlePasswordChange}
disabled={changingPassword || !currentPassword || newPassword.length < 6}
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg text-sm font-medium transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{changingPassword ? 'Saving...' : 'Save Password'}
</button>
<button
onClick={() => { setShowPasswordChange(false); setCurrentPassword(''); setNewPassword(''); setPasswordError(''); setPasswordSuccess(''); }}
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg text-sm transition"
>
Cancel
</button>
</div>
</div>
)}
</div>
</section>
<div className="flex items-center justify-between">
<button
onClick={handleSave}

View File

@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/EntryInput.tsx","./src/components/EntryList.tsx","./src/lib/ThemeContext.tsx","./src/lib/api.ts","./src/pages/Auth.tsx","./src/pages/Day.tsx","./src/pages/History.tsx","./src/pages/Home.tsx","./src/pages/Journal.tsx","./src/pages/Settings.tsx","./src/pages/Tasks.tsx"],"version":"5.9.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/DateNavigator.tsx","./src/components/EntryInput.tsx","./src/components/EntryList.tsx","./src/components/QuickAddWidget.tsx","./src/components/SearchModal.tsx","./src/lib/ThemeContext.tsx","./src/lib/api.ts","./src/lib/geolocation.ts","./src/pages/Auth.tsx","./src/pages/Calendar.tsx","./src/pages/Dashboard.tsx","./src/pages/Day.tsx","./src/pages/Diary.tsx","./src/pages/History.tsx","./src/pages/Home.tsx","./src/pages/Journal.tsx","./src/pages/Settings.tsx","./src/pages/Tasks.tsx"],"version":"5.9.3"}