feat: add theme system, branding, and task logging
- Add light/dark/system theme toggle in settings - Add DearDiary.io branding in navbar - Add task logging for journal generation with request/response - Rename project from TotalRecall to DearDiary - Update Docker configuration
This commit is contained in:
@@ -58,10 +58,17 @@ function App() {
|
||||
<div className="min-h-screen bg-slate-950 text-slate-100">
|
||||
{isAuthenticated && (
|
||||
<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 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>
|
||||
<a href="/settings" className="text-slate-300 hover:text-white transition">Settings</a>
|
||||
<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
|
||||
</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>
|
||||
<a href="/settings" className="text-slate-300 hover:text-white transition">Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,74 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
@apply bg-slate-950 text-slate-100;
|
||||
:root {
|
||||
--bg-primary: #020917;
|
||||
--bg-secondary: #0f172a;
|
||||
--bg-tertiary: #1e293b;
|
||||
--text-primary: #e2e8f0;
|
||||
--text-secondary: #94a3b8;
|
||||
--border-color: #334155;
|
||||
}
|
||||
|
||||
.light {
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f8fafc;
|
||||
--bg-tertiary: #f1f5f9;
|
||||
--text-primary: #1e293b;
|
||||
--text-secondary: #64748b;
|
||||
--border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.bg-slate-950 { background-color: var(--bg-primary); }
|
||||
.bg-slate-900 { background-color: var(--bg-secondary); }
|
||||
.bg-slate-800 { background-color: var(--bg-tertiary); }
|
||||
.bg-slate-700 { background-color: #475569; }
|
||||
|
||||
.text-slate-100 { color: var(--text-primary); }
|
||||
.text-slate-200 { color: var(--text-primary); }
|
||||
.text-slate-300 { color: var(--text-primary); }
|
||||
.text-slate-400 { color: var(--text-secondary); }
|
||||
.text-slate-500 { color: var(--text-secondary); }
|
||||
|
||||
.border-slate-800 { border-color: var(--border-color); }
|
||||
.border-slate-700 { border-color: var(--border-color); }
|
||||
|
||||
.light .bg-slate-900 { background-color: #f1f5f9; }
|
||||
.light .bg-slate-800 { background-color: #e2e8f0; }
|
||||
.light .bg-slate-700 { background-color: #cbd5e1; }
|
||||
|
||||
.light .text-slate-300 { color: #475569; }
|
||||
.light .text-slate-400 { color: #64748b; }
|
||||
|
||||
.light .border-slate-800 { border-color: #e2e8f0; }
|
||||
|
||||
.bg-white { background-color: var(--bg-primary); }
|
||||
.bg-black { background-color: var(--bg-secondary); }
|
||||
|
||||
.ring-offset-slate-950 { --tw-ring-offset-color: var(--bg-primary); }
|
||||
.dark .ring-offset-slate-950 { --tw-ring-offset-color: #020917; }
|
||||
.light .ring-offset-slate-950 { --tw-ring-offset-color: #ffffff; }
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
|
||||
83
frontend/src/lib/ThemeContext.tsx
Normal file
83
frontend/src/lib/ThemeContext.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
type ResolvedTheme = 'light' | 'dark';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
resolvedTheme: ResolvedTheme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType>({
|
||||
theme: 'system',
|
||||
resolvedTheme: 'dark',
|
||||
setTheme: () => {},
|
||||
});
|
||||
|
||||
export function useTheme() {
|
||||
return useContext(ThemeContext);
|
||||
}
|
||||
|
||||
function getSystemTheme(): ResolvedTheme {
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return (localStorage.getItem('theme') as Theme) || 'system';
|
||||
}
|
||||
return 'system';
|
||||
});
|
||||
|
||||
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>(() => {
|
||||
if (theme === 'system') {
|
||||
return getSystemTheme();
|
||||
}
|
||||
return theme;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
|
||||
if (theme === 'system') {
|
||||
const systemTheme = getSystemTheme();
|
||||
setResolvedTheme(systemTheme);
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(systemTheme);
|
||||
} else {
|
||||
setResolvedTheme(theme);
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(theme);
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme === 'system') {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
const newTheme = e.matches ? 'dark' : 'light';
|
||||
setResolvedTheme(newTheme);
|
||||
document.documentElement.classList.remove('light', 'dark');
|
||||
document.documentElement.classList.add(newTheme);
|
||||
};
|
||||
mediaQuery.addEventListener('change', handler);
|
||||
return () => mediaQuery.removeEventListener('change', handler);
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
const setTheme = (newTheme: Theme) => {
|
||||
setThemeState(newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -126,13 +126,21 @@ class ApiClient {
|
||||
}
|
||||
|
||||
async generateJournal(date: string) {
|
||||
return this.request<Journal>('POST', `/journal/generate/${date}`);
|
||||
return this.request<{ journal: Journal; task: Task }>('POST', `/journal/generate/${date}`);
|
||||
}
|
||||
|
||||
async getJournal(date: string) {
|
||||
return this.request<Journal>('GET', `/journal/${date}`);
|
||||
}
|
||||
|
||||
async getJournalTasks(date: string) {
|
||||
return this.request<Task[]>('GET', `/journal/${date}/tasks`);
|
||||
}
|
||||
|
||||
async getTask(taskId: string) {
|
||||
return this.request<Task>('GET', `/tasks/${taskId}`);
|
||||
}
|
||||
|
||||
async getSettings() {
|
||||
return this.request<Settings>('GET', '/settings');
|
||||
}
|
||||
@@ -160,6 +168,21 @@ export interface Journal {
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
journalId: string;
|
||||
type: string;
|
||||
status: 'pending' | 'completed' | 'failed';
|
||||
provider: string;
|
||||
model?: string;
|
||||
prompt?: string;
|
||||
request?: string;
|
||||
response?: string;
|
||||
error?: string;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
aiProvider: 'openai' | 'anthropic' | 'ollama' | 'lmstudio';
|
||||
aiApiKey?: string;
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import { ThemeProvider } from './lib/ThemeContext';
|
||||
import './index.css';
|
||||
|
||||
const savedTheme = localStorage.getItem('theme') || 'system';
|
||||
const isDark = savedTheme === 'dark' || (savedTheme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
document.documentElement.classList.add(isDark ? 'dark' : 'light');
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { api, Journal } from '../lib/api';
|
||||
import { api, Journal, Task } from '../lib/api';
|
||||
|
||||
export default function JournalPage() {
|
||||
const { date } = useParams<{ date: string }>();
|
||||
const [journal, setJournal] = useState<Journal | null>(null);
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (date) loadJournal();
|
||||
if (date) {
|
||||
loadJournal();
|
||||
loadTasks();
|
||||
}
|
||||
}, [date]);
|
||||
|
||||
const loadJournal = async () => {
|
||||
@@ -22,12 +26,21 @@ export default function JournalPage() {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const loadTasks = async () => {
|
||||
if (!date) return;
|
||||
const res = await api.getJournalTasks(date);
|
||||
if (res.data) {
|
||||
setTasks(res.data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!date) return;
|
||||
setGenerating(true);
|
||||
const res = await api.generateJournal(date);
|
||||
if (res.data) {
|
||||
setJournal(res.data);
|
||||
setJournal(res.data.journal);
|
||||
setTasks(prev => [res.data!.task, ...prev]);
|
||||
}
|
||||
setGenerating(false);
|
||||
};
|
||||
@@ -81,6 +94,63 @@ export default function JournalPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tasks.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-lg font-medium mb-4">Generation History</h2>
|
||||
<div className="space-y-3">
|
||||
{tasks.map(task => (
|
||||
<details key={task.id} className="bg-slate-900 rounded-lg border border-slate-800">
|
||||
<summary className="p-4 cursor-pointer flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`w-2 h-2 rounded-full ${
|
||||
task.status === 'completed' ? 'bg-green-500' :
|
||||
task.status === 'failed' ? 'bg-red-500' : 'bg-yellow-500'
|
||||
}`} />
|
||||
<span className="font-medium">
|
||||
{task.provider.charAt(0).toUpperCase() + task.provider.slice(1)}
|
||||
{task.model && ` - ${task.model}`}
|
||||
</span>
|
||||
<span className="text-sm text-slate-400">
|
||||
{new Date(task.createdAt).toLocaleString()}
|
||||
</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="px-4 pb-4 space-y-3">
|
||||
{task.error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded p-3">
|
||||
<p className="text-sm text-red-400 font-medium mb-1">Error:</p>
|
||||
<pre className="text-sm text-red-300 whitespace-pre-wrap">{task.error}</pre>
|
||||
</div>
|
||||
)}
|
||||
{task.request && (
|
||||
<div>
|
||||
<p className="text-sm text-slate-400 font-medium mb-1">Request:</p>
|
||||
<pre className="bg-slate-950 rounded p-3 text-xs text-slate-300 overflow-x-auto max-h-64">
|
||||
{JSON.stringify(JSON.parse(task.request), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{task.response && (
|
||||
<div>
|
||||
<p className="text-sm text-slate-400 font-medium mb-1">Response:</p>
|
||||
<pre className="bg-slate-950 rounded p-3 text-xs text-slate-300 overflow-x-auto max-h-64">
|
||||
{JSON.stringify(JSON.parse(task.response), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api, Settings } from '../lib/api';
|
||||
import { useTheme } from '../lib/ThemeContext';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [settings, setSettings] = useState<Partial<Settings>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
@@ -47,6 +49,46 @@ export default function SettingsPage() {
|
||||
<h1 className="text-2xl font-bold mb-6">Settings</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
<section className="bg-slate-900 rounded-xl p-6 border border-slate-800">
|
||||
<h2 className="text-lg font-medium mb-4">Appearance</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm text-slate-400 mb-2">Theme</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setTheme('light')}
|
||||
className={`flex-1 px-4 py-2 rounded-lg border transition ${
|
||||
theme === 'light'
|
||||
? 'bg-blue-600 border-blue-600 text-white'
|
||||
: 'bg-slate-800 border-slate-700 hover:border-slate-600'
|
||||
}`}
|
||||
>
|
||||
Light
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTheme('dark')}
|
||||
className={`flex-1 px-4 py-2 rounded-lg border transition ${
|
||||
theme === 'dark'
|
||||
? 'bg-blue-600 border-blue-600 text-white'
|
||||
: 'bg-slate-800 border-slate-700 hover:border-slate-600'
|
||||
}`}
|
||||
>
|
||||
Dark
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTheme('system')}
|
||||
className={`flex-1 px-4 py-2 rounded-lg border transition ${
|
||||
theme === 'system'
|
||||
? 'bg-blue-600 border-blue-600 text-white'
|
||||
: 'bg-slate-800 border-slate-700 hover:border-slate-600'
|
||||
}`}
|
||||
>
|
||||
System
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-slate-900 rounded-xl p-6 border border-slate-800">
|
||||
<h2 className="text-lg font-medium mb-4">AI Provider</h2>
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/EntryInput.tsx","./src/components/EntryList.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"],"version":"5.9.3"}
|
||||
{"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"],"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user