Add light/dark mode theme support with OS sync
- Added theme state management (light/dark/system) - Implemented OS theme detection and automatic sync - Added theme toggle UI in sidebar with 3 buttons - Applied dark mode styles to all components: - Sidebar with dark backgrounds and borders - Note editor with dark text and inputs - Toolbar buttons with dark hover states - TipTap editor with dark mode text and code blocks - Theme preference saved to localStorage - Enabled Tailwind dark mode with class strategy - Smooth transitions between themes
This commit is contained in:
38
src/App.tsx
38
src/App.tsx
@@ -14,11 +14,18 @@ function App() {
|
||||
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false);
|
||||
const [fontSize] = useState(14);
|
||||
const [username, setUsername] = useState('');
|
||||
const [theme, setTheme] = useState<'light' | 'dark' | 'system'>('system');
|
||||
const [effectiveTheme, setEffectiveTheme] = useState<'light' | 'dark'>('light');
|
||||
|
||||
useEffect(() => {
|
||||
const savedServer = localStorage.getItem('serverURL');
|
||||
const savedUsername = localStorage.getItem('username');
|
||||
const savedPassword = localStorage.getItem('password');
|
||||
const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | 'system' | null;
|
||||
|
||||
if (savedTheme) {
|
||||
setTheme(savedTheme);
|
||||
}
|
||||
|
||||
if (savedServer && savedUsername && savedPassword) {
|
||||
const apiInstance = new NextcloudAPI({
|
||||
@@ -32,6 +39,30 @@ function App() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const updateEffectiveTheme = () => {
|
||||
if (theme === 'system') {
|
||||
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
setEffectiveTheme(isDark ? 'dark' : 'light');
|
||||
} else {
|
||||
setEffectiveTheme(theme);
|
||||
}
|
||||
};
|
||||
|
||||
updateEffectiveTheme();
|
||||
|
||||
if (theme === 'system') {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = () => updateEffectiveTheme();
|
||||
mediaQuery.addEventListener('change', handler);
|
||||
return () => mediaQuery.removeEventListener('change', handler);
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', effectiveTheme === 'dark');
|
||||
}, [effectiveTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (api && isLoggedIn) {
|
||||
syncNotes();
|
||||
@@ -75,6 +106,11 @@ function App() {
|
||||
setIsLoggedIn(false);
|
||||
};
|
||||
|
||||
const handleThemeChange = (newTheme: 'light' | 'dark' | 'system') => {
|
||||
setTheme(newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
};
|
||||
|
||||
const handleCreateNote = async () => {
|
||||
if (!api) return;
|
||||
try {
|
||||
@@ -152,6 +188,8 @@ function App() {
|
||||
onSync={syncNotes}
|
||||
onLogout={handleLogout}
|
||||
username={username}
|
||||
theme={theme}
|
||||
onThemeChange={handleThemeChange}
|
||||
searchText={searchText}
|
||||
onSearchChange={setSearchText}
|
||||
showFavoritesOnly={showFavoritesOnly}
|
||||
|
||||
@@ -142,22 +142,22 @@ export function NoteEditor({ note, onUpdateNote, fontSize }: NoteEditorProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col bg-white">
|
||||
<div className="border-b border-gray-200 p-4 flex items-center justify-between">
|
||||
<div className="flex-1 flex flex-col bg-white dark:bg-gray-900">
|
||||
<div className="border-b border-gray-200 dark:border-gray-700 p-4 flex items-center justify-between">
|
||||
<input
|
||||
type="text"
|
||||
value={localTitle}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
placeholder="Note Title"
|
||||
className="flex-1 text-2xl font-bold border-none outline-none focus:ring-0"
|
||||
className="flex-1 text-2xl font-bold border-none outline-none focus:ring-0 bg-transparent text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
|
||||
<div className="flex items-center space-x-2 ml-4">
|
||||
{hasUnsavedChanges && (
|
||||
<span className="text-sm text-orange-500">Unsaved changes</span>
|
||||
<span className="text-sm text-orange-500 dark:text-orange-400">Unsaved changes</span>
|
||||
)}
|
||||
{isSaving && (
|
||||
<span className="text-sm text-gray-500">Saving...</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Saving...</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
@@ -195,17 +195,17 @@ export function NoteEditor({ note, onUpdateNote, fontSize }: NoteEditorProps) {
|
||||
value={localCategory}
|
||||
onChange={(e) => handleCategoryChange(e.target.value)}
|
||||
placeholder="Category"
|
||||
className="w-32 px-3 py-1 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-32 px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Formatting Toolbar */}
|
||||
<div className="border-b border-gray-200 px-4 py-2 flex items-center space-x-1 bg-gray-50">
|
||||
<div className="border-b border-gray-200 dark:border-gray-700 px-4 py-2 flex items-center justify-between bg-gray-50 dark:bg-gray-800">
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
className={`p-2 rounded transition-colors ${
|
||||
editor.isActive('bold') ? 'bg-blue-100 text-blue-700' : 'hover:bg-gray-200'
|
||||
editor.isActive('bold') ? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300' : 'hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
title="Bold"
|
||||
>
|
||||
|
||||
@@ -10,6 +10,8 @@ interface NotesListProps {
|
||||
onSync: () => void;
|
||||
onLogout: () => void;
|
||||
username: string;
|
||||
theme: 'light' | 'dark' | 'system';
|
||||
onThemeChange: (theme: 'light' | 'dark' | 'system') => void;
|
||||
searchText: string;
|
||||
onSearchChange: (text: string) => void;
|
||||
showFavoritesOnly: boolean;
|
||||
@@ -25,6 +27,8 @@ export function NotesList({
|
||||
onSync,
|
||||
onLogout,
|
||||
username,
|
||||
theme,
|
||||
onThemeChange,
|
||||
searchText,
|
||||
onSearchChange,
|
||||
showFavoritesOnly,
|
||||
@@ -58,15 +62,15 @@ export function NotesList({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-80 bg-gray-50 border-r border-gray-200 flex flex-col">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<div className="w-80 bg-gray-50 dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 flex flex-col">
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Notes</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Notes</h2>
|
||||
<div className="flex items-center space-x-1">
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={isSyncing}
|
||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50"
|
||||
className="p-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50"
|
||||
title="Sync with Server"
|
||||
>
|
||||
<svg
|
||||
@@ -80,7 +84,7 @@ export function NotesList({
|
||||
</button>
|
||||
<button
|
||||
onClick={onCreateNote}
|
||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
className="p-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title="New Note"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -95,26 +99,26 @@ export function NotesList({
|
||||
value={searchText}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder="Search notes..."
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
<button
|
||||
onClick={onToggleFavorites}
|
||||
className="text-xs text-gray-600 hover:text-gray-900 flex items-center"
|
||||
className="text-xs text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 flex items-center"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-1" fill={showFavoritesOnly ? "currentColor" : "none"} stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</svg>
|
||||
{showFavoritesOnly ? 'All Notes' : 'Favorites'}
|
||||
</button>
|
||||
<span className="text-xs text-gray-500">{notes.length} notes</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{notes.length} notes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{notes.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-gray-400 p-8">
|
||||
<div className="flex flex-col items-center justify-center h-full text-gray-400 dark:text-gray-500 p-8">
|
||||
<svg className="w-16 h-16 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
@@ -125,8 +129,8 @@ export function NotesList({
|
||||
<div
|
||||
key={note.id}
|
||||
onClick={() => onSelectNote(note.id)}
|
||||
className={`p-4 border-b border-gray-200 cursor-pointer hover:bg-gray-100 transition-colors ${
|
||||
selectedNoteId === note.id ? 'bg-blue-50 hover:bg-blue-50' : ''
|
||||
className={`p-3 border-b border-gray-200 dark:border-gray-700 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors group ${
|
||||
note.id === selectedNoteId ? 'bg-blue-50 dark:bg-gray-800 border-l-4 border-l-blue-500' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-1">
|
||||
@@ -136,7 +140,7 @@ export function NotesList({
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
)}
|
||||
<h3 className="font-medium text-gray-900 truncate">
|
||||
<h3 className="font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{note.title || 'Untitled'}
|
||||
</h3>
|
||||
</div>
|
||||
@@ -145,7 +149,7 @@ export function NotesList({
|
||||
e.stopPropagation();
|
||||
onDeleteNote(note);
|
||||
}}
|
||||
className="ml-2 p-1 hover:bg-red-100 rounded text-red-600 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
className="ml-2 p-1 hover:bg-red-100 dark:hover:bg-red-900/30 rounded text-red-600 dark:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Delete"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -154,9 +158,9 @@ export function NotesList({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-xs text-gray-500 mb-2">
|
||||
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||||
{note.category && (
|
||||
<span className="bg-gray-200 px-2 py-0.5 rounded-full mr-2">
|
||||
<span className="bg-gray-200 dark:bg-gray-700 px-2 py-0.5 rounded-full mr-2 text-gray-700 dark:text-gray-300">
|
||||
{note.category}
|
||||
</span>
|
||||
)}
|
||||
@@ -164,7 +168,7 @@ export function NotesList({
|
||||
</div>
|
||||
|
||||
{getPreview(note.content) && (
|
||||
<p className="text-sm text-gray-600 line-clamp-2">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
||||
{getPreview(note.content)}
|
||||
</p>
|
||||
)}
|
||||
@@ -174,24 +178,70 @@ export function NotesList({
|
||||
</div>
|
||||
|
||||
{/* User Info and Logout */}
|
||||
<div className="border-t border-gray-200 p-4 bg-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="border-t border-gray-200 p-4 bg-white dark:bg-gray-800 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center space-x-2 min-w-0">
|
||||
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white font-semibold flex-shrink-0">
|
||||
{username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="text-sm text-gray-700 truncate font-medium">{username}</span>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-200 truncate font-medium">{username}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors flex-shrink-0"
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors flex-shrink-0"
|
||||
title="Logout"
|
||||
>
|
||||
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="w-5 h-5 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Theme</span>
|
||||
<div className="flex items-center space-x-1 bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => onThemeChange('light')}
|
||||
className={`p-1.5 rounded transition-colors ${
|
||||
theme === 'light'
|
||||
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
|
||||
}`}
|
||||
title="Light mode"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onThemeChange('dark')}
|
||||
className={`p-1.5 rounded transition-colors ${
|
||||
theme === 'dark'
|
||||
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
|
||||
}`}
|
||||
title="Dark mode"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onThemeChange('system')}
|
||||
className={`p-1.5 rounded transition-colors ${
|
||||
theme === 'system'
|
||||
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
|
||||
}`}
|
||||
title="System theme"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,11 @@ code {
|
||||
/* TipTap Editor Styles */
|
||||
.ProseMirror {
|
||||
min-height: 100%;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.dark .ProseMirror {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.ProseMirror:focus {
|
||||
@@ -72,6 +77,12 @@ code {
|
||||
border-radius: 0.25rem;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.dark .ProseMirror code {
|
||||
background-color: #374151;
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.ProseMirror pre {
|
||||
@@ -83,6 +94,10 @@ code {
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.dark .ProseMirror pre {
|
||||
background-color: #111827;
|
||||
}
|
||||
|
||||
.ProseMirror pre code {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
|
||||
Reference in New Issue
Block a user