Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useTheme } from './hooks/useTheme';
import Navbar from './components/Navbar';
import Hero from './components/Hero';
import SocialProof from './components/SocialProof';
Expand All @@ -8,9 +9,11 @@ import CallToAction from './components/CallToAction';
import Footer from './components/Footer';

function App() {
const { theme, toggleTheme } = useTheme();

return (
<div className="app">
<Navbar />
<Navbar theme={theme} toggleTheme={toggleTheme} />
<main>
<Hero />
<SocialProof />
Expand Down
7 changes: 6 additions & 1 deletion src/components/Navbar.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useState } from 'react';
import { NAV_LINKS, BRAND } from '../data/navigation';
import ThemeToggle from './ThemeToggle';

function Navbar() {
function Navbar({ theme, toggleTheme }) {
const [menuOpen, setMenuOpen] = useState(false);

return (
Expand Down Expand Up @@ -39,6 +40,10 @@ function Navbar() {
</li>
))}
</ul>

{/* Theme Toggle Button */}
<ThemeToggle theme={theme} toggleTheme={toggleTheme} />

<a href="#demo" className="btn btn--primary btn--sm navbar__cta">
預約 Demo
</a>
Expand Down
31 changes: 31 additions & 0 deletions src/components/ThemeToggle.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* ThemeToggle — 日/月圖示動畫切換按鈕
* 接收 theme ('dark'|'light') 與 toggleTheme callback
*/
function ThemeToggle({ theme, toggleTheme }) {
const isDark = theme === 'dark';

return (
<button
id="theme-toggle-btn"
className="theme-toggle"
onClick={toggleTheme}
aria-label={isDark ? '切換至淺色主題' : '切換至深色主題'}
aria-pressed={!isDark}
title={isDark ? '切換至淺色主題' : '切換至深色主題'}
>
<span className="theme-toggle__thumb" aria-hidden="true">
{/* 月亮(深色模式) */}
<span className="theme-toggle__icon theme-toggle__icon--moon">
🌙
</span>
{/* 太陽(淺色模式) */}
<span className="theme-toggle__icon theme-toggle__icon--sun">
☀️
</span>
</span>
</button>
);
}

export default ThemeToggle;
41 changes: 41 additions & 0 deletions src/hooks/useTheme.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { useState, useEffect } from 'react';

const STORAGE_KEY = 'sp-theme';

/**
* 讀取初始主題,優先順序:
* 1. localStorage 中的使用者偏好
* 2. OS 系統層級 prefers-color-scheme
* 3. 預設 'dark'
*/
function getInitialTheme() {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'light' || stored === 'dark') {
return stored;
}
if (window.matchMedia('(prefers-color-scheme: light)').matches) {
return 'light';
}
return 'dark';
}

/**
* useTheme Hook
* 管理主題狀態,同步至 <html data-theme="..."> 與 localStorage
*/
export function useTheme() {
const [theme, setTheme] = useState(() => getInitialTheme());

// 當 theme 改變時,同步至 html attribute 與 localStorage
useEffect(() => {
const root = document.documentElement;
root.setAttribute('data-theme', theme);
localStorage.setItem(STORAGE_KEY, theme);
}, [theme]);

function toggleTheme() {
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));
}

return { theme, toggleTheme };
}
159 changes: 152 additions & 7 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
======================================== */

/* --- CSS Custom Properties --- */
:root {

/* Dark Theme (Default) */
[data-theme="dark"] {
/* Colors */
--color-bg: #0a0e1a;
--color-bg-elevated: #111827;
Expand Down Expand Up @@ -31,6 +33,57 @@
--color-border: rgba(148, 163, 184, 0.12);
--color-border-hover: rgba(148, 163, 184, 0.25);

/* Navbar specific */
--color-navbar-bg: rgba(10, 14, 26, 0.85);

/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.4);
--shadow-glow: 0 0 40px var(--color-primary-glow);
}

