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
812 changes: 812 additions & 0 deletions index.html

Large diffs are not rendered by default.

351 changes: 66 additions & 285 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"@refinedev/simple-rest": "^4.5.0",
"axios": "^0.26.1",
"dayjs": "^1.10.7",
"framer-motion": "^12.35.2",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-router-dom": "^6.8.1"
Expand Down
19 changes: 7 additions & 12 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Authenticated, ErrorComponent, HttpError, Refine } from "@refinedev/core";
import { ErrorComponent, HttpError, Refine } from "@refinedev/core";
import routerProvider from "@refinedev/react-router-v6";
import { HashRouter, Routes, Route, Outlet, Navigate } from "react-router-dom";
import { HashRouter, Routes, Route } from "react-router-dom";
import axios, { AxiosRequestConfig } from "axios";

import { authProvider } from "./authProvider";
Expand All @@ -13,6 +13,8 @@ import { ProfilePage } from "./pages/profile";
import { SettingsPage } from "./pages/settings";
import { EditorPage, EditArticlePage } from "./pages/editor";
import { ArticlePage } from "./pages/article";
import { TopicListPage } from "./pages/topic";
import { TopicDetailPage } from "./pages/topic/detail";

import { TOKEN_KEY } from "./constants";

Expand Down Expand Up @@ -60,25 +62,18 @@ function App() {
authProvider={authProvider(axiosInstance)}
>
<Routes>
<Route
element={
<Layout>
<Outlet />
</Layout>
}
>
<Route element={<Layout />}>
<Route index element={<HomePage />} />

<Route path="editor" element={<EditorPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="article/:slug" element={<ArticlePage />} />
<Route path="profile/:username" element={<ProfilePage />} />
<Route path="profile/:username/:page" element={<ProfilePage />} />
<Route path="editor/:slug" element={<EditArticlePage />} />

<Route path="topics" element={<TopicListPage />} />
<Route path="topic/:slug" element={<TopicDetailPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />

<Route path="*" element={<ErrorComponent />} />
</Route>
</Routes>
Expand Down
196 changes: 196 additions & 0 deletions src/components/AnimatedPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import React, { useRef, useEffect, useCallback, useState } from "react";
import { motion, useMotionValue, animate, useTransform } from "framer-motion";
import { useNavigate } from "react-router-dom";

interface AnimatedPageProps {
children: React.ReactNode;
}

const pageVariants = {
initial: {
opacity: 0,
x: 20,
},
animate: {
opacity: 1,
x: 0,
},
exit: {
opacity: 0,
x: -20,
},
};

const pageTransition = {
type: "spring",
stiffness: 400,
damping: 35,
mass: 0.5,
duration: 0.25,
};

export const AnimatedPage: React.FC<AnimatedPageProps> = ({ children }) => {
const navigate = useNavigate();
const x = useMotionValue(0);
const [isSwipeBack, setIsSwipeBack] = useState(false);
const [canSwipeBack, setCanSwipeBack] = useState(false);
const isDragging = useRef(false);
const startX = useRef(0);
const startY = useRef(0);
const edgeWidth = 35;
const threshold = 100;

useEffect(() => {
setCanSwipeBack(window.history.length > 1);
}, []);

const shadowOpacity = useTransform(x, [0, 150], [0, 0.3]);
const prevPageScale = useTransform(x, [0, 150], [0.95, 1]);
const prevPageX = useTransform(x, [0, 150], [-50, 0]);

const handleTouchStart = useCallback((e: TouchEvent) => {
const touch = e.touches[0];
startX.current = touch.clientX;
startY.current = touch.clientY;

if (startX.current < edgeWidth && window.history.length > 1) {
isDragging.current = true;
setIsSwipeBack(true);
}
}, []);

const handleTouchMove = useCallback((e: TouchEvent) => {
if (!isDragging.current) return;

const touch = e.touches[0];
const deltaX = touch.clientX - startX.current;
const deltaY = touch.clientY - startY.current;

if (Math.abs(deltaY) > Math.abs(deltaX) * 0.6) {
isDragging.current = false;
setIsSwipeBack(false);
return;
}

if (deltaX > 0) {
e.preventDefault();
const dragDistance = Math.min(deltaX * 0.7, window.innerWidth * 0.6);
x.set(dragDistance);
}
}, [x]);

const handleTouchEnd = useCallback(() => {
if (!isDragging.current) return;
isDragging.current = false;

const currentX = x.get();

if (currentX > threshold) {
animate(x, window.innerWidth, {
duration: 0.25,
ease: "outQuad",
onComplete: () => {
navigate(-1);
},
});
} else {
animate(x, 0, {
duration: 0.35,
ease: "outElastic(1, 0.7)",
onComplete: () => {
setIsSwipeBack(false);
},
});
}
}, [navigate, x]);

useEffect(() => {
document.addEventListener("touchstart", handleTouchStart, { passive: true });
document.addEventListener("touchmove", handleTouchMove, { passive: false });
document.addEventListener("touchend", handleTouchEnd);

return () => {
document.removeEventListener("touchstart", handleTouchStart);
document.removeEventListener("touchmove", handleTouchMove);
document.removeEventListener("touchend", handleTouchEnd);
};
}, [handleTouchStart, handleTouchMove, handleTouchEnd]);

return (
<>
{isSwipeBack && canSwipeBack && (
<motion.div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "#000",
opacity: shadowOpacity,
pointerEvents: "none",
zIndex: 1,
}}
/>
)}

{isSwipeBack && canSwipeBack && (
<motion.div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "#f5f5f5",
x: prevPageX,
scale: prevPageScale,
pointerEvents: "none",
zIndex: 0,
borderRadius: "8px",
overflow: "hidden",
}}
>
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%",
color: "#9ca3af"
}}>
<i className="ion-ios-arrow-back" style={{ fontSize: "2rem" }}></i>
</div>
</motion.div>
)}

