diff --git a/package.json b/package.json index 0fc5fce..5a97ab2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "moodist", "type": "module", - "version": "2.4.0", + "version": "2.5.0", "scripts": { "dev": "astro dev", "start": "astro dev", diff --git a/src/components/about-unified.astro b/src/components/about-unified.astro index 0d029f5..8a0b5af 100644 --- a/src/components/about-unified.astro +++ b/src/components/about-unified.astro @@ -27,15 +27,15 @@ const count = soundCount(); position: sticky; top: 0; height: 80px; - background: linear-gradient(var(--color-neutral-50), transparent); + background: linear-gradient(var(--bg-secondary), transparent); } & .paragraph { padding: 30px 0; background: linear-gradient( transparent, - var(--color-neutral-50) 10%, - var(--color-neutral-50) 90%, + var(--bg-secondary) 10%, + var(--bg-secondary) 90%, transparent ); @@ -49,8 +49,8 @@ const count = soundCount(); margin-bottom: 16px; font-size: var(--font-xsm); color: var(--color-foreground-subtle); - background: linear-gradient(var(--color-neutral-100), transparent); - border: 1px solid var(--color-neutral-300); + background: linear-gradient(var(--bg-secondary), transparent); + border: 1px solid var(--color-border); border-radius: 20px 20px 20px 8px; & span { @@ -84,7 +84,7 @@ const count = soundCount(); color: var(--color-foreground); cursor: pointer; background-color: transparent; - border: 1px solid var(--color-neutral-200); + border: 1px solid var(--color-border); border-radius: 50px; outline: none; transition: 0.2s; @@ -99,7 +99,7 @@ const count = soundCount(); background: linear-gradient( 90deg, transparent, - var(--color-neutral-300), + var(--color-muted), transparent ); transform: translateX(-50%); @@ -107,11 +107,11 @@ const count = soundCount(); &:hover, &:focus-visible { - background-color: var(--color-neutral-100); + background-color: var(--bg-secondary); } &:focus-visible { - outline: 2px solid var(--color-neutral-400); + outline: 2px solid var(--color-muted); outline-offset: 2px; } } diff --git a/src/components/app/app.tsx b/src/components/app/app.tsx index 4926b4d..f2cf1be 100644 --- a/src/components/app/app.tsx +++ b/src/components/app/app.tsx @@ -16,8 +16,6 @@ import { SharedModal } from '@/components/modals/shared'; import { Toolbar } from '@/components/toolbar'; import { SnackbarProvider } from '@/contexts/snackbar'; import { MediaControls } from '@/components/media-controls'; -import { ThemeToggle } from '@/components/theme-toggle'; -import { AuthButton } from '@/components/auth-button'; import { sounds } from '@/data/sounds'; import { FADE_OUT } from '@/constants/events'; @@ -96,8 +94,6 @@ export function App() { return ( - -
diff --git a/src/components/language-switcher/language-switcher.module.css b/src/components/language-switcher/language-switcher.module.css index 06da8d1..3fdd196 100644 --- a/src/components/language-switcher/language-switcher.module.css +++ b/src/components/language-switcher/language-switcher.module.css @@ -1,43 +1,463 @@ +/* 头部控制容器 */ +.headerControls { + position: fixed; + top: 20px; + right: 20px; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 4px; + z-index: 1000; + background: var(--bg-secondary); + backdrop-filter: blur(10px); + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 8px; + min-width: 160px; +} + +/* 通用控制按钮样式 */ +.controlButton { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 12px; + padding: 10px 12px; + background: transparent; + border: none; + border-radius: 6px; + cursor: pointer; + color: var(--color-foreground); + transition: all 0.2s ease; + font-size: 14px; + line-height: 1; + width: 100%; + height: auto; + min-height: 40px; + position: relative; + text-align: left; +} + +.controlButton:hover { + background: var(--bg-tertiary); +} + +.controlButton:focus { + outline: 2px solid var(--color-foreground); + outline-offset: 2px; +} + +.controlButton:active { + transform: scale(0.95); +} + +/* 用户指示器 */ +.userIndicator { + position: absolute; + bottom: 2px; + right: 2px; + width: 8px; + height: 8px; + background: #10b981; + border-radius: 50%; + border: 2px solid var(--bg-secondary); +} + +/* 语言切换器样式 */ .languageSwitcher { display: flex; align-items: center; - gap: 8px; - padding: 6px 12px; - border: 1px solid var(--color-neutral-200); - border-radius: 8px; - background-color: var(--color-neutral-50); + justify-content: flex-start; + gap: 12px; + padding: 10px 12px; + border: none; + border-radius: 6px; + background-color: transparent; color: var(--color-foreground); - font-size: var(--font-xsm); + font-size: 14px; transition: all 0.2s ease; + cursor: pointer; + width: 100%; + text-align: left; +} + +.languageSwitcher:hover { + background: var(--bg-tertiary); } .icon { color: var(--color-foreground-subtle); font-size: 14px; + flex-shrink: 0; } .select { background: transparent; border: none; color: var(--color-foreground); - font-size: var(--font-xsm); + font-size: 14px; cursor: pointer; outline: none; - padding: 2px; + padding: 0; border-radius: 4px; - min-width: 80px; + min-width: 70px; + flex: 1; } .select:hover { - background-color: var(--color-neutral-100); + background-color: transparent; } .select:focus-visible { - outline: 2px solid var(--color-neutral-400); + outline: 2px solid var(--color-muted); outline-offset: 2px; } -.languageSwitcher:hover { - background-color: var(--color-neutral-100); - border-color: var(--color-neutral-300); +/* 认证表单样式 */ +.authFormOverlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1001; + padding: 16px; +} + +.authForm { + width: 100%; + max-width: 320px; + padding: 16px; + background: var(--component-bg); + border: 1px solid var(--color-border); + border-radius: 8px; + color: var(--color-foreground); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); +} + +.authForm h3 { + margin: 0 0 16px 0; + font-size: var(--font-lg); + font-weight: 600; + text-align: center; + color: var(--color-foreground); +} + +.authForm form { + display: flex; + flex-direction: column; + gap: 12px; +} + +.authInput { + padding: 8px 12px; + border: 1px solid var(--color-border); + border-radius: 6px; + font-size: var(--font-sm); + background: var(--input-bg); + color: var(--color-foreground); + transition: border-color 0.2s, box-shadow 0.2s; +} + +.authInput::placeholder { + color: var(--color-foreground-subtler); +} + +.authInput:focus { + outline: none; + border-color: var(--color-muted); + box-shadow: 0 0 0 2px var(--color-muted); +} + +.authButtons { + display: flex; + gap: 8px; +} + +.authSubmitButton { + flex: 1; + padding: 8px 16px; + background: var(--color-foreground); + color: var(--bg-primary); + border: none; + border-radius: 6px; + font-size: var(--font-sm); + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s; +} + +.authSubmitButton:hover:not(:disabled) { + background: var(--color-foreground-subtle); +} + +.authSubmitButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.authCancelButton { + padding: 8px 16px; + background: transparent; + color: var(--color-foreground-subtler); + border: 1px solid var(--color-border); + border-radius: 6px; + font-size: var(--font-sm); + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s, border-color 0.2s; +} + +.authCancelButton:hover { + background: var(--component-hover); + border-color: var(--color-foreground-subtle); +} + +.authToggle { + margin-top: 8px; +} + +.authToggleButton { + width: 100%; + padding: 6px 12px; + background: transparent; + color: var(--color-foreground-subtle); + border: none; + border-radius: 6px; + font-size: var(--font-xsm); + cursor: pointer; + text-decoration: underline; + transition: color 0.2s; +} + +.authToggleButton:hover { + color: var(--color-foreground); +} + +/* 用户菜单样式 */ +.userMenu { + position: fixed; + top: 70px; + right: 20px; + z-index: 999; +} + +.userInfo { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--component-bg); + border: 1px solid var(--color-border); + border-radius: 8px; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + min-width: 160px; +} + +.userAvatar { + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--color-foreground); + color: var(--bg-primary); + display: flex; + align-items: center; + justify-content: center; + font-size: var(--font-sm); + font-weight: 600; + flex-shrink: 0; +} + +.userName { + font-weight: 500; + color: var(--color-foreground); + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.logoutButton { + padding: 4px 8px; + font-size: var(--font-xsm); + color: #ef4444; + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s; +} + +.logoutButton:hover { + background: rgba(239, 68, 68, 0.1); +} + +/* 暗色主题下的特殊样式 */ +:global(.dark-theme) .headerControls { + background: var(--bg-secondary); + border-color: var(--color-border); +} + +:global(.dark-theme) .controlButton { + background: transparent; +} + +:global(.dark-theme) .controlButton:hover { + background: var(--bg-tertiary); +} + +:global(.dark-theme) .userIndicator { + border-color: var(--bg-secondary); +} + +:global(.dark-theme) .authForm { + background: var(--component-bg); + border-color: var(--color-border); +} + +:global(.dark-theme) .authInput { + background: var(--input-bg); + border-color: var(--color-border); +} + +:global(.dark-theme) .userInfo { + background: var(--component-bg); + border-color: var(--color-border); +} + +/* 提示通知样式 */ +.notification { + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + z-index: 1002; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + animation: notificationSlideIn 0.3s ease-out; + max-width: 400px; + width: calc(100vw - 40px); +} + +.notificationContent { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + gap: 12px; +} + +.notificationMessage { + flex: 1; + font-size: 14px; + font-weight: 500; + line-height: 1.4; +} + +.notificationClose { + background: none; + border: none; + font-size: 18px; + font-weight: bold; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + color: inherit; + opacity: 0.7; + transition: opacity 0.2s; +} + +.notificationClose:hover { + opacity: 1; +} + +/* 成功通知样式 */ +.notification.success { + background: linear-gradient(135deg, #10b981, #059669); + color: white; + border: 1px solid #059669; +} + +/* 错误通知样式 */ +.notification.error { + background: linear-gradient(135deg, #ef4444, #dc2626); + color: white; + border: 1px solid #dc2626; +} + +/* 通知动画 */ +@keyframes notificationSlideIn { + from { + transform: translateX(-50%) translateY(-100%); + opacity: 0; + } + to { + transform: translateX(-50%) translateY(0); + opacity: 1; + } +} + +/* 响应式设计 */ +@media (max-width: 768px) { + .headerControls { + top: 15px; + right: 15px; + padding: 6px; + gap: 3px; + min-width: 140px; + } + + .controlButton { + padding: 8px 10px; + font-size: 13px; + min-height: 36px; + gap: 10px; + } + + .languageSwitcher { + padding: 8px 10px; + gap: 10px; + font-size: 13px; + } + + .icon { + font-size: 12px; + } + + .select { + font-size: 13px; + min-width: 60px; + } + + .userMenu { + top: 60px; + right: 15px; + } + + .authFormOverlay { + padding: 12px; + } + + .authForm { + padding: 12px; + } + + .notification { + top: 15px; + max-width: calc(100vw - 30px); + width: calc(100vw - 30px); + } + + .notificationContent { + padding: 10px 14px; + } + + .notificationMessage { + font-size: 13px; + } } \ No newline at end of file diff --git a/src/components/language-switcher/language-switcher.tsx b/src/components/language-switcher/language-switcher.tsx index 641e35a..db25554 100644 --- a/src/components/language-switcher/language-switcher.tsx +++ b/src/components/language-switcher/language-switcher.tsx @@ -1,7 +1,11 @@ -import { FaGlobe } from 'react-icons/fa/index'; +import { useState, useEffect } from 'react'; +import { FaGlobe, FaSun, FaMoon, FaUser } from 'react-icons/fa/index'; +import { AnimatePresence, motion } from 'motion/react'; import { useTranslation } from '@/hooks/useTranslation'; +import { useAuthStore } from '@/stores/auth'; import styles from './language-switcher.module.css'; +import { fade } from '@/lib/motion'; interface LanguageSwitcherProps { className?: string; @@ -9,23 +13,323 @@ interface LanguageSwitcherProps { export function LanguageSwitcher({ className }: LanguageSwitcherProps) { const { currentLang, changeLanguage, t } = useTranslation(); + const { isAuthenticated, user, login, register, logout, isLoading, checkAuth, error, clearError } = useAuthStore(); + const [isDarkTheme, setIsDarkTheme] = useState(false); + const [showAuthForm, setShowAuthForm] = useState(false); + const [isLogin, setIsLogin] = useState(true); + const [showNotification, setShowNotification] = useState(false); + const [notificationMessage, setNotificationMessage] = useState(''); + const [notificationType, setNotificationType] = useState<'success' | 'error'>('success'); + const [showUserMenu, setShowUserMenu] = useState(false); + const [formData, setFormData] = useState({ + username: '', + password: '', + }); + + // 认证状态检查 + useEffect(() => { + checkAuth(); + }, []); + + // 点击外部关闭用户菜单 + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Element; + if (showUserMenu && !target.closest(`.${styles.headerControls}`) && !target.closest(`.${styles.userMenu}`)) { + setShowUserMenu(false); + } + }; + + if (showUserMenu) { + document.addEventListener('click', handleClickOutside); + } + + return () => { + document.removeEventListener('click', handleClickOutside); + }; + }, [showUserMenu]); + + // 主题切换逻辑 + useEffect(() => { + const savedTheme = localStorage.getItem('theme'); + const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + const initialDarkTheme = savedTheme ? savedTheme === 'dark' : systemPrefersDark; + setIsDarkTheme(initialDarkTheme); + applyTheme(initialDarkTheme); + }, []); + + const applyTheme = (isDark: boolean) => { + const root = document.documentElement; + const body = document.body; + + if (isDark) { + root.classList.add('dark-theme'); + root.style.setProperty('--bg-primary', '#0d1117'); + root.style.setProperty('--bg-secondary', '#161b22'); + root.style.setProperty('--bg-tertiary', '#21262d'); + root.style.setProperty('--bg-quaternary', '#30363d'); + root.style.setProperty('--color-foreground', '#f0f6fc'); + root.style.setProperty('--color-foreground-subtle', '#8b949e'); + root.style.setProperty('--color-foreground-subtler', '#6e7681'); + root.style.setProperty('--color-muted', '#484f58'); + root.style.setProperty('--color-border', '#30363d'); + root.style.setProperty('--component-bg', '#161b22'); + root.style.setProperty('--component-hover', '#21262d'); + root.style.setProperty('--component-active', '#30363d'); + root.style.setProperty('--modal-bg', '#0d1117'); + root.style.setProperty('--input-bg', '#0d1117'); + body.style.backgroundColor = '#0d1117'; + } else { + root.classList.remove('dark-theme'); + root.style.setProperty('--bg-primary', '#ffffff'); + root.style.setProperty('--bg-secondary', '#f8fafc'); + root.style.setProperty('--bg-tertiary', '#f1f5f9'); + root.style.setProperty('--bg-quaternary', '#e2e8f0'); + root.style.setProperty('--color-foreground', '#1e293b'); + root.style.setProperty('--color-foreground-subtle', '#475569'); + root.style.setProperty('--color-foreground-subtler', '#64748b'); + root.style.setProperty('--color-muted', '#94a3b8'); + root.style.setProperty('--color-border', '#cbd5e1'); + root.style.setProperty('--component-bg', '#ffffff'); + root.style.setProperty('--component-hover', '#f8fafc'); + root.style.setProperty('--component-active', '#f1f5f9'); + root.style.setProperty('--modal-bg', '#ffffff'); + root.style.setProperty('--input-bg', '#ffffff'); + body.style.backgroundColor = '#ffffff'; + } + }; + + const toggleTheme = () => { + const newTheme = !isDarkTheme; + setIsDarkTheme(newTheme); + applyTheme(newTheme); + localStorage.setItem('theme', newTheme ? 'dark' : 'light'); + }; + + // 显示提示信息 + const showNotificationMessage = (message: string, type: 'success' | 'error') => { + setNotificationMessage(message); + setNotificationType(type); + setShowNotification(true); + + // 3秒后自动关闭 + setTimeout(() => { + setShowNotification(false); + }, 3000); + }; + + // 认证逻辑 + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + clearError(); // 清除之前的错误 + + try { + if (isLogin) { + await login(formData); + showNotificationMessage('登录成功!', 'success'); + setShowAuthForm(false); + setFormData({ username: '', password: '' }); + } else { + await register(formData); + showNotificationMessage('注册成功!', 'success'); + setShowAuthForm(false); + setFormData({ username: '', password: '' }); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '认证失败'; + showNotificationMessage(errorMessage, 'error'); + console.error('认证失败:', error); + // 认证失败时不关闭弹窗,让用户重新尝试 + } + }; + + const handleChange = (e: React.ChangeEvent) => { + setFormData({ + ...formData, + [e.target.name]: e.target.value, + }); + }; + + const handleLogout = () => { + logout(); + }; + + const handleAuthClick = () => { + if (isAuthenticated) { + setShowUserMenu(!showUserMenu); + } else { + setShowAuthForm(true); + } + }; const handleLanguageChange = (e: React.ChangeEvent) => { changeLanguage(e.target.value); }; + const variants = fade(); + return ( -
- - -
+ <> +
+ {/* 语言切换器 */} +
+ + +
+ + {/* 主题切换按钮 */} + + + {/* 登录按钮 */} + + + {isAuthenticated && user && ( + + )} + + {isAuthenticated ? (user?.username || '用户') : '登录'} + + +
+ + {/* 认证表单模态框 */} + {showAuthForm && ( +
setShowAuthForm(false)}> +
e.stopPropagation()}> +

{isLogin ? '登录' : '注册'}

+
+ + +
+ + +
+
+ +
+
+
+
+ )} + + {/* 用户菜单 - 下拉菜单 */} + {isAuthenticated && showUserMenu && ( +
+
+
+ {user?.username.charAt(0).toUpperCase()} +
+ {user?.username} + +
+
+ )} + + {/* 提示通知 */} + {showNotification && ( +
+
+ + {notificationMessage} + + +
+
+ )} + ); } \ No newline at end of file diff --git a/src/layouts/layout.astro b/src/layouts/layout.astro index c2b8673..2c9d413 100644 --- a/src/layouts/layout.astro +++ b/src/layouts/layout.astro @@ -54,28 +54,12 @@ const lang = ['en', 'zh-CN'].includes(langParam) ? langParam : 'en'; {pwaInfo && } - + diff --git a/src/pages/api/auth/login.ts b/src/pages/api/auth/login.ts index 9b57ae1..544943b 100644 --- a/src/pages/api/auth/login.ts +++ b/src/pages/api/auth/login.ts @@ -3,7 +3,16 @@ import { authenticateUser } from '@/lib/database'; export const POST: APIRoute = async ({ request }) => { try { - const { username, password } = await request.json(); + const body = await request.text(); + + if (!body.trim()) { + return new Response(JSON.stringify({ error: '请求体不能为空' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const { username, password } = JSON.parse(body); // 验证输入 if (!username || !password) { @@ -38,7 +47,15 @@ export const POST: APIRoute = async ({ request }) => { } catch (error) { console.error('登录错误:', error); - return new Response(JSON.stringify({ error: '登录失败,请稍后再试' }), { + let errorMessage = '登录失败,请稍后再试'; + + if (error instanceof SyntaxError && error.message.includes('JSON')) { + errorMessage = '请求格式错误'; + } else if (error instanceof Error) { + errorMessage = error.message; + } + + return new Response(JSON.stringify({ error: errorMessage }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); diff --git a/src/pages/api/auth/register.ts b/src/pages/api/auth/register.ts index 204912d..45d64d7 100644 --- a/src/pages/api/auth/register.ts +++ b/src/pages/api/auth/register.ts @@ -3,7 +3,16 @@ import { createUser } from '@/lib/database'; export const POST: APIRoute = async ({ request }) => { try { - const { username, password } = await request.json(); + const body = await request.text(); + + if (!body.trim()) { + return new Response(JSON.stringify({ error: '请求体不能为空' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const { username, password } = JSON.parse(body); // 验证输入 if (!username || !password) { @@ -45,14 +54,21 @@ export const POST: APIRoute = async ({ request }) => { } catch (error) { console.error('注册错误:', error); - if (error instanceof Error && error.message === '用户名已存在') { - return new Response(JSON.stringify({ error: '用户名已存在' }), { + let errorMessage = '注册失败,请稍后再试'; + + if (error instanceof SyntaxError && error.message.includes('JSON')) { + errorMessage = '请求格式错误'; + } else if (error instanceof Error && error.message === '用户名已存在') { + errorMessage = '用户名已存在'; + return new Response(JSON.stringify({ error: errorMessage }), { status: 409, headers: { 'Content-Type': 'application/json' }, }); + } else if (error instanceof Error) { + errorMessage = error.message; } - return new Response(JSON.stringify({ error: '注册失败,请稍后再试' }), { + return new Response(JSON.stringify({ error: errorMessage }), { status: 500, headers: { 'Content-Type': 'application/json' }, });