Skip to content

Commit fb7b8eb

Browse files
committed
Support Light and Dark mode
1 parent 93b5d11 commit fb7b8eb

9 files changed

Lines changed: 491 additions & 174 deletions

File tree

src/App.js

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,50 @@ import AnimatedRoutes from "./components/MainFrame/AnimatedRoutes";
1515
function App() {
1616
const [load, upadateLoad] = useState(true);
1717
const preloaderTimerRef = useRef(null);
18+
const [theme, setTheme] = useState(() => {
19+
if (typeof window !== "undefined") {
20+
if (window.localStorage && window.localStorage.getItem("theme")) {
21+
return window.localStorage.getItem("theme");
22+
}
23+
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) {
24+
return "light";
25+
}
26+
}
27+
return "dark";
28+
});
29+
30+
useEffect(() => {
31+
document.documentElement.setAttribute("data-theme", theme);
32+
if (typeof window !== "undefined" && window.localStorage) {
33+
window.localStorage.setItem("theme", theme);
34+
}
35+
}, [theme]);
36+
37+
useEffect(() => {
38+
const mediaQuery = window.matchMedia('(prefers-color-scheme: light)');
39+
const handleChange = (e) => {
40+
// Only auto-switch if user hasn't manually set a preference
41+
if (typeof window !== "undefined" && window.localStorage && !window.localStorage.getItem("theme")) {
42+
setTheme(e.matches ? "light" : "dark");
43+
}
44+
};
45+
46+
if (mediaQuery.addEventListener) {
47+
mediaQuery.addEventListener('change', handleChange);
48+
return () => mediaQuery.removeEventListener('change', handleChange);
49+
} else if (mediaQuery.addListener) {
50+
mediaQuery.addListener(handleChange);
51+
return () => mediaQuery.removeListener(handleChange);
52+
}
53+
}, []);
54+
55+
const toggleTheme = useCallback(() => {
56+
document.documentElement.classList.add('theme-transition');
57+
setTheme((prev) => prev === "dark" ? "light" : "dark");
58+
setTimeout(() => {
59+
document.documentElement.classList.remove('theme-transition');
60+
}, 400);
61+
}, []);
1862