<motion.div
initial="initial"
animate="animate"
exit="exit"
variants={pageVariants}
transition={pageTransition}
className="animated-page"
style={{
x: isSwipeBack ? x : 0,
width: "100%",
minHeight: "100%",
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
overflow: "auto",
WebkitOverflowScrolling: "touch",
background: "#fff",
zIndex: isSwipeBack ? 2 : "auto",
boxShadow: isSwipeBack ? "-10px 0 40px rgba(0,0,0,0.2)" : "none",
}}
>
{children}
</motion.div>
</>
);
};

export { pageVariants, pageTransition };

59 changes: 59 additions & 0 deletions src/components/BottomNav.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useGetIdentity } from "@refinedev/core";
import { Link, useLocation } from "react-router-dom";
import { IUser } from "../interfaces";

export const BottomNav: React.FC = () => {
const { data: user } = useGetIdentity<IUser>();
const location = useLocation();
const isLoggedIn = !!user;

const isActive = (path: string) => {
return location.pathname === path || location.pathname.startsWith(path);
};

return (
<nav className="bottom-nav">
<Link to="/" className={`nav-item ${isActive("/") && !isActive("/topics") && !isActive("/profile") ? "active" : ""}`}>
<i className="ion-home"></i>
<span>首页</span>
</Link>

<Link to="/topics" className={`nav-item ${isActive("/topics") || isActive("/topic/") ? "active" : ""}`}>
<i className="ion-ios-list"></i>
<span>专题</span>
</Link>

{isLoggedIn ? (
<>
<Link to="/editor" className="nav-item create-btn">
<div className="create-btn-inner">
<i className="ion-compose"></i>
</div>
</Link>

<Link to="/settings" className={`nav-item ${isActive("/settings") ? "active" : ""}`}>
<i className="ion-gear-a"></i>
<span>设置</span>
</Link>

<Link to={`/profile/${user?.username}`} className={`nav-item ${isActive("/profile") ? "active" : ""}`}>
<img src={user?.image || "https://api.dicebear.com/7.x/avataaars/svg?seed=default"} alt="profile" className="nav-avatar" />
<span>我的</span>
</Link>
</>
) : (
<>
<Link to="/login" className="nav-item">
<i className="ion-log-in"></i>
<span>登录</span>
</Link>

<Link to="/register" className="nav-item">
<i className="ion-person-add"></i>
<span>注册</span>
</Link>
</>
)}
</nav>
);
};
18 changes: 17 additions & 1 deletion src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,26 @@
import { useGetIdentity } from "@refinedev/core";
import { Link } from "react-router-dom";
import { IUser } from "../interfaces";
import { useEffect, useState } from "react";

export const Header: React.FC = () => {
const { data: user } = useGetIdentity<IUser>();
const [isScrolled, setIsScrolled] = useState(false);

const isLoggedIn = !!user;

useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 10);
};

window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);

return (
<>
<nav className="navbar navbar-light">
<nav className={`navbar navbar-light ${isScrolled ? "navbar-scrolled" : ""}`}>
<div className="container">
<Link className="navbar-brand" to="/">
conduit
Expand All @@ -20,6 +31,11 @@ export const Header: React.FC = () => {
Home
</Link>
</li>
<li className="nav-item">
<Link className="nav-link" to="/topics">
专题
</Link>
</li>
{!isLoggedIn ? (
<>
<li className="nav-item">
Expand Down
19 changes: 16 additions & 3 deletions src/components/Layout.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
import { LayoutProps } from "@refinedev/core";
import { Outlet, useLocation } from "react-router-dom";
import { AnimatePresence } from "framer-motion";

import { Header, Footer } from "../components";
import { BottomNav } from "./BottomNav";
import { AnimatedPage } from "./AnimatedPage";

export const Layout: React.FC<LayoutProps> = () => {
const location = useLocation();

export const Layout: React.FC<LayoutProps> = ({ children }) => {
return (
<div>
<div className="app-container">
<Header />
<div>{children}</div>
<main className="main-content" style={{ position: "relative" }}>
<AnimatePresence mode="wait">
<AnimatedPage key={location.pathname}>
<Outlet />
</AnimatedPage>
</AnimatePresence>
</main>
<BottomNav />
<Footer />
</div>
);
Expand Down
Loading