diff --git a/frontend/app/index.css b/frontend/app/index.css index b39a8dd..8df6e9a 100644 --- a/frontend/app/index.css +++ b/frontend/app/index.css @@ -21,121 +21,3 @@ body { height: 100%; } -/* Logo 动画 */ -.animated-text { - max-width: 100%; - height: auto; -} - -.animated-text path { - fill: transparent; - stroke: currentColor; - stroke-width: 2; - stroke-dasharray: var(--path-length); - stroke-dashoffset: var(--path-length); - animation: logo-anim 15s cubic-bezier(0.4, 0, 0.2, 1) infinite; - transform-origin: center; - stroke-linecap: round; - stroke-linejoin: round; -} - -@keyframes logo-anim { - 0% { - stroke-dashoffset: var(--path-length); - stroke-dasharray: var(--path-length) var(--path-length); - fill: transparent; - opacity: 0; - } - - 5% { - opacity: 1; - stroke-dashoffset: var(--path-length); - stroke-dasharray: var(--path-length) var(--path-length); - } - - 50% { - stroke-dashoffset: 0; - stroke-dasharray: var(--path-length) var(--path-length); - fill: transparent; - } - - 60%, 75% { - stroke-dashoffset: 0; - stroke-dasharray: var(--path-length) var(--path-length); - fill: currentColor; - opacity: 1; - } - - 85% { - stroke-dashoffset: 0; - stroke-dasharray: var(--path-length) var(--path-length); - fill: transparent; - opacity: 1; - } - - 95% { - stroke-dashoffset: var(--path-length); - stroke-dasharray: var(--path-length) var(--path-length); - fill: transparent; - opacity: 1; - } - - 100% { - stroke-dashoffset: var(--path-length); - stroke-dasharray: var(--path-length) var(--path-length); - fill: transparent; - opacity: 0; - } -} - -/* 确保在暗色模式下的颜色正确 */ -@media (prefers-color-scheme: dark) { - .animated-text path { - stroke: currentColor; - } -} - - - -/* 先确保基本动画工作后再添加脉动效果 */ -.root { - fill: none; - stroke: var(--accent-9); - stroke-width: 1px; - stroke-linecap: round; - opacity: 0; - stroke-dasharray: 50; - stroke-dashoffset: 50; - animation: rootGrow 0.8s ease-out forwards var(--delay); -} - -@keyframes rootGrow { - 0% { - opacity: 0; - stroke-dashoffset: 50; - stroke-width: 0.5px; - } - 100% { - opacity: 0.6; - stroke-dashoffset: 0; - stroke-width: 1px; - } -} - -@keyframes growPath { - 0% { - stroke-dashoffset: 100%; - } - 100% { - stroke-dashoffset: 0; - } -} - -@keyframes fadeIn { - 0% { - opacity: 0; - } - 100% { - opacity: 0.6; - } -} diff --git a/frontend/app/routes.tsx b/frontend/app/routes.tsx index bec67ff..2066b72 100644 --- a/frontend/app/routes.tsx +++ b/frontend/app/routes.tsx @@ -1,8 +1,8 @@ import ErrorPage from "hooks/error"; -import Layout from "themes/echoes/layout"; +import layout from "themes/echoes/layout"; export default function Routes() { - return Layout.render({ + return layout.element({ children: <>, args: { title: "我的页面", diff --git a/frontend/hooks/echoes.tsx b/frontend/hooks/echoes.tsx index f21030c..7206efc 100644 --- a/frontend/hooks/echoes.tsx +++ b/frontend/hooks/echoes.tsx @@ -1,4 +1,5 @@ import React from "react"; +import "styles/echoes.css" export const Echoes: React.FC = () => { return ( diff --git a/frontend/hooks/error.tsx b/frontend/hooks/error.tsx index 3b1fdf9..89ee11b 100644 --- a/frontend/hooks/error.tsx +++ b/frontend/hooks/error.tsx @@ -20,18 +20,18 @@ const ErrorPage = () => { }, []); return ( -
+
-

+

{text} |

-

+

抱歉,您访问的页面已经离家出走了

diff --git a/frontend/hooks/themeMode.tsx b/frontend/hooks/themeMode.tsx index 6c4af42..ea10875 100644 --- a/frontend/hooks/themeMode.tsx +++ b/frontend/hooks/themeMode.tsx @@ -6,7 +6,6 @@ const THEME_KEY = "theme-preference"; export const ThemeModeToggle: React.FC = () => { const [mounted, setMounted] = useState(false); - const [visible, setVisible] = useState(true); const [isDark, setIsDark] = useState(false); useEffect(() => { @@ -24,15 +23,7 @@ export const ThemeModeToggle: React.FC = () => { document.documentElement.className = initialTheme; } - let lastScroll = 0; - const handleScroll = () => { - const currentScroll = window.scrollY; - setVisible(currentScroll <= lastScroll || currentScroll < 50); - lastScroll = currentScroll; - }; - window.addEventListener("scroll", handleScroll); - return () => window.removeEventListener("scroll", handleScroll); }, []); const toggleTheme = () => { @@ -49,15 +40,13 @@ export const ThemeModeToggle: React.FC = () => { ); diff --git a/frontend/hooks/tide.tsx b/frontend/hooks/tide.tsx deleted file mode 100644 index 090d89c..0000000 --- a/frontend/hooks/tide.tsx +++ /dev/null @@ -1,239 +0,0 @@ -"use client"; -import React, { useEffect, useRef } from "react"; - -const Tide: React.FC = () => { - const svgRef = useRef(null); - const containerRef = useRef(null); - const dimensionsRef = useRef({ width: 1000, height: 800 }); - const pathCountRef = useRef(0); - - useEffect(() => { - const updateDimensions = () => { - if (!containerRef.current) return; - const rect = containerRef.current.getBoundingClientRect(); - dimensionsRef.current = { - width: rect.width, - height: rect.height, - }; - - if (svgRef.current) { - svgRef.current.setAttribute( - "viewBox", - `0 0 ${dimensionsRef.current.width} ${dimensionsRef.current.height}`, - ); - } - }; - - const createLine = ( - startX: number, - startY: number, - endX: number, - endY: number, - width: number, - alpha: number = 0.3, - animationDelay: number = 0, - ) => { - if (!svgRef.current || pathCountRef.current > 500) return; - - const path = document.createElementNS( - "http://www.w3.org/2000/svg", - "path", - ); - - const midX = (startX + endX) / 2; - const midY = (startY + endY) / 2; - const controlX = midX + (Math.random() - 0.5) * 2; - const controlY = midY + (Math.random() - 0.5) * 2; - - const d = `M ${startX} ${startY} Q ${controlX} ${controlY}, ${endX} ${endY}`; - - path.setAttribute("d", d); - path.setAttribute("stroke", "var(--accent-9)"); - path.setAttribute("stroke-width", "1"); - path.setAttribute("stroke-linecap", "round"); - path.setAttribute("fill", "none"); - - const length = path.getTotalLength(); - path.style.strokeDasharray = `${length}`; - path.style.strokeDashoffset = `${length}`; - path.style.opacity = "0"; - path.style.transition = ` - stroke-dashoffset 0.8s ease-out ${animationDelay}s, - opacity 0.8s ease-out ${animationDelay}s - `; - - svgRef.current.appendChild(path); - pathCountRef.current += 1; - - setTimeout(() => { - path.style.strokeDashoffset = "0"; - path.style.opacity = "0.6"; - }, 10); - }; - - const createRoot = ( - startX: number, - startY: number, - baseAngle: number, - length: number, - width: number, - depth: number, - animationDelay: number = 0, - ) => { - if (depth <= 0 || !svgRef.current || pathCountRef.current > 600) return; - - const endX = startX + Math.cos(baseAngle) * length; - const endY = startY - Math.sin(baseAngle) * length; - - if ( - endX < 0 || - endX > dimensionsRef.current.width || - endY < 0 || - endY > dimensionsRef.current.height - ) - return; - - createLine(startX, startY, endX, endY, width, 0.6, animationDelay); - - const growthDelay = 0.3; - const newDelay = animationDelay + growthDelay; - - setTimeout(() => { - if (depth > 0) { - createRoot( - endX, - endY, - baseAngle + (Math.random() * 0.08 - 0.04), - length * 0.99, - width * 0.99, - depth - 1, - newDelay, - ); - } - - const branchProbability = depth > 20 ? 0.3 : 0.2; - - if (depth > 5 && depth < 35 && Math.random() < branchProbability) { - const direction = Math.random() > 0.5 ? 1 : -1; - const branchAngle = - baseAngle + - direction * (Math.PI / 6 + (Math.random() * Math.PI) / 12); - - setTimeout(() => { - createRoot( - endX, - endY, - branchAngle, - length * 0.85, - width * 0.85, - Math.floor(depth * 0.8), - newDelay + 0.2, - ); - }, 150); - } - }, growthDelay * 1000); - }; - - const startGrowth = () => { - if (!svgRef.current) return; - svgRef.current.innerHTML = ""; - pathCountRef.current = 0; - updateDimensions(); - - const { width, height } = dimensionsRef.current; - - const edge = Math.floor(Math.random() * 4); - let startX, startY, baseAngle; - - const margin = 50; - const randomPos = Math.random(); - - switch (edge) { - case 0: - startX = margin + (width - 2 * margin) * randomPos; - startY = 0; - baseAngle = Math.PI / 2; - break; - case 1: - startX = width; - startY = margin + (height - 2 * margin) * randomPos; - baseAngle = Math.PI; - break; - case 2: - startX = margin + (width - 2 * margin) * randomPos; - startY = height; - baseAngle = -Math.PI / 2; - break; - default: - startX = 0; - startY = margin + (height - 2 * margin) * randomPos; - baseAngle = 0; - break; - } - - const angleVariation = Math.random() * 0.4 - 0.2; - - const minDepth = 25; - const maxDepth = 45; - const depth = - minDepth + Math.floor(Math.random() * (maxDepth - minDepth)); - - const initialLength = 15 + Math.random() * 5; - const initialWidth = 0.8 + Math.random() * 0.4; - - createRoot( - startX, - startY, - baseAngle + angleVariation, - initialLength, - initialWidth, - depth, - 0, - ); - }; - - if (typeof window !== "undefined") { - const resizeObserver = new ResizeObserver(() => { - updateDimensions(); - startGrowth(); - }); - - if (containerRef.current) { - resizeObserver.observe(containerRef.current); - } - - setTimeout(startGrowth, 100); - - return () => resizeObserver.disconnect(); - } - }, []); - - return ( -
- -
- ); -}; - -export default Tide; diff --git a/frontend/package.json b/frontend/package.json index b55053f..de9543c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,6 +31,7 @@ "@remix-run/dev": "^2.14.0", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", + "@types/lodash": "^4.17.13", "@types/react": "^18.2.20", "@types/react-dom": "^18.2.7", "@typescript-eslint/eslint-plugin": "^6.7.4", diff --git a/frontend/styles/echoes.css b/frontend/styles/echoes.css new file mode 100644 index 0000000..022bd66 --- /dev/null +++ b/frontend/styles/echoes.css @@ -0,0 +1,72 @@ +.animated-text { + max-width: 100%; + height: auto; + } + + .animated-text path { + fill: transparent; + stroke: currentColor; + stroke-width: 2; + stroke-dasharray: var(--path-length); + stroke-dashoffset: var(--path-length); + animation: logo-anim 10s cubic-bezier(0.4, 0, 0.2, 1) infinite; + transform-origin: center; + stroke-linecap: round; + stroke-linejoin: round; + } + + @keyframes logo-anim { + 0% { + stroke-dashoffset: var(--path-length); + stroke-dasharray: var(--path-length) var(--path-length); + fill: transparent; + opacity: 0; + } + + 5% { + opacity: 1; + stroke-dashoffset: var(--path-length); + stroke-dasharray: var(--path-length) var(--path-length); + } + + 50% { + stroke-dashoffset: 0; + stroke-dasharray: var(--path-length) var(--path-length); + fill: transparent; + } + + 60%, 75% { + stroke-dashoffset: 0; + stroke-dasharray: var(--path-length) var(--path-length); + fill: currentColor; + opacity: 1; + } + + 85% { + stroke-dashoffset: 0; + stroke-dasharray: var(--path-length) var(--path-length); + fill: transparent; + opacity: 1; + } + + 95% { + stroke-dashoffset: var(--path-length); + stroke-dasharray: var(--path-length) var(--path-length); + fill: transparent; + opacity: 1; + } + + 100% { + stroke-dashoffset: var(--path-length); + stroke-dasharray: var(--path-length) var(--path-length); + fill: transparent; + opacity: 0; + } + } + + @media (prefers-color-scheme: dark) { + .animated-text path { + stroke: currentColor; + } + } + \ No newline at end of file diff --git a/frontend/themes/echoes/layout.tsx b/frontend/themes/echoes/layout.tsx index 7c7c425..59ce81e 100644 --- a/frontend/themes/echoes/layout.tsx +++ b/frontend/themes/echoes/layout.tsx @@ -1,131 +1,231 @@ import { Layout } from "interface/layout"; import { ThemeModeToggle } from "hooks/themeMode"; import { Echoes } from "hooks/echoes"; -import Tide from "hooks/tide"; -import { - Container, - Flex, - Box, - Link, - TextField, - DropdownMenu, -} from "@radix-ui/themes"; +import { Container, Flex, Box, Link, TextField } from "@radix-ui/themes"; import { MagnifyingGlassIcon, HamburgerMenuIcon, Cross1Icon, PersonIcon, - CheckIcon, AvatarIcon, } from "@radix-ui/react-icons"; import { Theme } from "@radix-ui/themes"; -import { useState } from "react"; +import { useState, useEffect } from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import throttle from "lodash/throttle"; +import "./styles/layouts.css"; -export default new Layout(({ children, args }) => { +// 直接导出 Layout 实例 +const EchoesLayout = new Layout(({ children, args }) => { const [moreState, setMoreState] = useState(false); - const [loginState, setLoginState] = useState(false); + const [loginState, setLoginState] = useState(true); + const [device, setDevice] = useState(""); + + // 添加窗口尺寸变化监听 + useEffect(() => { + // 立即执行一次设备检测 + if (window.innerWidth >= 1024) { + setDevice("desktop"); + } else { + setDevice("mobile"); + } + + // 创建节流函数,200ms 内只执行一次 + const handleResize = throttle(() => { + if (window.innerWidth >= 1024) { + setDevice("desktop"); + } else { + setDevice("mobile"); + } + }, 200); + + window.addEventListener("resize", handleResize); + + return () => { + window.removeEventListener("resize", handleResize); + handleResize.cancel(); + }; + }, []); + return ( - + {/* 导航栏 */} - - - {/* Logo 区域 */} - - - - - - - + {/* 主要内容区域 */} - - +
{children}
@@ -133,3 +233,5 @@ export default new Layout(({ children, args }) => {
); }); + +export default EchoesLayout; diff --git a/frontend/themes/echoes/styles/layouts.css b/frontend/themes/echoes/styles/layouts.css new file mode 100644 index 0000000..888d280 --- /dev/null +++ b/frontend/themes/echoes/styles/layouts.css @@ -0,0 +1,33 @@ +* { + color: var(--gray-a12); +} + +#nav a { + position: relative; + transition: opacity 0.2s ease; +} + +#nav a:hover { + opacity: 0.8; +} + +#nav a::after { + content: ""; + position: absolute; + left: 0; + bottom: -3px; + width: 100%; + height: 2px; + background-color: var(--gray-a11); + transform: scaleX(0); + transition: transform 0.3s ease; +} + +#nav a:hover::after { + transform: scaleX(1); +} + + +#search { + color: var(--gray-a12); +}