1963
const triggerPreloader = useCallback(() => {
2064
upadateLoad(true);
@@ -150,8 +194,8 @@ function App() {
150194
<Preloader load={load} />
151195
<div className="App" id={load ? "no-scroll" : "scroll"}>
152196
<div className="app-top-blur" aria-hidden="true" />
153-
<Navbar triggerPreloader={triggerPreloader} />
154-
<Particle />
197+
<Navbar triggerPreloader={triggerPreloader} theme={theme} toggleTheme={toggleTheme} />
198+
<Particle theme={theme} />
155199
<ScrollToTop />
156200
<div className="content-wrap">
157201
<AnimatedRoutes />

src/components/About/Techstack.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,14 @@ const techStackIcons = [
1111
{ icon: <img src="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/javascript/javascript-original.svg" alt="JavaScript" style={{ width: "1em", height: "1em" }} />, name: "JavaScript", link: "https://developer.mozilla.org/en-US/docs/Web/JavaScript" },
1212
{ icon: <img src="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/python/python-original.svg" alt="Python" style={{ width: "1em", height: "1em" }} />, name: "Python", link: "https://www.python.org/" },
1313
{ icon: <img src="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/django/django-plain.svg" alt="Django" style={{ width: "1em", height: "1em", filter: "brightness(3.5) contrast(1.2)" }} />, name: "Django", link: "https://www.djangoproject.com/" },
14-
{ icon: <img src="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/flask/flask-original.svg" alt="Flask" style={{ width: "1em", height: "1em", filter: "invert(1)" }} />, name: "Flask", link: "https://flask.palletsprojects.com/" },
14+
{ icon: <img src="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/flask/flask-original.svg" alt="Flask" style={{ width: "1em", height: "1em" }} className="theme-invert" />, name: "Flask", link: "https://flask.palletsprojects.com/" },
1515
{ icon: <img src="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/fastapi/fastapi-original.svg" alt="FastAPI" style={{ width: "1em", height: "1em", filter: "brightness(1.2) contrast(1.1)" }} />, name: "FastAPI", link: "https://fastapi.tiangolo.com/" },
1616
{ icon: <img src="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/nodejs/nodejs-original.svg" alt="Node.js" style={{ width: "1em", height: "1em",filter: "brightness(1.2) contrast(1.0)" }} />, name: "Node.js", link: "https://nodejs.org/" },
1717

1818
// Frontend
1919
{ icon: <img src="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/react/react-original.svg" alt="React" style={{ width: "1em", height: "1em" }} />, name: "React", link: "https://react.dev/" },
2020
{ icon: <img src="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/vuejs/vuejs-original.svg" alt="Vue.js" style={{ width: "1em", height: "1em" }} />, name: "Vue.js", link: "https://vuejs.org/" },
21-
{ icon: <img src="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/nextjs/nextjs-original.svg" alt="Next.js" style={{ width: "1em", height: "1em", filter: "invert(1)" }} />, name: "Next.js", link: "https://nextjs.org/" },
21+
{ icon: <img src="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/nextjs/nextjs-original.svg" alt="Next.js" style={{ width: "1em", height: "1em" }} className="theme-invert" />, name: "Next.js", link: "https://nextjs.org/" },
2222

2323
// Databases
2424
{ icon: <img src="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/mysql/mysql-original.svg" alt="MySQL" style={{ width: "1em", height: "1em", filter: "brightness(1.8) contrast(1.2)" }} />, name: "MySQL", link: "https://www.mysql.com/" },

src/components/About/Toolstack.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Col, Row } from "react-bootstrap";
33

44
const toolStackIcons = [
55
{ icon: <img src="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/windows11/windows11-original.svg" alt="Windows" style={{ width: "1em", height: "1em", filter: "brightness(1.5) contrast(1.2)" }} />, name: "Windows", link: "https://www.microsoft.com/en-us/windows" },
6-
{ icon: <img src="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/apple/apple-original.svg" alt="macOS" style={{ width: "1em", height: "1em", filter: "invert(1)" }} />, name: "macOS", link: "https://www.apple.com/macos/" },
6+
{ icon: <img src="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/apple/apple-original.svg" alt="macOS" style={{ width: "1em", height: "1em" }} className="theme-invert" />, name: "macOS", link: "https://www.apple.com/macos/" },
77
{ icon: <img src="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/linux/linux-original.svg" alt="Linux" style={{ width: "1em", height: "1em" }} />, name: "Linux", link: "https://ubuntu.com/desktop/" },
88
{ icon: <img src="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/jupyter/jupyter-original.svg" alt="Jupyter" style={{ width: "1em", height: "1em" }} />, name: "Jupyter", link: "https://jupyter.org/" },
99
{ icon: <img src="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/vscode/vscode-original.svg" alt="VS Code" style={{ width: "1em", height: "1em" }} />, name: "Visual Studio Code", link: "https://code.visualstudio.com/" },

src/components/Home/Home2.js

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,15 +82,16 @@ function Home2() {
8282
/>
8383
</Tilt>
8484
<div className="d-flex justify-content-center w-100 home-about-actions">
85-
<Button
86-
className="download-cv-button"
87-
variant="primary"
85+
<a
8886
href={cvFile}
8987
target="_blank"
88+
rel="noopener noreferrer"
89+
className="floating-nav-ghost-btn"
90+
style={{ maxWidth: '200px' }}
9091
>
9192
<AiOutlineDownload />
92-
&nbsp;Download CV
93-
</Button>
93+
<span>Download CV</span>
94+
</a>
9495
</div>
9596
</div>
9697
</Col>

src/components/MainFrame/Navbar.js

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
AiOutlineDownload,
1111
AiFillStar
1212
} from "react-icons/ai";
13-
import { MdWorkOutline } from "react-icons/md";
13+
import { MdWorkOutline, MdDarkMode, MdLightMode } from "react-icons/md";
1414
import { FiSidebar, FiMapPin, FiMail, FiPhone } from "react-icons/fi";
1515
import { FaLinkedinIn, FaWeixin } from "react-icons/fa";
1616
import { SiBilibili } from "react-icons/si";
@@ -155,7 +155,7 @@ function NavLinks({ items = NAV_ITEMS, linkClassName, iconClassName, onClick, na
155155
});
156156
}
157157

158-
function NavBar({ triggerPreloader }) {
158+
function NavBar({ triggerPreloader, theme, toggleTheme }) {
159159
const [isExpanded, setIsExpanded] = useState(false);
160160
const { isSideNavVisible, toggleSideNav } = useNavMode();
161161
const { isScrolled, isTopNavHidden, isBottomNavHidden } = useScrollHideNav({ isExpanded, setIsExpanded });
@@ -326,8 +326,16 @@ function NavBar({ triggerPreloader }) {
326326
</Navbar.Collapse>
327327
</div>
328328

329-
{/* Right Column: GitHub */}
330-
<div className="navbar-right-col">
329+
{/* Right Column: GitHub & Theme Toggle */}
330+
<div className="navbar-right-col" style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
331+
<button
332+
type="button"
333+
className="theme-toggle-btn"
334+
onClick={toggleTheme}
335+
aria-label="Toggle Theme"
336+
>
337+
{theme === "dark" ? <MdLightMode /> : <MdDarkMode />}
338+
</button>
331339
<a
332340
href="https://github.com/Magicherry/Bits-of-Me"
333341
target="_blank"
@@ -347,12 +355,18 @@ function NavBar({ triggerPreloader }) {
347355
<div className={`floating-nav-container ${isSideNavVisible ? "show" : ""}`}>
348356
<div className="floating-nav-panel">
349357
<div className="floating-nav-header">
350-
<span className="floating-nav-brand" onClick={() => { navigate("/"); if (triggerPreloader) { triggerPreloader(); } }}>
351-
Bits of Me
352-
</span>
353358
<button type="button" className="floating-nav-close" onClick={toggleSideNav} aria-label="Collapse to top navigation">
354359
<FiSidebar />
355360
</button>
361+
<button
362+
type="button"
363+
className="theme-toggle-btn"
364+
onClick={toggleTheme}
365+
aria-label="Toggle Theme"
366+
style={{ width: '40px', height: '40px', fontSize: '1.1rem' }}
367+
>
368+
{theme === "dark" ? <MdLightMode /> : <MdDarkMode />}
369+
</button>
356370
</div>
357371

358372
<div className="floating-nav-profile">
@@ -480,6 +494,16 @@ function NavBar({ triggerPreloader }) {
480494
/>
481495
</Nav>
482496
</div>
497+
498+
{/* Mobile Theme Toggle Button */}
499+
<button
500+
type="button"
501+
className="mobile-theme-toggle-btn"
502+
onClick={toggleTheme}
503+
aria-label="Toggle Theme"
504+
>
505+
{theme === "dark" ? <MdLightMode /> : <MdDarkMode />}
506+
</button>
483507
</div>
484508

485509
<Modal show={showWechatModal} onHide={() => setShowWechatModal(false)} centered>

src/components/MainFrame/Particle.js

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ import React, { useEffect, useMemo } from "react";
22
import Particles, { initParticlesEngine } from "@tsparticles/react";
33
import { loadSlim } from "@tsparticles/slim";
44

5-
function Particle() {
5+
function Particle({ theme }) {
66
useEffect(() => {
77
initParticlesEngine(loadSlim);
88
}, []);
99

10+
const particleColor = theme === "light" ? "#0284c7" : "#38bdf8";
11+
1012
const options = useMemo(
1113
() => ({
1214
fullScreen: {
@@ -21,13 +23,13 @@ function Particle() {
2123
fpsLimit: 120,
2224
particles: {
2325
color: {
24-
value: "#38bdf8", // 科技蓝
26+
value: particleColor,
2527
},
2628
links: {
27-
color: "#38bdf8",
28-
distance: 160, // 增加连线判定距离,让更多节点连接
29+
color: particleColor,
30+
distance: 160,
2931
enable: true,
30-
opacity: 0.15, // 稍微提高连线可见度
32+
opacity: 0.15,
3133
width: 1,
3234
},
3335
move: {
@@ -70,11 +72,11 @@ function Particle() {
7072
},
7173
detectRetina: true,
7274
}),
73-
[]
75+
[particleColor]
7476
);
7577

7678
return (
77-
<Particles id="tsparticles" options={options} />
79+
<Particles key={theme} id="tsparticles" options={options} />
7880
);
7981
}
8082

src/components/Projects/Projects.js

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { BsGridFill, BsListUl, BsChevronDown, BsCheck } from "react-icons/bs";
77
import { projects } from "./ProjectData";
88

99
const SORT_OPTIONS = [
10-
{ value: "default", label: "Default" },
1110
{ value: "dateDesc", label: "Newest first" },
1211
{ value: "dateAsc", label: "Oldest first" },
1312
];
@@ -22,7 +21,7 @@ const Projects = () => {
2221
const [viewMode, setViewMode] = useState(getInitialViewMode);
2322
const [isInitialLoad, setIsInitialLoad] = useState(true);
2423
const [selectedTags, setSelectedTags] = useState([]);
25-
const [sortBy, setSortBy] = useState("default");
24+
const [sortBy, setSortBy] = useState("dateDesc");
2625
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
2726
const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false);
2827
const dropdownRef = useRef(null);
@@ -35,7 +34,6 @@ const Projects = () => {
3534
: projects.filter(p => selectedTags.every(tag => p.tags.includes(tag)));
3635

3736
const sortedProjects = (() => {
38-
if (sortBy === "default") return filteredProjects;
3937
const year = (p) => parseInt(p.date, 10) || 0;
4038
if (sortBy === "dateDesc") return [...filteredProjects].sort((a, b) => year(b) - year(a));
4139
if (sortBy === "dateAsc") return [...filteredProjects].sort((a, b) => year(a) - year(b));
@@ -147,7 +145,7 @@ const Projects = () => {
147145
onClick={() => setIsSortDropdownOpen(!isSortDropdownOpen)}
148146
>
149147
<span className="filter-dropdown-label">
150-
Sort: {SORT_OPTIONS.find(o => o.value === sortBy)?.label ?? "Default"}
148+
Sort: {SORT_OPTIONS.find(o => o.value === sortBy)?.label ?? "Newest first"}
151149
</span>
152150
<BsChevronDown className="dropdown-icon" />
153151
</button>
@@ -170,12 +168,12 @@ const Projects = () => {
170168
)}
171169
</div>
172170

173-
{(selectedTags.length > 0 || sortBy !== "default") && (
171+
{(selectedTags.length > 0 || sortBy !== "dateDesc") && (
174172
<button
175173
className="filter-clear-btn"
176174
onClick={() => {
177175
setSelectedTags([]);
178-
setSortBy("default");
176+
setSortBy("dateDesc");
179177
}}
180178
>
181179
× Clear

src/components/Resume/ResumeNew.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,16 @@ const LoadingError = ({ message }) => (
3939

4040
const DownloadButton = () => (
4141
<div className="d-flex justify-content-center">
42-
<Button className="download-cv-button" variant="primary" href={pdf} target="_blank">
42+
<a
43+
href={pdf}
44+
target="_blank"
45+
rel="noopener noreferrer"
46+
className="floating-nav-ghost-btn"
47+
style={{ maxWidth: '200px' }}
48+
>
4349
<AiOutlineDownload />
44-
&nbsp;Download CV
45-
</Button>
50+
<span>Download CV</span>
51+
</a>
4652
</div>
4753
);
4854

0 commit comments

Comments
 (0)