Skip to content

Commit b1e2d78

Browse files
committed
feat: add themes.
1 parent 399b632 commit b1e2d78

18 files changed

Lines changed: 667 additions & 371 deletions

frontend/src/components/ChangePasswordModal.jsx

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,10 @@ export default function ChangePasswordModal({ isOpen, onClose }) {
5555
return (
5656
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={handleClose}>
5757
<div
58-
className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-sm mx-4 p-6"
58+
className="bg-surface rounded-xl shadow-xl w-full max-w-sm mx-4 p-6"
5959
onClick={(e) => e.stopPropagation()}
6060
>
61-
<h2 className="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-4">Change Password</h2>
61+
<h2 className="text-lg font-semibold text-text mb-4">Change Password</h2>
6262

6363
{success ? (
6464
<div className="p-3 rounded-lg text-sm bg-green-50 text-green-700 border border-green-200">
@@ -67,48 +67,48 @@ export default function ChangePasswordModal({ isOpen, onClose }) {
6767
) : (
6868
<form onSubmit={handleSubmit} className="space-y-3">
6969
<div>
70-
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Current Password</label>
70+
<label className="block text-sm font-medium text-text mb-1">Current Password</label>
7171
<input
7272
type="password"
7373
value={oldPassword}
7474
onChange={(e) => setOldPassword(e.target.value)}
7575
required
76-
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:border-blue-400"
76+
className="w-full px-3 py-2 border border-border rounded-lg text-sm bg-surface text-text focus:outline-none focus:border-primary"
7777
/>
7878
</div>
7979
<div>
80-
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">New Password</label>
80+
<label className="block text-sm font-medium text-text mb-1">New Password</label>
8181
<input
8282
type="password"
8383
value={newPassword}
8484
onChange={(e) => setNewPassword(e.target.value)}
8585
required
86-
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:border-blue-400"
86+
className="w-full px-3 py-2 border border-border rounded-lg text-sm bg-surface text-text focus:outline-none focus:border-primary"
8787
/>
8888
</div>
8989
<div>
90-
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Confirm New Password</label>
90+
<label className="block text-sm font-medium text-text mb-1">Confirm New Password</label>
9191
<input
9292
type="password"
9393
value={confirmPassword}
9494
onChange={(e) => setConfirmPassword(e.target.value)}
9595
required
96-
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:border-blue-400"
96+
className="w-full px-3 py-2 border border-border rounded-lg text-sm bg-surface text-text focus:outline-none focus:border-primary"
9797
/>
9898
</div>
9999
{error && <p className="text-sm text-red-600">{error}</p>}
100100
<div className="flex justify-end gap-2 pt-2">
101101
<button
102102
type="button"
103103
onClick={handleClose}
104-
className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
104+
className="px-4 py-2 text-sm text-text-secondary hover:text-text"
105105
>
106106
Cancel
107107
</button>
108108
<button
109109
type="submit"
110110
disabled={loading}
111-
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700 disabled:opacity-50"
111+
className="px-4 py-2 bg-primary text-primary-text rounded-lg text-sm hover:bg-primary-hover disabled:opacity-50"
112112
>
113113
{loading ? 'Saving...' : 'Change Password'}
114114
</button>

frontend/src/components/Comments.jsx

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,39 +16,39 @@ function CommentItem({ comment, currentUser, onDelete, onUpdate }) {
1616

1717
return (
1818
<div className="flex gap-3 py-3">
19-
<div className="w-8 h-8 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center text-sm font-medium shrink-0">
19+
<div className="w-8 h-8 rounded-full bg-primary-soft text-primary flex items-center justify-center text-sm font-medium shrink-0">
2020
{((comment.display_name || comment.username) || '?')[0].toUpperCase()}
2121
</div>
2222
<div className="flex-1 min-w-0">
2323
<div className="flex items-center gap-2 mb-1">
24-
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">{comment.display_name || comment.username}</span>
25-
<span className="text-xs text-gray-400">{new Date(comment.created_at).toLocaleString()}</span>
24+
<span className="text-sm font-medium text-text">{comment.display_name || comment.username}</span>
25+
<span className="text-xs text-text-secondary">{new Date(comment.created_at).toLocaleString()}</span>
2626
{comment.updated_at !== comment.created_at && (
27-
<span className="text-xs text-gray-400">(edited)</span>
27+
<span className="text-xs text-text-secondary">(edited)</span>
2828
)}
2929
</div>
3030
{editing ? (
3131
<div>
3232
<textarea
3333
value={editContent}
3434
onChange={(e) => setEditContent(e.target.value)}
35-
className="w-full p-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:border-blue-400 resize-none"
35+
className="w-full p-2 border border-border rounded-lg text-sm focus:outline-none focus:border-primary resize-none bg-surface text-text"
3636
rows={3}
3737
/>
3838
<div className="flex gap-2 mt-1">
39-
<button onClick={handleSave} className="text-xs px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">Save</button>
40-
<button onClick={() => { setEditing(false); setEditContent(comment.content) }} className="text-xs px-3 py-1 text-gray-500 hover:text-gray-700">Cancel</button>
39+
<button onClick={handleSave} className="text-xs px-3 py-1 bg-primary text-primary-text rounded hover:bg-primary-hover">Save</button>
40+
<button onClick={() => { setEditing(false); setEditContent(comment.content) }} className="text-xs px-3 py-1 text-text-secondary hover:text-text">Cancel</button>
4141
</div>
4242
</div>
4343
) : (
44-
<div className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{comment.content}</div>
44+
<div className="text-sm text-text whitespace-pre-wrap">{comment.content}</div>
4545
)}
4646
{!editing && (isOwner || isAdmin) && (
4747
<div className="flex gap-3 mt-1">
4848
{isOwner && (
49-
<button onClick={() => setEditing(true)} className="text-xs text-gray-400 hover:text-gray-600">Edit</button>
49+
<button onClick={() => setEditing(true)} className="text-xs text-text-secondary hover:text-text">Edit</button>
5050
)}
51-
<button onClick={() => onDelete(comment.id)} className="text-xs text-gray-400 hover:text-red-500">Delete</button>
51+
<button onClick={() => onDelete(comment.id)} className="text-xs text-text-secondary hover:text-red-500">Delete</button>
5252
</div>
5353
)}
5454
</div>
@@ -106,17 +106,17 @@ export default function Comments({ slug }) {
106106
}
107107

108108
return (
109-
<div className="mt-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
110-
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-4">
109+
<div className="mt-6 bg-surface rounded-xl shadow-sm border border-border p-6">
110+
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-4">
111111
Discussion {total > 0 && `(${total})`}
112112
</h3>
113113

114114
{loading ? (
115-
<div className="text-sm text-gray-400">Loading...</div>
115+
<div className="text-sm text-text-secondary">Loading...</div>
116116
) : (
117117
<>
118118
{comments.length > 0 && (
119-
<div className="divide-y divide-gray-100 mb-4">
119+
<div className="divide-y divide-border mb-4">
120120
{comments.map((c) => (
121121
<CommentItem
122122
key={c.id}
@@ -134,14 +134,14 @@ export default function Comments({ slug }) {
134134
value={newComment}
135135
onChange={(e) => setNewComment(e.target.value)}
136136
placeholder="Write a comment..."
137-
className="w-full p-3 border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-lg text-sm focus:outline-none focus:border-blue-400 resize-none"
137+
className="w-full p-3 border border-border bg-surface text-text rounded-lg text-sm focus:outline-none focus:border-primary resize-none"
138138
rows={3}
139139
/>
140140
<div className="flex justify-end mt-2">
141141
<button
142142
type="submit"
143143
disabled={submitting || !newComment.trim()}
144-
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700 disabled:opacity-50"
144+
className="px-4 py-2 bg-primary text-primary-text rounded-lg text-sm hover:bg-primary-hover disabled:opacity-50"
145145
>
146146
{submitting ? 'Posting...' : 'Comment'}
147147
</button>

frontend/src/components/Layout/Layout.jsx

Lines changed: 105 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,109 @@
1-
import { useEffect, useState } from 'react'
1+
import { useEffect, useState, useRef } from 'react'
22
import { Link, useNavigate } from 'react-router-dom'
33
import useAuth from '../../store/useAuth'
44
import usePages from '../../store/usePages'
55
import useBookmarks from '../../store/useBookmarks'
6-
import useTheme from '../../store/useTheme'
6+
import useTheme, { themes } from '../../store/useTheme'
77
import Sidebar from './Sidebar'
88
import KeyboardShortcuts from '../../hooks/useKeyboard'
99
import SearchModal from '../Search/SearchModal'
1010

11+
function applyThemePreview(themeId) {
12+
const t = themes[themeId] || themes.light
13+
document.documentElement.setAttribute('data-theme', themeId)
14+
document.documentElement.classList.toggle('dark', t.dark)
15+
}
16+
17+
function ThemePicker({ theme, setTheme }) {
18+
const [open, setOpen] = useState(false)
19+
const ref = useRef(null)
20+
const savedTheme = useRef(theme)
21+
22+
useEffect(() => {
23+
const handler = (e) => {
24+
if (ref.current && !ref.current.contains(e.target)) {
25+
applyThemePreview(savedTheme.current)
26+
setOpen(false)
27+
}
28+
}
29+
document.addEventListener('mousedown', handler)
30+
return () => document.removeEventListener('mousedown', handler)
31+
}, [])
32+
33+
const handleOpen = () => {
34+
savedTheme.current = theme
35+
setOpen(!open)
36+
}
37+
38+
const handleHover = (id) => {
39+
applyThemePreview(id)
40+
}
41+
42+
const handleLeave = () => {
43+
applyThemePreview(savedTheme.current)
44+
}
45+
46+
const handleSelect = (id) => {
47+
savedTheme.current = id
48+
setTheme(id)
49+
setOpen(false)
50+
}
51+
52+
return (
53+
<div className="relative mr-2" ref={ref}>
54+
<button
55+
onClick={handleOpen}
56+
className="p-1.5 rounded hover:bg-surface-hover text-text-secondary flex items-center gap-1"
57+
title="Change theme"
58+
>
59+
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
60+
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" />
61+
<path d="M12 2c3 2.5 4.5 6 4.5 10s-1.5 7.5-4.5 10" />
62+
<path d="M12 2c-3 2.5-4.5 6-4.5 10s1.5 7.5 4.5 10" />
63+
<path d="M2 12h20" />
64+
</svg>
65+
</button>
66+
{open && (
67+
<div
68+
className="absolute right-0 top-full mt-1 bg-surface border border-border rounded-xl shadow-lg p-2 z-50 w-44"
69+
onMouseLeave={handleLeave}
70+
>
71+
<div className="text-xs font-semibold text-text-secondary uppercase tracking-wider px-2 py-1 mb-1">Theme</div>
72+
{Object.entries(themes).map(([id, t]) => (
73+
<button
74+
key={id}
75+
onMouseEnter={() => handleHover(id)}
76+
onClick={() => handleSelect(id)}
77+
className={`w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg text-sm text-left transition-colors ${
78+
theme === id
79+
? 'bg-surface-hover font-medium text-text'
80+
: 'text-text-secondary hover:bg-surface-hover'
81+
}`}
82+
>
83+
<span className="flex gap-0.5 shrink-0">
84+
{t.preview.map((c, i) => (
85+
<span
86+
key={i}
87+
className="w-3 h-3 rounded-full border border-border"
88+
style={{ background: c }}
89+
/>
90+
))}
91+
</span>
92+
<span>{t.name}</span>
93+
{theme === id && <span className="ml-auto text-primary text-xs">&#10003;</span>}
94+
</button>
95+
))}
96+
</div>
97+
)}
98+
</div>
99+
)
100+
}
101+
11102
export default function Layout({ children }) {
12103
const { user, logout } = useAuth()
13104
const { fetchTree } = usePages()
14105
const { fetchBookmarks } = useBookmarks()
15-
const { dark, toggle: toggleTheme, init: initTheme } = useTheme()
106+
const { theme, setTheme, init: initTheme, dark } = useTheme()
16107
const navigate = useNavigate()
17108
const [sidebarOpen, setSidebarOpen] = useState(window.innerWidth > 768)
18109
const [searchOpen, setSearchOpen] = useState(false)
@@ -29,69 +120,54 @@ export default function Layout({ children }) {
29120
}
30121

31122
return (
32-
<div className="h-screen flex flex-col bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
123+
<div className="h-screen flex flex-col bg-bg text-text">
33124
<KeyboardShortcuts onOpenSearch={() => setSearchOpen(true)} />
34125
<SearchModal isOpen={searchOpen} onClose={() => setSearchOpen(false)} />
35126

36127
{/* Navbar */}
37-
<nav className="h-12 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 flex items-center px-4 shrink-0">
128+
<nav className="h-12 bg-surface border-b border-border flex items-center px-4 shrink-0">
38129
<button
39130
onClick={() => setSidebarOpen(!sidebarOpen)}
40-
className="p-1.5 rounded hover:bg-gray-100 mr-2 text-gray-500"
131+
className="p-1.5 rounded hover:bg-surface-hover mr-2 text-text-secondary"
41132
title="Toggle sidebar"
42133
>
43134
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
44135
<path d="M3 12h18M3 6h18M3 18h18" />
45136
</svg>
46137
</button>
47-
<Link to="/" className="font-bold text-lg text-gray-800 dark:text-gray-100 mr-4">JustWiki</Link>
138+
<Link to="/" className="font-bold text-lg text-text mr-4">JustWiki</Link>
48139
<div className="flex-1" />
49140

50141
{/* Search button */}
51142
<button
52143
onClick={() => setSearchOpen(true)}
53-
className="flex items-center gap-2 text-sm px-3 py-1.5 bg-gray-100 text-gray-500 rounded-lg hover:bg-gray-200 mr-3"
144+
className="flex items-center gap-2 text-sm px-3 py-1.5 bg-surface-hover text-text-secondary rounded-lg hover:brightness-95 mr-3"
54145
title="Search (Ctrl+K)"
55146
>
56147
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
57148
<circle cx="11" cy="11" r="8" />
58149
<path d="m21 21-4.35-4.35" />
59150
</svg>
60151
<span className="hidden sm:inline">Search</span>
61-
<kbd className="text-xs text-gray-400 border border-gray-300 rounded px-1">⌘K</kbd>
152+
<kbd className="text-xs text-text-secondary border border-border rounded px-1">⌘K</kbd>
62153
</button>
63154

64155
<button
65156
onClick={() => navigate('/new')}
66-
className="text-sm px-3 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 mr-3"
157+
className="text-sm px-3 py-1.5 bg-primary text-primary-text rounded-lg hover:bg-primary-hover mr-3"
67158
title="New page (Ctrl+N)"
68159
>
69160
+ New
70161
</button>
71-
<button
72-
onClick={toggleTheme}
73-
className="p-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-700 mr-2 text-gray-500 dark:text-gray-400"
74-
title={dark ? 'Light mode' : 'Dark mode'}
75-
>
76-
{dark ? (
77-
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
78-
<circle cx="12" cy="12" r="5" />
79-
<path d="M12 1v2m0 18v2M4.22 4.22l1.42 1.42m12.72 12.72l1.42 1.42M1 12h2m18 0h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
80-
</svg>
81-
) : (
82-
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
83-
<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" />
84-
</svg>
85-
)}
86-
</button>
162+
<ThemePicker theme={theme} setTheme={setTheme} />
87163
<Link
88164
to="/profile"
89-
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 mr-3"
165+
className="text-sm text-text-secondary hover:text-text mr-3"
90166
title="Profile"
91167
>
92168
{user?.username}
93169
</Link>
94-
<button onClick={handleLogout} className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
170+
<button onClick={handleLogout} className="text-sm text-text-secondary hover:text-text">
95171
Logout
96172
</button>
97173
</nav>
@@ -112,7 +188,7 @@ export default function Layout({ children }) {
112188
>
113189
<Sidebar />
114190
</div>
115-
<main className="flex-1 overflow-auto bg-gray-50 dark:bg-gray-900 p-4 sm:p-6">
191+
<main className="flex-1 overflow-auto bg-bg p-4 sm:p-6">
116192
{children}
117193
</main>
118194
</div>

0 commit comments

Comments
 (0)