/* Light Theme */
[data-theme="light"] {
/* Colors */
--color-bg: #f8faff;
--color-bg-elevated: #ffffff;
--color-bg-card: #ffffff;
--color-bg-card-hover: #f0f4ff;
--color-surface: #e8edfa;

--color-primary: #4f46e5;
--color-primary-light: #6366f1;
--color-primary-dark: #3730a3;
--color-primary-glow: rgba(79, 70, 229, 0.15);

--color-accent: #0891b2;
--color-accent-light: #06b6d4;

--color-success: #059669;
--color-warning: #d97706;
--color-error: #dc2626;

--color-text: #0f172a;
--color-text-secondary: #475569;
--color-text-muted: #94a3b8;
--color-text-inverse: #f1f5f9;

--color-border: rgba(15, 23, 42, 0.08);
--color-border-hover: rgba(15, 23, 42, 0.18);

/* Navbar specific */
--color-navbar-bg: rgba(248, 250, 255, 0.9);

/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.12);
--shadow-glow: 0 0 40px var(--color-primary-glow);
}

:root {

/* Typography */
--font-sans: 'Inter', 'Noto Sans TC', system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
Expand Down Expand Up @@ -68,11 +121,7 @@
--radius-2xl: 1.5rem;
--radius-full: 9999px;

/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.4);
--shadow-glow: 0 0 40px var(--color-primary-glow);
/* Note: Shadows are defined per theme above */

/* Transitions */
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
Expand Down Expand Up @@ -106,6 +155,8 @@ body {
background: var(--color-bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* Theme transition — spec: 300ms smooth */
transition: background-color 0.3s ease, color 0.3s ease;
}

img {
Expand Down Expand Up @@ -256,9 +307,10 @@ button {
right: 0;
height: var(--navbar-height);
z-index: 1000;
background: rgba(10, 14, 26, 0.8);
background: var(--color-navbar-bg);
backdrop-filter: blur(16px);
border-bottom: 1px solid var(--color-border);
transition: background-color 0.3s ease, border-color 0.3s ease;
}

.navbar__inner {
Expand Down Expand Up @@ -1243,3 +1295,96 @@ button {
grid-template-columns: 1fr;
}
}

/* ========================================
THEME TOGGLE BUTTON
======================================== */
.theme-toggle {
position: relative;
display: inline-flex;
align-items: center;
width: 52px;
height: 28px;
border-radius: var(--radius-full);
background: var(--color-surface);
border: 1.5px solid var(--color-border-hover);
cursor: pointer;
transition: background-color 0.3s ease, border-color 0.3s ease;
flex-shrink: 0;
padding: 0;
}

.theme-toggle:hover {
border-color: var(--color-primary-light);
background: var(--color-bg-card-hover);
}

.theme-toggle:focus-visible {
outline: 2px solid var(--color-primary-light);
outline-offset: 2px;
}

/* The sliding thumb */
.theme-toggle__thumb {
position: absolute;
left: 3px;
width: 20px;
height: 20px;
border-radius: 50%;
background: linear-gradient(135deg, var(--color-primary-light), var(--color-primary));
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1),
background 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
line-height: 1;
}

/* Slide to right when light theme active */
[data-theme="light"] .theme-toggle__thumb {
transform: translateX(24px);
background: linear-gradient(135deg, #f59e0b, #fbbf24);
}

/* Icon inside the thumb */
.theme-toggle__icon {
display: block;
transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s ease;
transform-origin: center;
}

.theme-toggle__icon--moon {
opacity: 1;
transform: rotate(0deg) scale(1);
}

.theme-toggle__icon--sun {
display: none;
}

[data-theme="light"] .theme-toggle__icon--moon {
display: none;
}

[data-theme="light"] .theme-toggle__icon--sun {
display: block;
opacity: 1;
transform: rotate(180deg) scale(1);
}

/* Track background glow */
.theme-toggle::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(90deg, var(--color-primary-glow), transparent);
opacity: 0;
transition: opacity 0.3s ease;
}

.theme-toggle:hover::before {
opacity: 1;
}