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:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "deardiary-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
96
frontend/src/components/DateNavigator.tsx
Normal file
96
frontend/src/components/DateNavigator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
120
frontend/src/components/QuickAddWidget.tsx
Normal file
120
frontend/src/components/QuickAddWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
159
frontend/src/components/SearchModal.tsx
Normal file
159
frontend/src/components/SearchModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
50
frontend/src/lib/geolocation.ts
Normal file
50
frontend/src/lib/geolocation.ts
Normal 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 }
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
200
frontend/src/pages/Calendar.tsx
Normal file
200
frontend/src/pages/Calendar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
165
frontend/src/pages/Dashboard.tsx
Normal file
165
frontend/src/pages/Dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
154
frontend/src/pages/Diary.tsx
Normal file
154
frontend/src/pages/Diary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 →
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user